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