Files
svn-log-tool/frontend-vue/src/views/SvnFetchView.vue
T

391 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="svn-fetch">
<!-- 空状态无预设时引导去仓库管理新增 -->
<div v-if="!loading && !presets.length" class="card" style="text-align:center;padding:var(--space-2xl) var(--space-lg);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--c-text-muted);margin-bottom:var(--space-md);" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<h3 style="font-size:16px;font-weight:600;margin-bottom:8px;color:var(--c-text);">尚未配置 SVN 仓库</h3>
<p style="font-size:13px;color:var(--c-text-muted);margin-bottom:var(--space-lg);">
请先在<a href="#/presets" style="color:var(--c-primary);font-weight:500;">仓库管理</a>页面新增仓库后再来抓取日志
</p>
<router-link to="/presets" class="btn btn-primary">前往仓库管理</router-link>
</div>
<div v-else 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);">
支持多个项目批量抓取可先选月份自动填充版本号或手动填写
</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 loading = ref(true)
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: '',
}))
loading.value = false
} catch (err) { loading.value = false; 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>