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
+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>