feat: v2 Vue3 frontend + multiple optimizations

- New Vue 3 + Vite frontend at /v2/ (OLED dark theme, Fira Sans/Code)
- Date selector: support day/week/month range (backend unchanged)
- SSE auto-reconnect (up to 3 retries)
- Visibility polling pause (dashboard pauses when tab hidden)
- Friendly Chinese HTTP error messages
- Cancel task with confirmation in Dashboard
- Split AiWorkflowService (1700->845 lines):
  - AiApiService: AI API calls + streaming
  - ExcelExportService: POI Excel generation
- Dockerfile: 3-stage build (Node frontend -> Maven -> JRE)
- WebApplication.java: System.out -> Logger
- .gitignore: v2 build output, backup dirs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-06-08 15:12:52 +08:00
parent c9c40869d7
commit 1b182c2930
37 changed files with 4782 additions and 1913 deletions
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="app-layout">
<a href="#main-content" class="skip-link" @click.prevent="focusMain">跳到主内容</a>
<AppSidebar />
<main class="main-area" id="main-content" tabindex="-1">
<header class="main-header">
<h2>{{ route.meta.title || 'SVN 工作台' }}</h2>
<p>{{ route.meta.desc || '' }}</p>
</header>
<div class="main-content">
<router-view />
</div>
</main>
<div class="toast-container" aria-live="polite">
<div
v-for="t in toastQueue"
:key="t.id"
:class="['toast-item', t.isError ? 'toast-error' : 'toast-success']"
>
<span>{{ t.isError ? '!' : '✓' }}</span>
<span>{{ t.message }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue'
import { useToast } from './composables/useApi'
const route = useRoute()
const { toastQueue } = useToast()
function focusMain() {
document.getElementById('main-content')?.focus()
}
</script>
<style scoped>
.skip-link {
position: fixed;
top: -100%;
left: 8px;
z-index: 10000;
background: var(--c-primary);
color: #fff;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 600;
text-decoration: none;
font-family: var(--font-sans);
}
.skip-link:focus {
top: 8px;
}
#main-content:focus {
outline: none;
}
</style>
@@ -0,0 +1,30 @@
<template>
<aside class="sidebar" aria-label="主导航">
<div class="sidebar-brand">
<div class="sidebar-brand-icon" aria-hidden="true">S</div>
<div class="sidebar-brand-text">
<h1>SVN 工作台</h1>
<p>v2 · Log &amp; Analysis</p>
</div>
</div>
<nav class="sidebar-nav">
<router-link class="sidebar-link" to="/dashboard" exact-active-class="active" aria-label="工作台">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>工作台</span>
</router-link>
<router-link class="sidebar-link" to="/svn-fetch" active-class="active" aria-label="SVN 日志抓取">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>SVN 日志抓取</span>
</router-link>
<router-link class="sidebar-link" to="/history" active-class="active" aria-label="任务历史">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>任务历史</span>
</router-link>
<router-link class="sidebar-link" to="/settings" active-class="active" aria-label="系统设置">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>系统设置</span>
</router-link>
</nav>
<div class="sidebar-footer">SVN Log Tool v2</div>
</aside>
</template>
+76
View File
@@ -0,0 +1,76 @@
import { ref } from 'vue'
const toastQueue = ref([])
let toastId = 0
export function useToast() {
function toast(message, isError = false) {
const id = ++toastId
toastQueue.value.push({ id, message, isError })
setTimeout(() => {
const idx = toastQueue.value.findIndex(t => t.id === id)
if (idx >= 0) toastQueue.value.splice(idx, 1)
}, 3500)
}
return { toastQueue, toast }
}
const HTTP_ERRORS = {
400: '请求参数有误,请检查输入',
401: '认证失败,请检查 API Key 配置',
403: '无权限访问',
404: '请求的资源不存在',
413: '文件过大,请减小输入文件',
429: '请求过于频繁,请稍后重试',
500: '服务器内部错误,请稍后重试',
502: '服务暂时不可用,请稍后重试',
503: '服务暂时不可用,请稍后重试',
}
export function useApi() {
async function apiFetch(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const friendly = HTTP_ERRORS[res.status] || `请求失败 (${res.status})`
throw new Error(body.error || body.message || friendly)
}
const text = await res.text()
return text ? JSON.parse(text) : {}
}
function buildDownloadUrl(path) {
return `/api/files/download?path=${encodeURIComponent(path || '')}`
}
async function downloadFile(path) {
const response = await fetch(buildDownloadUrl(path), {
headers: { Accept: 'application/octet-stream' },
})
if (!response.ok) throw new Error(`下载失败: ${response.status}`)
// Check content type to avoid HTML error pages
const ct = (response.headers.get('Content-Type') || '').toLowerCase()
if (ct.includes('text/html')) {
throw new Error('下载接口返回了 HTML 错误页')
}
const blob = await response.blob()
const cd = response.headers.get('Content-Disposition') || ''
const match = cd.match(/filename\*=UTF-8''([^;]+)/i)
let name = path.split('/').filter(Boolean).pop() || 'download'
if (match) try { name = decodeURIComponent(match[1]) } catch {}
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = name
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(blobUrl)
}
return { apiFetch, buildDownloadUrl, downloadFile }
}
+25
View File
@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import DashboardView from './views/DashboardView.vue'
import SvnFetchView from './views/SvnFetchView.vue'
import HistoryView from './views/HistoryView.vue'
import SettingsView from './views/SettingsView.vue'
import './styles/main.css'
const routes = [
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'dashboard', component: DashboardView, meta: { title: '工作台', desc: '查看系统状态与最近产物' } },
{ path: '/svn-fetch', name: 'svn-fetch', component: SvnFetchView, meta: { title: 'SVN 日志抓取', desc: '一键抓取 SVN 日志并导出工作量 Excel' } },
{ path: '/history', name: 'history', component: HistoryView, meta: { title: '任务历史', desc: '查看任务执行状态、日志与产物' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '系统设置', desc: '配置 API Key 与输出目录' } },
]
const router = createRouter({
history: createWebHashHistory('/v2/'),
routes,
})
const app = createApp(App)
app.use(router)
app.mount('#app')
+658
View File
@@ -0,0 +1,658 @@
/* =============================================
SVN Log Tool v2 — OLED Dark Theme
Design System: Dark Mode (OLED) by UI/UX Pro Max
Colors: #0F172A bg, #1E293B surface, #22C55E accent
Fonts: Fira Sans (body), Fira Code (heading/data)
============================================= */
:root {
--c-bg: #0F172A;
--c-surface: #1E293B;
--c-surface-hover: #273548;
--c-surface-subtle: #1A2538;
--c-border: #334155;
--c-border-light: #293548;
--c-text: #F1F5F9;
--c-text-secondary: #94A3B8;
--c-text-muted: #64748B;
--c-primary: #22C55E;
--c-primary-hover: #16A34A;
--c-primary-bg: rgba(34, 197, 94, 0.10);
--c-primary-glow: 0 0 20px rgba(34, 197, 94, 0.15);
--c-success: #22C55E;
--c-success-bg: rgba(34, 197, 94, 0.12);
--c-warning: #EAB308;
--c-warning-bg: rgba(234, 179, 8, 0.12);
--c-danger: #EF4444;
--c-danger-bg: rgba(239, 68, 68, 0.12);
--c-info: #3B82F6;
--c-info-bg: rgba(59, 130, 246, 0.12);
--c-code-bg: #0C1929;
--font-sans: 'Fira Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.3);
--shadow-glow: 0 0 20px rgba(34, 197, 94, 0.12);
--transition: 180ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; -webkit-font-smoothing: antialiased; }
body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
color: var(--c-text);
background: var(--c-bg);
height: 100%;
overflow: hidden;
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(34, 197, 94, 0.2);
}
#app { height: 100%; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
/* ========== Layout ========== */
.app-layout {
display: flex;
height: 100vh;
}
/* ========== Sidebar ========== */
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--c-surface);
border-right: 1px solid var(--c-border-light);
display: flex;
flex-direction: column;
padding: var(--space-lg) var(--space-md);
gap: var(--space-xl);
user-select: none;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 var(--space-sm);
}
.sidebar-brand-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, #22C55E, #16A34A);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 16px;
font-family: var(--font-mono);
box-shadow: var(--shadow-glow);
}
.sidebar-brand-text h1 {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.2px;
color: var(--c-text);
}
.sidebar-brand-text p {
font-size: 11px;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 2px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 450;
color: var(--c-text-secondary);
text-decoration: none;
transition: background var(--transition), color var(--transition);
cursor: pointer;
border: none;
background: none;
font-family: inherit;
width: 100%;
text-align: left;
position: relative;
}
.sidebar-link:hover {
background: rgba(255,255,255,0.04);
color: var(--c-text);
}
.sidebar-link.active,
.sidebar-link.router-link-exact-active {
background: var(--c-primary-bg);
color: var(--c-primary);
font-weight: 500;
}
.sidebar-link.active::before,
.sidebar-link.router-link-exact-active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--c-primary);
border-radius: 0 2px 2px 0;
}
.sidebar-link:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
border-radius: var(--radius-md);
}
.sidebar-link .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity var(--transition);
}
.sidebar-link:hover .icon { opacity: 0.9; }
.sidebar-link.active .icon,
.sidebar-link.router-link-exact-active .icon {
opacity: 1;
color: var(--c-primary);
}
.sidebar-footer {
font-size: 11px;
color: var(--c-text-muted);
padding: var(--space-md) var(--space-sm) 0;
border-top: 1px solid var(--c-border-light);
margin-top: auto;
}
/* ========== Main Area ========== */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: var(--c-bg);
}
.main-header {
padding: var(--space-xl) var(--space-xl) 0;
flex-shrink: 0;
}
.main-header h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.2px;
color: var(--c-text);
font-family: var(--font-sans);
}
.main-header p {
font-size: 13px;
color: var(--c-text-secondary);
margin-top: 4px;
}
.main-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-lg) var(--space-xl) var(--space-xl);
min-height: 0;
}
/* ========== Cards ========== */
.card {
background: var(--c-surface);
border: 1px solid var(--c-border-light);
border-radius: var(--radius-lg);
padding: var(--space-lg);
box-shadow: var(--shadow-card);
transition: border-color var(--transition);
}
.card:hover {
border-color: var(--c-border);
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--c-text);
margin-bottom: var(--space-md);
display: flex;
align-items: center;
gap: 8px;
}
/* ========== Stats Row ========== */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-md);
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--space-sm) 0;
}
.stat-item .label {
font-size: 11px;
font-weight: 500;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-item .value {
font-size: 28px;
font-weight: 600;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
line-height: 1.2;
color: var(--c-text);
}
.stat-item .value-sub {
font-size: 12px;
font-weight: 400;
font-family: var(--font-sans);
color: var(--c-text-secondary);
line-height: 1.5;
}
/* ========== Grids ========== */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
}
.span-2 { grid-column: span 2; }
.span-all { grid-column: 1 / -1; }
/* ========== Forms ========== */
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label {
font-size: 12px;
font-weight: 500;
color: var(--c-text-secondary);
letter-spacing: 0.2px;
}
.form-input, .form-select, .form-textarea {
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: 9px 12px;
font-size: 13px;
font-family: inherit;
color: var(--c-text);
background: var(--c-code-bg);
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
width: 100%;
}
.form-input:hover, .form-select:hover { border-color: #475569; }
.form-input:focus-visible, .form-select:focus-visible {
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.form-input:focus:not(:focus-visible), .form-select:focus:not(:focus-visible) {
border-color: var(--c-border);
}
.form-input::placeholder { color: var(--c-text-muted); }
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 32px;
cursor: pointer;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
/* ========== Buttons ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
color: var(--c-text);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
text-decoration: none;
}
.btn:hover:not(:disabled) {
border-color: #475569;
background: var(--c-surface-hover);
}
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
font-weight: 600;
box-shadow: var(--shadow-glow);
}
.btn-primary:hover:not(:disabled) {
background: var(--c-primary-hover);
border-color: var(--c-primary-hover);
box-shadow: 0 0 24px rgba(34, 197, 94, 0.25);
}
.btn-danger {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: var(--c-danger);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
border-color: var(--c-danger);
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn-group {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
/* ========== Tags ========== */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.tag-success { background: var(--c-success-bg); color: var(--c-success); }
.tag-warning { background: var(--c-warning-bg); color: var(--c-warning); }
.tag-danger { background: var(--c-danger-bg); color: var(--c-danger); }
.tag-muted { background: rgba(100, 116, 139, 0.15); color: var(--c-text-muted); }
.tag-info { background: var(--c-info-bg); color: var(--c-info); }
/* ========== Tables ========== */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--c-border-light);
border-radius: var(--radius-md);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
background: rgba(15, 23, 42, 0.5);
padding: 10px 14px;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--c-text-muted);
text-align: left;
border-bottom: 1px solid var(--c-border-light);
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid var(--c-border-light);
color: var(--c-text);
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(255,255,255,0.02); }
tr a { color: var(--c-primary); text-decoration: none; font-weight: 500; }
tr a:hover { text-decoration: underline; color: var(--c-primary-hover); }
/* ========== Lists ========== */
.list { list-style: none; }
.list li {
padding: 10px 12px;
border-bottom: 1px solid var(--c-border-light);
font-size: 13px;
}
.list li:last-child { border-bottom: none; }
.list li:hover { background: rgba(255,255,255,0.02); }
.list-empty {
color: var(--c-text-muted);
font-size: 13px;
padding: 24px 20px;
text-align: center;
}
/* ========== Toast ========== */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-sm);
pointer-events: none;
}
.toast-item {
pointer-events: auto;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: 12px 20px;
box-shadow: var(--shadow-elevated);
font-size: 13px;
font-weight: 500;
color: var(--c-text);
display: flex;
align-items: center;
gap: 8px;
animation: toast-in 200ms ease-out forwards;
max-width: 420px;
}
.toast-item.toast-error { border-left: 3px solid var(--c-danger); }
.toast-item.toast-success { border-left: 3px solid var(--c-primary); }
.toast-item.toast-leave { animation: toast-out 200ms ease-in forwards; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
/* ========== Log Panels ========== */
.log-panel {
background: var(--c-code-bg);
border: 1px solid #1E293B;
border-radius: var(--radius-md);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
color: #CBD5E1;
overflow-y: auto;
position: relative;
white-space: pre-wrap;
word-break: break-all;
}
.log-panel-dots {
padding-bottom: var(--space-sm);
margin-bottom: var(--space-sm);
border-bottom: 1px solid rgba(255,255,255,0.06);
opacity: 0.3;
display: flex;
gap: 6px;
}
.log-panel-dots span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.log-panel-dots .dot-red { background: #FF5F56; }
.log-panel-dots .dot-yellow { background: #FFBD2E; }
.log-panel-dots .dot-green { background: #27C93F; }
.log-pane-3 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
.log-pane-3 > div:nth-child(3) { grid-column: 1 / -1; }
.log-line { margin: 2px 0; }
.log-info { color: #94A3B8; }
.log-error { color: #F87171; }
.log-reasoning { color: #818CF8; font-style: italic; }
.log-answer { color: #34D399; }
.log-muted { color: #475569; }
/* ========== Toolbar ========== */
.toolbar {
display: flex;
gap: var(--space-sm);
align-items: center;
flex-wrap: wrap;
padding: var(--space-md);
background: rgba(15, 23, 42, 0.4);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
border: 1px solid var(--c-border-light);
}
.toolbar .form-input,
.toolbar .form-select {
padding: 7px 10px;
font-size: 12px;
background: var(--c-code-bg);
}
/* ========== Pagination ========== */
.pager {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) 0 0;
font-size: 13px;
color: var(--c-text-secondary);
}
.pager-actions { display: flex; gap: var(--space-sm); }
/* ========== Alerts ========== */
.alert {
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.5;
}
.alert-info {
background: var(--c-info-bg);
border-left: 3px solid var(--c-info);
color: #93C5FD;
}
/* ========== Project Blocks ========== */
.project-block {
border: 1px solid var(--c-border-light);
border-radius: var(--radius-md);
padding: var(--space-md);
background: rgba(15, 23, 42, 0.3);
}
.project-block h4 {
font-size: 13px;
font-weight: 600;
margin-bottom: var(--space-md);
color: var(--c-text);
font-family: var(--font-sans);
}
/* ========== Utilities ========== */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.15);
border-top-color: var(--c-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.form-grid-section-title {
font-size: 13px;
font-weight: 600;
color: var(--c-text-secondary);
padding: var(--space-md) 0 var(--space-sm);
border-bottom: 1px solid var(--c-border-light);
margin-bottom: var(--space-sm);
letter-spacing: 0.3px;
}
/* ========== Responsive ========== */
@media (max-width: 1024px) {
.sidebar { width: 200px; padding: var(--space-md) var(--space-sm); }
.main-header { padding: var(--space-lg) var(--space-md) 0; }
.main-content { padding: var(--space-md); }
}
@media (max-width: 768px) {
.sidebar { width: 56px; padding: var(--space-md) var(--space-sm); }
.sidebar-brand-text { display: none; }
.sidebar-link span:not(.icon) { display: none; }
.sidebar-link { justify-content: center; padding: 10px; }
.sidebar-link.active::before,
.sidebar-link.router-link-exact-active::before { left: -8px; }
.sidebar-footer { display: none; }
.grid-2 { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
.log-pane-3 { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: repeat(2, 1fr); }
}
+172
View File
@@ -0,0 +1,172 @@
<template>
<div class="dashboard">
<div class="card" style="padding: var(--space-md) var(--space-lg);">
<div class="stats-row">
<div class="stat-item">
<span class="label">任务总数</span>
<span class="value">{{ stats.total }}</span>
</div>
<div class="stat-item">
<span class="label">执行中</span>
<span class="value" style="color: var(--c-warning);">{{ stats.running }}</span>
</div>
<div class="stat-item">
<span class="label">失败任务</span>
<span class="value" style="color: var(--c-danger);">{{ stats.failed }}</span>
</div>
<div class="stat-item">
<span class="label">系统状态</span>
<span
class="value"
:style="{ color: healthOk ? 'var(--c-success)' : 'var(--c-danger)' }"
>{{ healthOk ? '正常' : '异常' }}</span>
</div>
<div class="stat-item">
<span class="label">健康详情</span>
<span class="value-sub">{{ healthDetail }}</span>
</div>
</div>
</div>
<div class="grid-2" style="margin-top: var(--space-lg); flex: 1; min-height: 0;">
<div class="card" style="display:flex;flex-direction:column;">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
最近任务
</div>
<div style="flex:1;overflow-y:auto;min-height:0;">
<ul class="list">
<li v-for="t in recentTasks" :key="t.taskId" style="display:flex;align-items:flex-start;gap:8px;">
<div style="flex:1;min-width:0;">
<strong style="font-family:var(--font-mono);font-size:12px;">{{ t.type }}</strong>
<span :class="['tag', statusClass(t.status)]" style="margin-left:6px;">{{ t.status }}</span>
<br>
<span style="font-size:12px;color:var(--c-text-muted);">{{ t.message || '' }}</span>
</div>
<button
v-if="t.status === 'RUNNING' || t.status === 'PENDING'"
class="btn btn-sm btn-danger"
style="flex-shrink:0;"
@click="cancelTask(t.taskId)"
>取消</button>
</li>
<li v-if="!recentTasks.length" class="list-empty">暂无任务记录</li>
</ul>
</div>
</div>
<div class="card" style="display:flex;flex-direction:column;">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
最近文件
</div>
<div style="flex:1;overflow-y:auto;min-height:0;">
<ul class="list">
<li v-for="f in recentFiles" :key="f.path">
<a
href="#"
@click.prevent="downloadFile(f.path)"
style="font-family:var(--font-mono);font-size:12px;"
>{{ f.path }}</a>
<br>
<span style="font-size:11px;color:var(--c-text-muted);">{{ formatBytes(f.size) }}</span>
</li>
<li v-if="!recentFiles.length" class="list-empty">暂无输出文件</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch, downloadFile } = useApi()
const { toast } = useToast()
const tasks = ref([])
const files = ref([])
const health = ref(null)
let timer = null
const stats = computed(() => ({
total: tasks.value.length,
running: tasks.value.filter(t => t.status === 'RUNNING' || t.status === 'PENDING').length,
failed: tasks.value.filter(t => t.status === 'FAILED').length,
}))
const healthOk = computed(() => health.value?.outputDirWritable || false)
const healthDetail = computed(() => {
if (!health.value) return '健康状态暂不可用'
return `输出目录: ${health.value.outputDir} | 可写: ${health.value.outputDirWritable ? '是' : '否'} | API Key: ${health.value.apiKeyConfigured ? '已配置' : '未配置'}`
})
const recentTasks = computed(() => tasks.value.slice(0, 20))
const recentFiles = computed(() => files.value.slice(0, 20))
async function refresh() {
const [tasksRes, filesRes, healthRes] = await Promise.allSettled([
apiFetch('/api/tasks'),
apiFetch('/api/files'),
apiFetch('/api/health/details'),
])
if (tasksRes.status === 'fulfilled') {
const items = tasksRes.value || []
items.sort((a, b) => sortTime(b.createdAt, a.createdAt))
tasks.value = items
}
if (filesRes.status === 'fulfilled') {
const items = (filesRes.value?.files || []).slice()
items.sort((a, b) => sortTime(b.modifiedAt, a.modifiedAt))
files.value = items
}
if (healthRes.status === 'fulfilled') {
health.value = healthRes.value
}
}
function sortTime(a, b) {
return (new Date(a || 0)).getTime() - (new Date(b || 0)).getTime()
}
function formatBytes(bytes) {
if (bytes == null) return '-'
const u = ['B', 'KB', 'MB', 'GB']
let v = Number(bytes), i = 0
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`
}
function statusClass(s) {
return s === 'SUCCESS' ? 'tag-success' : s === 'RUNNING' || s === 'PENDING' ? 'tag-warning' : s === 'FAILED' ? 'tag-danger' : 'tag-muted'
}
async function cancelTask(taskId) {
if (!confirm('确定要取消此任务吗?')) return
try {
await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST' })
toast('任务已取消')
await refresh()
} catch (err) { toast(err.message, true) }
}
onMounted(() => {
refresh()
timer = setInterval(refresh, 8000)
document.addEventListener('visibilitychange', onVisibilityChange)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange)
})
function onVisibilityChange() {
if (document.hidden) {
if (timer) { clearInterval(timer); timer = null }
} else {
refresh()
if (!timer) timer = setInterval(refresh, 8000)
}
}
</script>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="history">
<div class="card" style="margin-bottom:var(--space-lg);">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
任务列表
</div>
<div class="toolbar">
<select class="form-select" v-model="query.status" style="min-width:120px;" aria-label="状态筛选">
<option value="">全部状态</option>
<option v-for="s in statusOptions" :key="s" :value="s">{{ s }}</option>
</select>
<select class="form-select" v-model="query.type" style="min-width:120px;" aria-label="类型筛选">
<option value="">全部类型</option>
<option v-for="t in typeOptions" :key="t" :value="t">{{ t }}</option>
</select>
<input class="form-input" v-model="query.keyword" placeholder="搜索任务ID/信息" style="min-width:160px;flex:1;" aria-label="关键词搜索" spellcheck="false" />
<button class="btn btn-primary" @click="search">查询</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>任务ID</th>
<th>类型</th>
<th>状态</th>
<th>进度</th>
<th>说明</th>
<th>产物</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in page.items" :key="t.taskId">
<td style="font-family:var(--font-mono);font-size:12px;">{{ t.taskId.slice(0, 8) }}</td>
<td>{{ t.type }}</td>
<td><span :class="['tag', statusClass(t.status)]">{{ t.status }}</span></td>
<td><span style="font-family:var(--font-mono);">{{ t.progress || 0 }}%</span></td>
<td>
<div :title="t.message" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-text-secondary);">{{ t.message || '-' }}</div>
<div v-if="t.error" style="color:var(--c-danger);font-size:12px;margin-top:2px;">{{ t.error }}</div>
</td>
<td>
<span v-for="f in (t.files || [])" :key="f" style="display:block;">
<a href="#" @click.prevent="downloadFile(f)" style="font-size:12px;">{{ basename(f) }}</a>
</span>
<span v-if="!t.files?.length" style="color:var(--c-text-muted);">-</span>
</td>
<td>
<button v-if="t.status === 'RUNNING' || t.status === 'PENDING'" class="btn btn-sm btn-danger" @click="cancelTask(t.taskId)">取消</button>
<span v-else style="color:var(--c-text-muted);">-</span>
</td>
</tr>
<tr v-if="!page.items.length">
<td colspan="7" style="text-align:center;color:var(--c-text-muted);padding:32px;">暂无任务记录</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<span> <strong style="color:var(--c-text);font-family:var(--font-mono);">{{ page.total }}</strong> 条记录 {{ page.page }} / {{ totalPages }} </span>
<div class="pager-actions">
<button class="btn btn-sm" :disabled="page.page <= 1" @click="goPage(page.page - 1)">上一页</button>
<button class="btn btn-sm" :disabled="page.page >= totalPages" @click="goPage(page.page + 1)">下一页</button>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
输出文件归档
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>文件路径</th>
<th>大小</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="f in files" :key="f.path">
<td style="word-break:break-all;font-family:var(--font-mono);font-size:12px;">{{ f.path }}</td>
<td style="font-family:var(--font-mono);font-variant-numeric:tabular-nums;">{{ formatBytes(f.size) }}</td>
<td style="color:var(--c-text-secondary);">{{ formatTime(f.modifiedAt) }}</td>
<td><a href="#" @click.prevent="downloadFile(f.path)">下载</a></td>
</tr>
<tr v-if="!files.length">
<td colspan="4" style="text-align:center;color:var(--c-text-muted);padding:32px;">暂无输出文件</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch, downloadFile } = useApi()
const { toast } = useToast()
const statusOptions = ['PENDING', 'RUNNING', 'SUCCESS', 'FAILED', 'CANCELLED']
const typeOptions = ['SVN_FETCH', 'AI_ANALYZE']
const query = ref({ status: '', type: '', keyword: '', page: 1, size: 10 })
const page = ref({ items: [], page: 1, size: 10, total: 0 })
const files = ref([])
const totalPages = computed(() => Math.max(1, Math.ceil((page.value.total || 0) / query.value.size)))
onMounted(() => { loadTasks(); loadFiles() })
function statusClass(s) {
return s === 'SUCCESS' ? 'tag-success' : s === 'RUNNING' || s === 'PENDING' ? 'tag-warning' : s === 'FAILED' ? 'tag-danger' : 'tag-muted'
}
function basename(p) { return (p || '').split('/').filter(Boolean).pop() || p }
function formatBytes(bytes) {
if (bytes == null) return '-'
const u = ['B', 'KB', 'MB', 'GB']
let v = Number(bytes), i = 0
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`
}
function formatTime(v) {
if (!v) return '-'
const d = new Date(v)
return isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false })
}
function search() { query.value.page = 1; loadTasks() }
async function loadTasks() {
const p = new URLSearchParams()
if (query.value.status) p.set('status', query.value.status)
if (query.value.type) p.set('type', query.value.type)
if (query.value.keyword) p.set('keyword', query.value.keyword)
p.set('page', String(query.value.page))
p.set('size', String(query.value.size))
try {
const data = await apiFetch(`/api/tasks/query?${p.toString()}`)
page.value = { items: data.items || [], page: data.page || 1, size: data.size || 10, total: data.total || 0 }
} catch (err) { toast(err.message, true) }
}
function goPage(p) { query.value.page = p; loadTasks() }
async function cancelTask(taskId) {
if (!confirm('确定要取消此任务吗?')) return
try {
await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST' })
toast('任务取消请求已处理')
loadTasks()
} catch (err) { toast(err.message, true) }
}
async function loadFiles() {
try {
const data = await apiFetch('/api/files')
files.value = data.files || []
} catch (err) { toast(err.message, true) }
}
</script>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div class="settings">
<div class="card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
系统配置
</div>
<form @submit.prevent="onSave">
<div class="form-grid span-all" style="gap:var(--space-lg);">
<div class="form-group span-all">
<label for="provider">AI 提供商</label>
<select id="provider" class="form-select" v-model="form.provider" style="max-width:300px;">
<option value="deepseek">DeepSeek</option>
<option value="openai-compatible">OpenAI 兼容</option>
</select>
</div>
<template v-if="form.provider === 'deepseek'">
<div class="form-group span-all">
<label for="apiKey">DeepSeek API Key</label>
<input id="apiKey" class="form-input" type="password" v-model="form.apiKey" placeholder="保存后写入本地 settings.json" autocomplete="off" />
</div>
</template>
<template v-if="form.provider === 'openai-compatible'">
<div class="form-group span-all">
<label for="openaiBaseUrl">OpenAI 兼容 Base URL</label>
<input id="openaiBaseUrl" class="form-input" v-model="form.openaiBaseUrl" placeholder="例如 http://127.0.0.1:5001/v1" autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="openaiApiKey">OpenAI 兼容 API Key</label>
<input id="openaiApiKey" class="form-input" type="password" v-model="form.openaiApiKey" placeholder="保存后写入本地 settings.json" autocomplete="off" />
</div>
<div class="form-group">
<label for="stageOneModel">第一阶段模型</label>
<select id="stageOneModel" class="form-select" v-model="form.openaiStageOneModel">
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
</select>
</div>
<div class="form-group">
<label for="stageTwoModel">第二阶段模型</label>
<select id="stageTwoModel" class="form-select" v-model="form.openaiStageTwoModel">
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
</select>
</div>
</template>
<div class="form-group">
<label for="svnUsername">SVN 用户名</label>
<input id="svnUsername" class="form-input" v-model="form.svnUsername" placeholder="留空则继续使用已保存值" autocomplete="username" spellcheck="false" />
</div>
<div class="form-group">
<label for="svnPassword">SVN 密码</label>
<input id="svnPassword" class="form-input" type="password" v-model="form.svnPassword" placeholder="留空则不覆盖已保存密码" autocomplete="current-password" />
</div>
<div class="form-group span-all">
<label for="defaultPreset">默认 SVN 项目</label>
<select id="defaultPreset" class="form-select" v-model="form.defaultSvnPresetId" style="max-width:400px;">
<option v-for="p in presets" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-group span-all">
<label for="outputDir">输出目录</label>
<input id="outputDir" class="form-input" v-model="form.outputDir" placeholder="默认 outputs" autocomplete="off" />
</div>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner"></span>
保存系统设置
</button>
</div>
</form>
<div v-if="savedState" style="margin-top:var(--space-lg);padding-top:var(--space-md);border-top:1px solid var(--c-border-light);font-size:13px;color:var(--c-text-secondary);line-height:1.8;">
<div v-if="savedState.provider === 'openai-compatible'">
当前提供商: <strong style="color:var(--c-text);">OpenAI 兼容</strong><br>
Base URL: {{ savedState.openaiBaseUrl || '(未配置)' }}<br>
API Key: <span :style="{ color: savedState.openaiApiKeyConfigured ? 'var(--c-success)' : 'var(--c-warning)' }">{{ savedState.openaiApiKeyConfigured ? '已配置' : '未配置' }}</span><br>
Stage1: {{ savedState.openaiStageOneModel || '-' }}<br>
Stage2: {{ savedState.openaiStageTwoModel || '-' }}<br>
SVN: {{ renderSvnState(savedState) }}
</div>
<div v-else>
当前提供商: <strong style="color:var(--c-text);">DeepSeek</strong><br>
API Key: <span :style="{ color: savedState.apiKeyConfigured ? 'var(--c-success)' : 'var(--c-warning)' }">{{ savedState.apiKeyConfigured ? '已配置' : '未配置' }}</span> (来源: {{ savedState.apiKeySource }})<br>
SVN: {{ renderSvnState(savedState) }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch } = useApi()
const { toast } = useToast()
const form = ref({
provider: 'deepseek',
apiKey: '',
openaiBaseUrl: '',
openaiApiKey: '',
openaiStageOneModel: 'deepseek-v4-flash',
openaiStageTwoModel: 'deepseek-v4-pro',
svnUsername: '',
svnPassword: '',
outputDir: '',
defaultSvnPresetId: '',
})
const presets = ref([])
const savedState = ref(null)
const saving = ref(false)
onMounted(async () => {
try {
const [settingsData, presetsData] = await Promise.all([
apiFetch('/api/settings'),
apiFetch('/api/svn/presets'),
])
presets.value = presetsData.presets || []
form.value.provider = settingsData.provider || 'deepseek'
form.value.openaiBaseUrl = settingsData.openaiBaseUrl || ''
form.value.openaiStageOneModel = settingsData.openaiStageOneModel || 'deepseek-v4-flash'
form.value.openaiStageTwoModel = settingsData.openaiStageTwoModel || 'deepseek-v4-pro'
form.value.svnUsername = settingsData.svnUsername || ''
form.value.outputDir = settingsData.outputDir || ''
form.value.defaultSvnPresetId = settingsData.defaultSvnPresetId || (presets.value[0]?.id || '')
savedState.value = settingsData
} catch (err) { toast(err.message, true) }
})
async function onSave() {
saving.value = true
try {
const data = await apiFetch('/api/settings', { method: 'PUT', body: JSON.stringify(form.value) })
savedState.value = data
form.value.apiKey = ''
form.value.openaiApiKey = ''
form.value.svnPassword = ''
toast('设置保存成功')
} catch (err) { toast(err.message, true) }
finally { saving.value = false }
}
function renderSvnState(d) {
const user = d.svnUsername || '(未配置)'
const configured = d.svnCredentialsConfigured ? '已配置' : '未配置'
return `${user} / ${configured}`
}
</script>
+378
View File
@@ -0,0 +1,378 @@
<template>
<div class="svn-fetch">
<div class="card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
SVN 批量抓取参数
</div>
<div class="alert alert-info" style="margin-bottom:var(--space-md);">
默认已填充 3 个常用项目路径可选择月份自动填充版本号或手动填写
</div>
<div style="padding:var(--space-md);background:rgba(15,23,42,0.4);border-radius:var(--radius-md);margin-bottom:var(--space-lg);border:1px solid var(--c-border-light);">
<h4 style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--c-text);">智能版本号辅助</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select v-model="rangeType" class="form-select" style="width:80px;" aria-label="范围类型">
<option value="month"></option>
<option value="week"></option>
<option value="date"></option>
</select>
<input v-if="rangeType === 'month'" type="month" v-model="dateValue" class="form-input" style="max-width:200px;" aria-label="选择月份" />
<input v-if="rangeType === 'week'" type="week" v-model="dateValue" class="form-input" style="max-width:200px;" aria-label="选择周" />
<input v-if="rangeType === 'date'" type="date" v-model="dateValue" class="form-input" style="max-width:200px;" aria-label="选择日期" />
<button type="button" class="btn btn-primary" @click="autoFillVersions" :disabled="autoFillLoading">
<span v-if="autoFillLoading" class="spinner"></span>
自动计算并填充
</button>
</div>
</div>
<form @submit.prevent="onRunSvn">
<div class="form-grid" style="gap:var(--space-lg);">
<div class="span-all project-block" v-for="(proj, idx) in projects" :key="idx">
<h4>{{ proj.name }}</h4>
<div class="form-grid">
<div class="form-group">
<label :for="'start-'+idx">开始版本号</label>
<input :id="'start-'+idx" class="form-input" v-model="proj.startRevision" inputmode="numeric" placeholder="请输入开始版本" autocomplete="off" />
</div>
<div class="form-group">
<label :for="'end-'+idx">结束版本号</label>
<input :id="'end-'+idx" class="form-input" v-model="proj.endRevision" inputmode="numeric" placeholder="请输入结束版本" autocomplete="off" />
</div>
</div>
</div>
<div class="form-group">
<label for="filterUser">过滤用户名</label>
<input id="filterUser" class="form-input" v-model="filterUser" placeholder="包含匹配,留空不过滤" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="period">工作周期</label>
<input id="period" class="form-input" v-model="period" placeholder="例如 2026年03月" autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="outputFileName">输出文件名</label>
<input id="outputFileName" class="form-input" v-model="outputFileName" placeholder="例如 202603工作量统计.xlsx" autocomplete="off" />
</div>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="button" class="btn" @click="onTestConnection" :disabled="testing" aria-label="测试 SVN 连接">
<span v-if="testing" class="spinner"></span>
测试连接
</button>
<button type="submit" class="btn btn-primary" :disabled="running">
<span v-if="running" class="spinner"></span>
一键抓取并生成 Excel
</button>
</div>
</form>
</div>
<div class="card" v-show="showLogPanel" style="margin-top:var(--space-lg);">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
执行进度面板
</div>
<div class="log-pane-3">
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--c-text-muted);margin-bottom:6px;">AI 思考过程</div>
<div ref="reasoningPane" class="log-panel" style="height:250px;" role="log" aria-live="polite">
<div class="log-panel-dots"><span class="dot-red"></span><span class="dot-yellow"></span><span class="dot-green"></span></div>
<div v-if="!reasoningLines.length" class="log-line log-muted">等待思考输出...</div>
<div v-for="(line, i) in reasoningLines" :key="i" :class="['log-line', line.cls || 'log-reasoning']">{{ line.text }}</div>
</div>
</div>
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--c-text-muted);margin-bottom:6px;">最终分析输出</div>
<div ref="answerPane" class="log-panel" style="height:250px;" role="log" aria-live="polite">
<div class="log-panel-dots"><span class="dot-red"></span><span class="dot-yellow"></span><span class="dot-green"></span></div>
<div v-if="!answerLines.length" class="log-line log-muted">等待答案输出...</div>
<div v-for="(line, i) in answerLines" :key="i" :class="['log-line', line.cls || 'log-answer']">{{ line.text }}</div>
</div>
</div>
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--c-text-muted);margin-bottom:6px;">系统控制台</div>
<div ref="syslogPane" class="log-panel" style="height:180px;" role="log" aria-live="polite">
<div class="log-panel-dots"><span class="dot-red"></span><span class="dot-yellow"></span><span class="dot-green"></span></div>
<div v-if="!syslogLines.length" class="log-line log-muted">等待任务开始...</div>
<div v-for="(line, i) in syslogLines" :key="i" :class="['log-line', line.cls || 'log-info']">{{ line.text }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch, downloadFile } = useApi()
const { toast } = useToast()
const presets = ref([])
const defaultPresetId = ref('')
const testing = ref(false)
const running = ref(false)
const autoFillLoading = ref(false)
const showLogPanel = ref(false)
const rangeType = ref('month')
const dateValue = ref('')
const filterUser = ref('liujing')
const period = ref('')
const outputFileName = ref('')
const projects = ref([])
const syslogLines = ref([])
const reasoningLines = ref([])
const answerLines = ref([])
const reasoningPane = ref(null)
const answerPane = ref(null)
const syslogPane = ref(null)
function appendSyslog(msg, isError = false) {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
syslogLines.value.push({ text: `[${time}] ${isError ? '!' : ''} ${msg}`, cls: isError ? 'log-error' : 'log-info' })
scrollPane(syslogPane)
}
function appendReasoning(text) {
reasoningLines.value.push({ text, cls: 'log-reasoning' })
scrollPane(reasoningPane)
}
function appendAnswer(text) {
answerLines.value.push({ text, cls: 'log-answer' })
scrollPane(answerPane)
}
function clearLogs() {
syslogLines.value = []
reasoningLines.value = []
answerLines.value = []
}
function scrollPane(refEl) {
nextTick(() => { if (refEl.value) refEl.value.scrollTop = refEl.value.scrollHeight })
}
onMounted(async () => {
try {
const data = await apiFetch('/api/svn/presets')
presets.value = data.presets || []
defaultPresetId.value = data.defaultPresetId || ''
projects.value = (data.presets || []).map(p => ({
presetId: p.id,
name: p.name,
startRevision: '',
endRevision: '',
}))
} catch (err) { toast(err.message, true) }
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
dateValue.value = `${y}-${m}`
period.value = `${y}${m}`
outputFileName.value = `${y}${m}工作量统计.xlsx`
})
function getFormProjects() {
return projects.value.filter(p => p.startRevision && p.endRevision).map(p => ({ ...p }))
}
async function onTestConnection() {
if (!presets.value.length) { toast('未加载到 SVN 预设', true); return }
testing.value = true
try {
const pid = defaultPresetId.value && presets.value.some(p => p.id === defaultPresetId.value)
? defaultPresetId.value : presets.value[0].id
await apiFetch('/api/svn/test-connection', { method: 'POST', body: JSON.stringify({ presetId: pid }) })
toast('SVN 连接成功')
} catch (err) { toast(err.message, true) }
finally { testing.value = false }
}
async function autoFillVersions() {
if (!presets.value.length) { toast('未加载到 SVN 预设', true); return }
const rt = rangeType.value
const dv = dateValue.value
if (!dv) { toast('请选择日期范围', true); return }
let logPrefix = ''
let body
if (rt === 'month') {
const [y, m] = dv.split('-')
if (!y || !m) { toast('请选择月份', true); return }
logPrefix = `${y}${m}`
body = { presetId: '', year: parseInt(y, 10), month: parseInt(m, 10) }
} else if (rt === 'week') {
logPrefix = dv
body = { presetId: '', rangeType: 'week', week: dv }
} else {
logPrefix = dv
body = { presetId: '', rangeType: 'date', date: dv }
}
autoFillLoading.value = true
showLogPanel.value = true
clearLogs()
appendSyslog(`开始查询 ${logPrefix} 的版本范围...`)
try {
for (let i = 0; i < presets.value.length; i++) {
const proj = projects.value[i]
appendSyslog(`正在查询 ${proj.name} 的版本范围...`)
const data = await apiFetch('/api/svn/version-range', {
method: 'POST',
body: JSON.stringify({ ...body, presetId: proj.presetId }),
})
if (data.startRevision && data.endRevision) {
proj.startRevision = String(data.startRevision)
proj.endRevision = String(data.endRevision)
appendSyslog(`${proj.name} 版本范围: ${data.startRevision} - ${data.endRevision}`)
} else {
appendSyslog(`${proj.name} 该范围无提交记录`, true)
}
}
appendSyslog('所有项目版本号填充完成')
toast('版本号填充完成')
} catch (err) { appendSyslog(`填充失败: ${err.message}`, true); toast(err.message, true) }
finally { autoFillLoading.value = false }
}
async function onRunSvn() {
if (!presets.value.length) { toast('SVN 预设加载异常', true); return }
const formProjects = getFormProjects()
if (!formProjects.length) { toast('请至少填写一个项目的开始和结束版本号', true); return }
showLogPanel.value = true
clearLogs()
running.value = true
appendSyslog('任务开始...')
let aiStream = null
try {
const mdFiles = []
for (let i = 0; i < formProjects.length; i++) {
const proj = formProjects[i]
appendSyslog(`正在提交 ${proj.name} 的抓取任务...`)
const data = await apiFetch('/api/svn/fetch', {
method: 'POST',
body: JSON.stringify({ presetId: proj.presetId, startRevision: toNum(proj.startRevision), endRevision: toNum(proj.endRevision), filterUser: filterUser.value || '' }),
})
const taskId = data.taskId
appendSyslog(`已创建抓取任务: ${proj.name} (${taskId.slice(0,8)})`)
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`)
if (task.status === 'SUCCESS') {
appendSyslog(`${proj.name} 抓取完成`)
if (task.files) mdFiles.push(...task.files.filter(f => f.endsWith('.md')))
break
}
if (task.status === 'FAILED' || task.status === 'CANCELLED') {
throw new Error(`${proj.name} 抓取失败: ${task.error || task.message}`)
}
if (task.message) appendSyslog(`[${proj.name}] ${task.message}`)
await sleep(2000)
}
}
appendSyslog(`所有 SVN 抓取完成,共 ${mdFiles.length} 个文件`)
appendSyslog('正在提交 AI 分析任务...')
const aiData = await apiFetch('/api/ai/analyze', {
method: 'POST',
body: JSON.stringify({ filePaths: mdFiles, period: period.value || '', apiKey: '', outputFileName: outputFileName.value || '' }),
})
appendSyslog(`AI 分析任务已创建 (${aiData.taskId.slice(0,8)})`)
const streamReady = { reasoningLen: 0, answerLen: 0 }
const source = openStream(aiData.taskId, streamReady)
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`)
syncAiOutput(task, streamReady)
if (task.status === 'SUCCESS') {
appendSyslog('AI 分析完成')
syncAiOutput(task, streamReady)
source?.close()
break
}
if (task.status === 'FAILED' || task.status === 'CANCELLED') {
source?.close()
throw new Error(`AI 分析失败: ${task.error || task.message}`)
}
if (task.message) appendSyslog(task.message)
await sleep(1000)
}
const finalTask = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`)
if (finalTask.files) {
const excel = finalTask.files.find(f => f.endsWith('.xlsx'))
if (excel) {
appendSyslog('Excel 生成成功,开始下载...')
await downloadFile(excel)
appendSyslog('任务全部完成!')
}
}
toast('任务全部完成')
aiStream = source
} catch (err) {
appendSyslog(`错误: ${err.message}`, true)
toast(err.message, true)
} finally {
if (aiStream) aiStream.close()
running.value = false
}
}
function openStream(taskId, state) {
if (!window.EventSource) return { close() {} }
const url = `/api/tasks/${encodeURIComponent(taskId)}/stream`
let src = null
let reconnectAttempts = 0
const MAX_RECONNECT = 3
let closed = false
function connect() {
if (closed) return
src = new EventSource(url)
const onEvent = (event, handler) => {
src.addEventListener(event, e => {
try { const d = JSON.parse(e.data); handler(d) } catch {}
})
}
onEvent('reasoning_delta', d => { if (d.text) { appendReasoning(d.text); state.reasoningLen += d.text.length } })
onEvent('answer_delta', d => { if (d.text) { appendAnswer(d.text); state.answerLen += d.text.length } })
onEvent('phase', d => { if (d.message) appendSyslog(d.message) })
onEvent('usage', d => { appendSyslog(`Token: prompt=${d.promptTokens || 0} / completion=${d.completionTokens || 0} / total=${d.totalTokens || 0}`) })
onEvent('done', () => { closed = true; appendSyslog('SSE 流结束'); src.close() })
src.onerror = () => {
src.close()
if (closed) return
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++
appendSyslog(`SSE 连接断开,${reconnectAttempts}/${MAX_RECONNECT} 次重连...`, true)
setTimeout(connect, 2000)
} else {
closed = true
appendSyslog('SSE 重连失败,切换为轮询模式', true)
}
}
}
connect()
return { close() { closed = true; if (src) src.close() } }
}
function syncAiOutput(task, state) {
if (!task) return
const reasoning = task.aiReasoningText || ''
const answer = task.aiAnswerText || ''
if (reasoning.length > state.reasoningLen) {
const delta = reasoning.slice(state.reasoningLen)
if (delta) { appendReasoning(delta); state.reasoningLen = reasoning.length }
}
if (answer.length > state.answerLen) {
const delta = answer.slice(state.answerLen)
if (delta) { appendAnswer(delta); state.answerLen = answer.length }
}
}
function toNum(v) { const n = Number(v); return Number.isFinite(n) ? n : null }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
</script>