391 lines
17 KiB
Vue
391 lines
17 KiB
Vue
<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>
|