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:
@@ -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 & 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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user