Files
svn-log-tool/src/main/resources/static/index.html
T
2026-06-10 20:42:17 +08:00

889 lines
64 KiB
HTML
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.
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SVN 日志工作台</title><style>:root{--bg-base:#030712;--bg-gradient-1:#0f172a;--bg-gradient-2:#020617;--surface-glass:rgba(30, 41, 59, 0.4);--surface-glass-hover:rgba(30, 41, 59, 0.6);--surface-solid:#1e293b;--surface-border:rgba(255, 255, 255, 0.08);--surface-border-highlight:rgba(255, 255, 255, 0.15);--text-primary:#f8fafc;--text-secondary:#94a3b8;--text-muted:#64748b;--brand-primary:#3b82f6;--brand-primary-hover:#60a5fa;--brand-gradient:linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);--success:#10b981;--success-bg:rgba(16, 185, 129, 0.15);--warning:#f59e0b;--warning-bg:rgba(245, 158, 11, 0.15);--danger:#ef4444;--danger-bg:rgba(239, 68, 68, 0.15);--info:#3b82f6;--info-bg:rgba(59, 130, 246, 0.15);--radius-sm:8px;--radius-md:12px;--radius-lg:16px;--radius-xl:24px;--shadow-glass:0 8px 32px 0 rgba(0, 0, 0, 0.37);--shadow-glow:0 0 20px rgba(59, 130, 246, 0.3);--font-sans:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;--font-mono:'SFMono-Regular','Cascadia Code',Consolas,monospace;--transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1)}*{box-sizing:border-box}body,html{margin:0;padding:0;height:100vh;width:100vw;overflow:hidden}body{font-family:var(--font-sans);color:var(--text-primary);background:var(--bg-base);background-image:radial-gradient(circle at 15% 50%,rgba(59,130,246,.15),transparent 25%),radial-gradient(circle at 85% 30%,rgba(139,92,246,.15),transparent 25%);background-attachment:fixed;line-height:1.6}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:rgba(0,0,0,.1);border-radius:4px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.3)}.bg-grid{position:fixed;inset:0;z-index:-2;background-image:linear-gradient(to right,rgba(255,255,255,.03) 1px,transparent 1px),linear-gradient(to bottom,rgba(255,255,255,.03) 1px,transparent 1px);background-size:40px 40px;mask-image:linear-gradient(to bottom,#000 40%,transparent 100%);pointer-events:none}.app-shell{display:grid;grid-template-columns:260px 1fr;height:100vh;max-width:1600px;margin:0 auto;padding:24px;gap:24px;z-index:1;position:relative}.sidebar{background:var(--surface-glass);backdrop-filter:blur(16px);border:1px solid var(--surface-border);border-radius:var(--radius-xl);padding:24px;display:flex;flex-direction:column;gap:32px;height:100%;box-shadow:var(--shadow-glass);overflow-y:auto}.brand{display:flex;align-items:center;gap:16px}.brand-dot{width:32px;height:32px;border-radius:10px;background:var(--brand-gradient);box-shadow:var(--shadow-glow);display:flex;align-items:center;justify-content:center;position:relative}.brand-dot::after{content:'';width:12px;height:12px;background:#fff;border-radius:4px;transform:rotate(45deg)}.brand h1{margin:0;font-size:1.25rem;font-weight:700;letter-spacing:.5px}.brand p{margin:4px 0 0;font-size:.75rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:1px}.nav-list{display:flex;flex-direction:column;gap:8px}.nav-item{background:0 0;border:none;color:var(--text-secondary);font-family:inherit;font-size:.95rem;font-weight:500;text-align:left;padding:12px 16px;border-radius:var(--radius-md);cursor:pointer;transition:var(--transition);display:flex;align-items:center;gap:12px;justify-content:flex-start}.nav-item::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;opacity:.3;transition:var(--transition);flex-shrink:0}.nav-item:hover{color:var(--text-primary);background:rgba(255,255,255,.03)}.nav-item:hover::before{opacity:.8}.nav-item.active{color:#fff;background:var(--brand-primary);box-shadow:0 4px 15px rgba(59,130,246,.4)}.nav-item.active::before{background:#fff;opacity:1;box-shadow:0 0 8px #fff}.main{display:flex;flex-direction:column;gap:0;min-width:0;height:100%;overflow:hidden}.main-header{padding:0 8px;flex-shrink:0;margin-bottom:24px}.main-header h2{margin:0 0 8px;font-size:2rem;font-weight:700;letter-spacing:-.5px}.main-header p{margin:0;color:var(--text-secondary);font-size:1rem}.view{display:none;animation:fadeIn .4s ease-out forwards;flex:1;overflow-y:auto;overflow-x:hidden;min-height:0;padding-right:12px}.view.active{display:flex;flex-direction:column;gap:24px;padding-bottom:24px}#view-dashboard.active{overflow:hidden;padding-right:0}@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.grid{display:grid;gap:24px;align-items:stretch;min-width:0}.cols-5{grid-template-columns:1fr 1fr 1fr 1fr 2fr}.cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.span-2{grid-column:1/-1}.card{background:var(--surface-glass);backdrop-filter:blur(12px);border:1px solid var(--surface-border);border-radius:var(--radius-xl);padding:24px;box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -1px rgba(0,0,0,.06);transition:border-color .3s ease;display:flex;flex-direction:column;min-width:0;flex-shrink:0}#view-dashboard .cols-2 .card{min-height:0;flex-shrink:1;overflow:hidden}.card:hover{border-color:var(--surface-border-highlight)}.card h3{margin:0 0 20px;font-size:1.1rem;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px;flex-shrink:0}.card h3::before{content:'';display:block;width:4px;height:16px;background:var(--brand-primary);border-radius:2px}.stat-mini{display:flex;flex-direction:column;gap:4px;min-width:0}.stat-mini .label{font-size:.85rem;color:var(--text-secondary);white-space:nowrap}.stat-mini .value{font-size:1.8rem;font-weight:700;font-family:var(--font-mono);line-height:1.1;color:#fff}.stat-mini .value-small{font-size:.95rem;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.health-details-wrap{border-left:1px solid var(--surface-border);padding-left:20px;justify-content:center}.text-warning{color:var(--warning)!important}.text-danger{color:var(--danger)!important}.text-success{color:var(--success)!important}.text-muted{color:var(--text-muted)!important}.list-container{flex:1;overflow-y:auto;overflow-x:hidden;min-height:0;padding-right:8px}.list-container::-webkit-scrollbar{width:4px}.list-container::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:2px}.list{list-style:none;margin:0;padding:0}.list li{padding:12px 16px;margin-bottom:8px;background:rgba(0,0,0,.2);border-radius:var(--radius-md);font-size:.9rem;transition:var(--transition)}.list li:hover{background:rgba(0,0,0,.4);transform:translateX(4px)}.list a{color:var(--brand-primary-hover);text-decoration:none;font-weight:500}.list a:hover{text-decoration:underline;color:#fff}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;align-items:start}.form-grid>*{min-width:0}label{display:block;font-size:.85rem;font-weight:500;color:var(--text-secondary);margin-bottom:8px}input,select{width:100%;min-width:0;max-width:100%;background:rgba(0,0,0,.2);border:1px solid var(--surface-border);color:var(--text-primary);font-family:inherit;font-size:.95rem;padding:12px 16px;border-radius:var(--radius-md);transition:var(--transition);box-sizing:border-box}input:hover,select:hover{border-color:rgba(255,255,255,.2)}input:focus,select:focus{outline:0;border-color:var(--brand-primary);background:rgba(0,0,0,.4);box-shadow:0 0 0 3px rgba(59,130,246,.2)}input::placeholder{color:var(--text-muted)}select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;background-size:16px;padding-right:40px}.actions{display:flex;gap:12px;margin-top:8px;flex-wrap:wrap}button{background:var(--surface-solid);color:var(--text-primary);border:1px solid var(--surface-border);padding:10px 20px;font-size:.95rem;font-weight:500;font-family:inherit;border-radius:var(--radius-md);cursor:pointer;transition:var(--transition);display:inline-flex;align-items:center;justify-content:center;gap:8px}button:hover:not(:disabled){background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.2);transform:translateY(-1px)}button:active:not(:disabled){transform:translateY(1px)}button:disabled{opacity:.5;cursor:not-allowed}button.primary{background:var(--brand-primary);border:none;color:#fff;box-shadow:0 4px 12px rgba(59,130,246,.3)}button.primary:hover:not(:disabled){background:var(--brand-primary-hover);box-shadow:0 6px 16px rgba(59,130,246,.4)}.btn-cancel-task{padding:6px 12px;font-size:.8rem;background:rgba(239,68,68,.1);color:var(--danger);border-color:transparent}.btn-cancel-task:hover:not(:disabled){background:rgba(239,68,68,.2)}.alert.info{background:var(--info-bg);border-left:4px solid var(--info);padding:16px;border-radius:var(--radius-sm);margin-bottom:24px;color:#dbeafe;font-size:.9rem}.month-panel{background:rgba(0,0,0,.15);padding:20px;border-radius:var(--radius-lg);border:1px solid var(--surface-border);margin-bottom:24px}.project-item{background:rgba(0,0,0,.15);padding:20px;border-radius:var(--radius-lg);border:1px solid var(--surface-border);position:relative;overflow:hidden}.project-item::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--text-muted)}.project-item h4{margin:0 0 16px;font-size:1rem;color:var(--text-primary)}#log-panel{display:none}.live-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-bottom:16px}.live-column header,.system-log-wrap header{font-size:.8rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);margin-bottom:8px;font-weight:600}.live-output,.system-output{height:300px;overflow-y:auto;overflow-x:hidden;background:#000;border:1px solid #333;border-radius:var(--radius-md);padding:16px;font-family:var(--font-mono);font-size:.85rem;line-height:1.5;position:relative;word-break:break-all;white-space:pre-wrap}.live-output::before,.system-output::before{content:'';display:block;height:12px;width:50px;margin-bottom:16px;background-image:radial-gradient(circle,#ff5f56 6px,transparent 6px),radial-gradient(circle,#ffbd2e 6px,transparent 6px),radial-gradient(circle,#27c93f 6px,transparent 6px);background-size:12px 12px;background-position:0 center,18px center,36px center;background-repeat:no-repeat;opacity:.7}.system-output{height:250px;background:rgba(5,5,5,.9)}.log-line{margin:4px 0}.is-info{color:#cbd5e1}.is-error{color:#f87171}.is-reasoning{color:#94a3b8;font-style:italic}.is-answer{color:#34d399}.history-toolbar{display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;background:rgba(0,0,0,.1);padding:12px;border-radius:var(--radius-md)}.history-toolbar>*{flex:1;min-width:150px;margin:0}.history-toolbar button{flex:0 0 auto}.table-wrap{overflow-x:auto;border-radius:var(--radius-md);border:1px solid var(--surface-border);background:rgba(0,0,0,.2)}table{width:100%;border-collapse:collapse;min-width:800px;text-align:left}td,th{padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.05)}th{background:rgba(0,0,0,.4);color:var(--text-secondary);font-weight:500;font-size:.85rem;text-transform:uppercase;letter-spacing:.5px}tr:hover td{background:rgba(255,255,255,.02)}tr:last-child td{border-bottom:none}td{font-size:.9rem}td a{color:var(--brand-primary-hover);text-decoration:none}td a:hover{text-decoration:underline}.tag{display:inline-flex;align-items:center;padding:2px 10px;border-radius:99px;font-size:.75rem;font-weight:600;text-transform:uppercase;font-family:var(--font-mono)}.tag.SUCCESS{background:var(--success-bg);color:var(--success)}.tag.PENDING,.tag.RUNNING{background:var(--warning-bg);color:var(--warning)}.tag.FAILED{background:var(--danger-bg);color:var(--danger)}.tag.CANCELLED{background:var(--surface-solid);color:var(--text-secondary)}.pager{display:flex;justify-content:space-between;align-items:center;margin-top:16px;font-size:.9rem;color:var(--text-secondary);padding:0 8px}.pager-actions{display:flex;gap:8px}.toast{position:fixed;bottom:32px;right:32px;z-index:9999;background:var(--surface-solid);border:1px solid var(--surface-border-highlight);color:#fff;padding:16px 24px;border-radius:var(--radius-md);box-shadow:0 10px 40px rgba(0,0,0,.5);font-weight:500;display:none;animation:slideUp .3s cubic-bezier(.175,.885,.32,1.275) forwards}.toast.show{display:block}@keyframes slideUp{from{opacity:0;transform:translateY(20px) scale(.95)}to{opacity:1;transform:translateY(0) scale(1)}}@media (max-width:1024px){.app-shell{grid-template-columns:1fr;padding:16px;height:100vh;overflow:hidden}.sidebar{position:static;height:auto;flex-direction:row;align-items:center;padding:16px;gap:20px;border-radius:var(--radius-lg);flex-wrap:wrap;flex-shrink:0}.brand{width:100%}.nav-list{flex-direction:row;width:100%;overflow-x:auto;padding-bottom:4px;gap:12px}.nav-item{white-space:nowrap;flex:0 0 auto;justify-content:flex-start}.live-grid{grid-template-columns:1fr}.cols-5{grid-template-columns:repeat(2,minmax(0,1fr))}.health-details-wrap{border-left:none;padding-left:0;grid-column:span 2}}@media (max-width:768px){.cols-2,.cols-5,.form-grid{grid-template-columns:1fr}.health-details-wrap{grid-column:span 1}.history-toolbar{flex-direction:column}#view-dashboard.active{overflow-y:auto}.card{min-height:400px}}</style></head><body><div class="bg-grid" aria-hidden="true"></div><div class="app-shell"><aside class="sidebar" aria-label="主导航"><div class="brand"><div class="brand-dot" aria-hidden="true"></div><div><h1>SVN 工作台</h1><p>Log Fetch & Analysis</p></div></div><nav class="nav-list"><button class="nav-item active" data-view="dashboard">工作台</button> <button class="nav-item" data-view="svn">SVN 日志抓取</button> <button class="nav-item" data-view="history">任务历史</button> <button class="nav-item" data-view="settings">系统配置</button></nav></aside><main class="main" id="main"><header class="main-header"><h2 id="view-title">工作台</h2><p id="view-desc">查看系统状态与最近产物</p></header><section class="view active" id="view-dashboard" aria-live="polite"><article class="card" style="flex-shrink:0;padding:16px 24px"><div class="grid cols-5" style="gap:16px;align-items:center"><div class="stat-mini"><span class="label">任务总数</span> <strong id="stat-total" class="value">0</strong></div><div class="stat-mini"><span class="label">执行中</span> <strong id="stat-running" class="value text-warning">0</strong></div><div class="stat-mini"><span class="label">失败任务</span> <strong id="stat-failed" class="value text-danger">0</strong></div><div class="stat-mini"><span class="label">系统状态</span> <strong id="stat-health" class="value text-success">-</strong></div><div class="stat-mini health-details-wrap"><span class="label">健康检查详情</span> <span id="health-details" class="value-small text-muted">加载中...</span></div></div></article><div class="grid cols-2" style="flex:1;min-height:0"><article class="card" style="padding-bottom:12px"><h3>最近任务</h3><div class="list-container"><ul id="recent-tasks" class="list"></ul></div></article><article class="card" style="padding-bottom:12px"><h3>最近文件</h3><div class="list-container"><ul id="recent-files" class="list"></ul></div></article></div></section><section class="view" id="view-svn"><article class="card form-card"><h3>SVN 批量抓取参数</h3><div class="alert info span-2">默认已填充 3 个常用项目路径,可选择月份自动填充版本号,或手动填写。</div><div class="month-panel span-2"><h4 style="margin:0 0 12px 0;font-size:.95rem;color:var(--text-primary)">智能版本号辅助</h4><div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start"><input type="month" id="version-month" style="max-width:240px;margin:0;cursor:pointer"> <button type="button" id="btn-auto-fill" class="primary" style="margin:0;padding:12px 24px;white-space:nowrap">✨ 自动计算并填充下方项目</button></div></div><form id="svn-form" class="form-grid"><div class="span-2 project-item"><h4>项目 1:PRS-7050 场站智慧管控</h4><div class="form-grid"><label>开始版本号<input name="startRevision_1" inputmode="numeric" placeholder="请输入开始版本"></label> <label>结束版本号<input name="endRevision_1" inputmode="numeric" placeholder="请输入结束版本"></label></div></div><div class="span-2 project-item"><h4>项目 2PRS-7950 在线巡视</h4><div class="form-grid"><label>开始版本号<input name="startRevision_2" inputmode="numeric" placeholder="请输入开始版本"></label> <label>结束版本号<input name="endRevision_2" inputmode="numeric" placeholder="请输入结束版本"></label></div></div><div class="span-2 project-item"><h4>项目 3:PRS-7950 在线巡视电科院测试版</h4><div class="form-grid"><label>开始版本号<input name="startRevision_3" inputmode="numeric" placeholder="请输入开始版本"></label> <label>结束版本号<input name="endRevision_3" inputmode="numeric" placeholder="请输入结束版本"></label></div></div><label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤" value="liujing"></label> <label>工作周期<input name="period" placeholder="例如 2026年03月"></label> <label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label><div class="actions span-2"><button type="button" id="btn-test-connection">测试连接</button> <button type="submit" id="btn-svn-run" class="primary">一键抓取并生成 Excel</button></div></form></article><article class="card" id="log-panel"><h3>执行进度面板</h3><div class="live-grid"><section class="live-column reasoning"><header>AI 思考过程</header><div id="reasoning-output" class="live-output"><p class="muted">等待思考输出...</p></div></section><section class="live-column answer"><header>最终分析输出</header><div id="answer-output" class="live-output"><p class="muted">等待答案输出...</p></div></section></div><div class="system-log-wrap"><header>系统控制台</header><div id="system-log-output" class="system-output"><p class="muted">等待任务开始...</p></div></div></article></section><section class="view" id="view-history"><article class="card"><h3>任务列表</h3><div class="history-toolbar" id="history-toolbar"><select id="task-filter-status" aria-label="状态筛选"><option value="">全部状态</option><option value="PENDING">PENDING</option><option value="RUNNING">RUNNING</option><option value="SUCCESS">SUCCESS</option><option value="FAILED">FAILED</option><option value="CANCELLED">CANCELLED</option></select> <select id="task-filter-type" aria-label="类型筛选"><option value="">全部类型</option><option value="SVN_FETCH">SVN_FETCH</option><option value="AI_ANALYZE">AI_ANALYZE</option></select> <input id="task-filter-keyword" placeholder="搜索任务ID/信息" aria-label="关键词搜索"> <button id="btn-task-filter" type="button" class="primary">查询</button></div><div id="task-table" class="table-wrap"></div><div id="task-pager" class="pager"></div></article><article class="card"><h3>输出文件归档</h3><div id="file-table" class="table-wrap"></div></article></section><section class="view" id="view-settings"><article class="card form-card"><h3>系统配置</h3><form id="settings-form" class="form-grid"><label class="span-2">AI 提供商 <select name="provider" id="settings-provider"><option value="deepseek">DeepSeek</option><option value="openai-compatible">OpenAI 兼容</option></select></label><div class="span-2" id="deepseek-settings-group"><label>DeepSeek API Key <input type="password" name="apiKey" placeholder="保存后写入本地 settings.json"></label></div><div class="span-2 form-grid" id="openai-settings-group" hidden><label class="span-2">OpenAI兼容 Base URL <input name="openaiBaseUrl" placeholder="例如 http://127.0.0.1:5001/v1"></label> <label class="span-2">OpenAI兼容 API Key <input type="password" name="openaiApiKey" placeholder="保存后写入本地 settings.json"></label> <label>第一阶段模型 <select name="openaiStageOneModel"><option value="deepseek-v4-flash">deepseek-v4-flash</option><option value="deepseek-v4-pro">deepseek-v4-pro</option></select></label> <label>第二阶段模型 <select name="openaiStageTwoModel"><option value="deepseek-v4-pro">deepseek-v4-pro</option><option value="deepseek-v4-flash">deepseek-v4-flash</option></select></label></div><label>SVN 用户名 <input name="svnUsername" placeholder="留空则继续使用已保存值"></label> <label>SVN 密码 <input type="password" name="svnPassword" placeholder="留空则不覆盖已保存密码"></label> <label class="span-2">默认 SVN 项目 <select name="defaultSvnPresetId" id="settings-default-preset"></select></label> <label class="span-2">输出目录 <input name="outputDir" placeholder="默认 outputs"></label><div class="actions span-2"><button type="submit" class="primary">保存系统设置</button></div></form><p id="settings-state" class="muted" style="margin:24px 0 0 0;font-size:.85rem;line-height:1.6;border-top:1px solid var(--surface-border);padding-top:16px;display:none"></p></article></section><section class="toast" id="toast" aria-live="assertive"></section></main></div><script>const state = {
tasks: [],
taskPage: { items: [], page: 1, size: 10, total: 0 },
taskQuery: { status: "", type: "", keyword: "", page: 1, size: 10 },
files: [],
health: null,
presets: [],
defaultPresetId: "",
activeView: "dashboard",
polling: null,
};
const CUSTOM_PRESET_ID = "custom";
const viewMeta = {
dashboard: { title: "工作台", desc: "查看系统状态与最近产物" },
svn: { title: "SVN 日志抓取", desc: "一键抓取SVN日志并导出工作量Excel" },
history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" },
settings: { title: "系统设置", desc: "配置 API Key 与输出目录" },
};
document.addEventListener("DOMContentLoaded", async () => {
bindNav();
bindForms();
await loadPresets();
await refreshAll();
await loadSettings();
// 自动填充当月默认值
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
document.querySelector("#version-month").value = `${year}-${month}`;
document.querySelector("#svn-form [name='period']").value = `${year}${month}`;
document.querySelector("#svn-form [name='outputFileName']").value = `${year}${month}工作量统计.xlsx`;
document.querySelector("#btn-auto-fill").addEventListener("click", onAutoFillVersions);
state.polling = setInterval(refreshAll, 5000);
});
function bindNav() {
document.querySelectorAll(".nav-item").forEach((btn) => {
btn.addEventListener("click", () => {
const view = btn.dataset.view;
switchView(view);
});
});
}
function bindForms() {
const testBtn = document.querySelector("#btn-test-connection");
testBtn.addEventListener("click", onTestConnection);
const svnForm = document.querySelector("#svn-form");
svnForm.addEventListener("submit", onRunSvn);
const settingsForm = document.querySelector("#settings-form");
settingsForm.addEventListener("submit", onSaveSettings);
const settingsProvider = document.querySelector("#settings-provider");
if (settingsProvider) {
settingsProvider.addEventListener("change", () => updateSettingsProviderUI(settingsProvider.value));
}
const taskFilterBtn = document.querySelector("#btn-task-filter");
if (taskFilterBtn) {
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
}
document.addEventListener("click", async (event) => {
const link = event.target.closest(".download-link");
if (!link) {
return;
}
event.preventDefault();
const path = link.dataset.downloadPath || "";
try {
await downloadOutputFile(path);
} catch (err) {
toast(err.message, true);
}
});
}
function switchView(view) {
state.activeView = view;
document.querySelectorAll(".nav-item").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.view === view);
});
document.querySelectorAll(".view").forEach((v) => {
v.classList.toggle("active", v.id === `view-${view}`);
});
document.querySelector("#view-title").textContent = viewMeta[view].title;
document.querySelector("#view-desc").textContent = viewMeta[view].desc;
if (view === "history") {
loadTaskPage();
renderFileTable();
}
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `请求失败: ${response.status}`);
}
const text = await response.text();
return text ? JSON.parse(text) : {};
}
async function downloadOutputFile(path) {
const response = await fetch(`/api/files/download?path=${encodeURIComponent(path || "")}`, {
method: "GET",
headers: { "Accept": "application/octet-stream" },
});
const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
if (!response.ok) {
throw new Error(await extractDownloadError(response));
}
if (contentType.indexOf("text/html") >= 0) {
console.error("download endpoint returned HTML instead of file", {
path: path,
status: response.status,
contentType: contentType,
});
throw new Error("下载接口返回了 HTML,未返回目标文件");
}
if (contentType.indexOf("application/json") >= 0) {
throw new Error(await extractDownloadError(response));
}
const blob = await response.blob();
const fileName = resolveDownloadFileName(path, response.headers.get("Content-Disposition"));
triggerBlobDownload(blob, fileName);
}
async function extractDownloadError(response) {
const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
const fallback = `下载失败: ${response.status}`;
if (contentType.indexOf("application/json") >= 0) {
const body = await response.json().catch(() => ({}));
return body.error || body.message || fallback;
}
const text = (await response.text().catch(() => "")).trim();
if (!text) {
return fallback;
}
if (contentType.indexOf("text/html") >= 0) {
console.error("download endpoint returned HTML error page", {
status: response.status,
preview: text.slice(0, 200),
});
return "下载接口返回了 HTML 错误页,未返回目标文件";
}
return text;
}
function resolveDownloadFileName(path, contentDisposition) {
const utf8Match = contentDisposition && contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match && utf8Match[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch (err) {
console.warn("decode download filename failed:", err);
}
}
const plainMatch = contentDisposition && contentDisposition.match(/filename=\"?([^\";]+)\"?/i);
if (plainMatch && plainMatch[1]) {
return plainMatch[1];
}
const normalized = (path || "").split("/").filter(Boolean);
return normalized.length ? normalized[normalized.length - 1] : "download";
}
function triggerBlobDownload(blob, fileName) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function buildDownloadLink(path, label) {
return `<a href="#" class="download-link" data-download-path="${escapeHtml(path)}">${label || escapeHtml(path)}</a>`;
}
async function refreshAll() {
const [tasksResult, filesResult, healthResult] = await Promise.allSettled([
apiFetch("/api/tasks"),
apiFetch("/api/files"),
apiFetch("/api/health/details"),
]);
if (tasksResult.status === "fulfilled") {
const tasksResp = tasksResult.value;
state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
} else {
console.warn("refresh tasks failed:", tasksResult.reason);
}
if (filesResult.status === "fulfilled") {
const filesResp = filesResult.value || {};
state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
if (filesResp.error) {
console.warn("files api degraded:", filesResp.error);
}
} else {
console.warn("refresh files failed:", filesResult.reason);
state.files = [];
}
if (healthResult.status === "fulfilled") {
state.health = healthResult.value || null;
} else {
console.warn("refresh health failed:", healthResult.reason);
state.health = null;
}
renderDashboard();
if (state.activeView === "history") {
loadTaskPage();
renderFileTable();
}
}
async function loadPresets() {
try {
const data = await apiFetch("/api/svn/presets");
state.presets = data.presets || [];
state.defaultPresetId = data.defaultPresetId || "";
renderPresetSelects();
applyPresetToSvnForm(state.defaultPresetId);
} catch (err) {
toast(err.message, true);
}
}
function renderPresetSelects() {
const settingsSelect = document.querySelector("#settings-default-preset");
if (!settingsSelect) return;
settingsSelect.innerHTML = "";
state.presets.forEach((preset) => {
const option = document.createElement("option");
option.value = preset.id;
option.textContent = `${preset.name}`;
settingsSelect.appendChild(option);
});
const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : "");
if (selected) {
settingsSelect.value = selected;
}
}
function onSvnPresetChange(event) { }
function applyPresetToSvnForm(presetId) { }
function renderDashboard() {
const total = state.tasks.length;
const running = state.tasks.filter((t) => t.status === "RUNNING" || t.status === "PENDING").length;
const failed = state.tasks.filter((t) => t.status === "FAILED").length;
const health = state.health;
document.querySelector("#stat-total").textContent = `${total}`;
document.querySelector("#stat-running").textContent = `${running}`;
document.querySelector("#stat-failed").textContent = `${failed}`;
document.querySelector("#stat-health").textContent = health && health.outputDirWritable ? "正常" : "异常";
const healthDetails = document.querySelector("#health-details");
if (health) {
healthDetails.textContent = `输出目录: ${health.outputDir} | 可写: ${health.outputDirWritable ? "是" : "否"} | API Key: ${health.apiKeyConfigured ? "已配置" : "未配置"}`;
} else {
healthDetails.textContent = "健康状态暂不可用";
}
const taskList = document.querySelector("#recent-tasks");
taskList.innerHTML = "";
state.tasks.slice(0, 20).forEach((task) => {
const li = document.createElement("li");
li.innerHTML = `<strong>${task.type}</strong> · <span class="tag ${task.status}">${task.status}</span><br><span class="muted" style="font-size:0.85rem; margin-top:4px; display:block;">${task.message || ""}</span>`;
taskList.appendChild(li);
});
if (taskList.children.length === 0) {
taskList.innerHTML = "<li class='muted'>暂无任务记录</li>";
}
const fileList = document.querySelector("#recent-files");
fileList.innerHTML = "";
state.files.slice(0, 20).forEach((file) => {
const path = file.path;
const li = document.createElement("li");
li.innerHTML = `${buildDownloadLink(path, escapeHtml(path))}<br><span class='muted' style="font-size:0.8rem; font-family:var(--font-mono)">${formatBytes(file.size)}</span>`;
fileList.appendChild(li);
});
if (fileList.children.length === 0) {
fileList.innerHTML = "<li class='muted'>暂无输出文件</li>";
}
}
async function onTestConnection() {
const btn = document.querySelector("#btn-test-connection");
setLoading(btn, true);
try {
const connectionPresetId = pickConnectionPresetId();
if (!connectionPresetId) {
throw new Error("未加载到 SVN 预设,请刷新页面后重试");
}
await apiFetch("/api/svn/test-connection", {
method: "POST",
body: JSON.stringify({
presetId: connectionPresetId,
}),
});
toast("SVN 连接成功");
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
}
}
async function waitForTaskCompletion(taskId) {
while (true) {
try {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
if (task.status === "SUCCESS") {
return task;
}
if (task.status === "FAILED" || task.status === "CANCELLED") {
throw new Error(`任务 ${taskId} 执行失败: ${task.error || task.message}`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (err) {
throw err;
}
}
}
async function waitForStreamCompletion(streamState, timeoutMs) {
const start = Date.now();
while (!streamState.streamCompleted && (Date.now() - start) < timeoutMs) {
await new Promise(resolve => setTimeout(resolve, 100));
}
return streamState.streamCompleted;
}
function syncTaskAiOutput(task, streamState) {
if (!task || !streamState) return;
const reasoning = task.aiReasoningText || "";
const answer = task.aiAnswerText || "";
if (reasoning.length > streamState.reasoningRenderedLength) {
const delta = reasoning.slice(streamState.reasoningRenderedLength);
if (delta) {
appendReasoning(delta);
streamState.reasoningRenderedLength = reasoning.length;
streamState.firstDeltaReceived = true;
}
}
if (answer.length > streamState.answerRenderedLength) {
const delta = answer.slice(streamState.answerRenderedLength);
if (delta) {
appendAnswer(delta);
streamState.answerRenderedLength = answer.length;
streamState.firstDeltaReceived = true;
}
}
}
async function onRunSvn(event) {
event.preventDefault();
const form = event.target;
const payload = readForm(form);
const btn = document.querySelector("#btn-svn-run");
const logPanel = document.querySelector("#log-panel");
let aiStream = null;
logPanel.style.display = "block";
clearLog();
appendLog("任务开始...");
setLoading(btn, true);
form.disabled = true;
try {
if (!state.presets || state.presets.length < 3) {
throw new Error("SVN 预设加载异常,请刷新页面后重试");
}
const revisionRanges = [
{ start: payload.startRevision_1, end: payload.endRevision_1 },
{ start: payload.startRevision_2, end: payload.endRevision_2 },
{ start: payload.startRevision_3, end: payload.endRevision_3 },
];
const projects = revisionRanges
.map((range, idx) => ({
presetId: state.presets[idx].id,
name: state.presets[idx].name,
start: range.start,
end: range.end,
}))
.filter((project) => project.start && project.end);
if (projects.length === 0) {
appendLog("错误:请至少填写一个项目的开始和结束版本号", true);
toast("请至少填写一个项目的开始和结束版本号", true);
return;
}
appendLog(`检测到 ${projects.length} 个待处理项目`);
const mdFiles = [];
for (let i = 0; i < projects.length; i++) {
const project = projects[i];
appendLog(`正在提交 ${project.name} 的抓取任务...`);
const data = await apiFetch("/api/svn/fetch", {
method: "POST",
body: JSON.stringify({
presetId: project.presetId,
startRevision: toNumberOrNull(project.start),
endRevision: toNumberOrNull(project.end),
filterUser: payload.filterUser || "",
}),
});
const taskId = data.taskId;
appendLog(`已创建抓取任务:${project.name} (任务ID: ${taskId.slice(0,8)})`);
appendLog(`正在抓取 ${project.name} 日志...`);
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
if (task.status === "SUCCESS") {
appendLog(`${project.name} 抓取完成`);
if (task.message) appendLog(task.message);
if (task.files && task.files.length > 0) {
mdFiles.push(...task.files.filter(f => f.endsWith(".md")));
appendLog(`生成文件: ${task.files.join(", ")}`);
}
break;
}
if (task.status === "FAILED" || task.status === "CANCELLED") {
throw new Error(`${project.name} 抓取失败 (任务ID: ${taskId.slice(0,8)}): ${task.error || task.message}`);
}
if (task.message) appendLog(`[${project.name}] ${task.message} (进度: ${task.progress}%)`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
appendLog(`所有SVN抓取任务完成,共生成 ${mdFiles.length} 个Markdown文件`);
appendLog("正在提交AI分析任务...");
const aiData = await apiFetch("/api/ai/analyze", {
method: "POST",
body: JSON.stringify({
filePaths: mdFiles,
period: payload.period || "",
apiKey: "",
outputFileName: payload.outputFileName || "",
}),
});
appendSystemLog(`AI分析任务已创建 (任务ID: ${aiData.taskId.slice(0,8)})`);
appendSystemLog("正在进行AI分析,请耐心等待...");
const streamState = {
reasoningBuffer: "", answerBuffer: "", streamAvailable: true, streamConnected: false,
firstEventReceived: false, firstDeltaReceived: false, streamCompleted: false,
taskTerminal: false, reasoningRenderedLength: 0, answerRenderedLength: 0, lastAiStatusLogged: "",
};
aiStream = openTaskEventStream(aiData.taskId, {
onOpen: () => { streamState.streamConnected = true; appendSystemLog("实时流连接已建立"); },
onFirstEvent: () => { streamState.firstEventReceived = true; },
onPhase: (payload) => { if (payload && payload.message) appendSystemLog(payload.message); },
onReasoning: (text) => {
if (!text) return;
if (!streamState.firstDeltaReceived) { streamState.firstDeltaReceived = true; appendSystemLog("已接收 AI 流式输出"); }
streamState.reasoningRenderedLength += text.length;
streamState.reasoningBuffer += text;
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", false);
},
onAnswer: (text) => {
if (!text) return;
if (!streamState.firstDeltaReceived) { streamState.firstDeltaReceived = true; appendSystemLog("已接收 AI 流式输出"); }
streamState.answerRenderedLength += text.length;
streamState.answerBuffer += text;
flushStreamBuffer(streamState, "answerBuffer", "answer", false);
},
onUsage: (payload) => {
if (!payload) return;
appendSystemLog(`Token统计: prompt=${payload.promptTokens || 0}, completion=${payload.completionTokens || 0}, total=${payload.totalTokens || 0}`);
},
onError: (payload) => {
const detail = payload && (payload.detail || payload.error || payload.message);
if (detail) appendSystemLog(`流式错误: ${detail}`, true);
},
onDone: () => { streamState.streamCompleted = true; appendSystemLog("流式输出已结束"); },
onTransportError: (meta) => {
if (streamState.taskTerminal || streamState.streamCompleted) return;
if (streamState.streamAvailable) {
streamState.streamAvailable = false;
if (meta && !meta.opened) appendSystemLog("实时流连接失败,已回退到轮询模式", true);
else if (meta && !meta.firstEventReceived) appendSystemLog("实时流未收到事件即中断,已回退到轮询模式", true);
else appendSystemLog("实时流中断,已回退到轮询模式", true);
}
},
});
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`);
syncTaskAiOutput(task, streamState);
if (!streamState.streamAvailable && task.aiStreamStatus && task.aiStreamStatus !== streamState.lastAiStatusLogged) {
streamState.lastAiStatusLogged = task.aiStreamStatus;
appendSystemLog(`轮询回显状态: ${task.aiStreamStatus}`);
}
if (task.status === "SUCCESS") {
streamState.taskTerminal = true;
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
flushStreamBuffer(streamState, "answerBuffer", "answer", true);
syncTaskAiOutput(task, streamState);
appendSystemLog("AI分析完成");
if (task.message) appendSystemLog(task.message);
if (!streamState.streamCompleted) {
const completed = await waitForStreamCompletion(streamState, 2500);
if (!completed) appendSystemLog("流式收尾超时,已使用轮询结果完成任务");
}
aiStream.close();
break;
}
if (task.status === "FAILED" || task.status === "CANCELLED") {
streamState.taskTerminal = true;
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
flushStreamBuffer(streamState, "answerBuffer", "answer", true);
syncTaskAiOutput(task, streamState);
aiStream.close();
throw new Error(`AI分析失败: ${task.error || task.message}`);
}
if (!streamState.streamAvailable && task.message) {
const lastLog = document.querySelector("#system-log-output p:last-child");
if (!lastLog || !lastLog.textContent.includes(task.message)) appendSystemLog(task.message);
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
const aiTask = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`);
if (aiTask.files && aiTask.files.length > 0) {
const excelFile = aiTask.files.find(f => f.endsWith(".xlsx"));
if (excelFile) {
appendSystemLog("Excel生成成功,开始下载...");
await downloadOutputFile(excelFile);
appendSystemLog("✅ 任务全部完成!");
}
}
refreshAll();
} catch (err) {
appendLog(`错误: ${err.message}`, true);
toast(err.message, true);
} finally {
if (aiStream) aiStream.close();
setLoading(btn, false);
form.disabled = false;
}
}
function openTaskEventStream(taskId, handlers = {}) {
if (!window.EventSource) {
handlers.onTransportError && handlers.onTransportError();
return { close: () => {} };
}
const streamUrl = `/api/tasks/${encodeURIComponent(taskId)}/stream`;
const source = new EventSource(streamUrl);
const meta = { opened: false, done: false, firstEventReceived: false };
const parse = (event) => { try { return JSON.parse(event.data || "{}"); } catch (err) { return {}; } };
const markFirstEvent = () => { if (!meta.firstEventReceived) { meta.firstEventReceived = true; handlers.onFirstEvent && handlers.onFirstEvent(); } };
source.onopen = () => { meta.opened = true; handlers.onOpen && handlers.onOpen(); };
source.addEventListener("phase", (event) => { markFirstEvent(); handlers.onPhase && handlers.onPhase(parse(event)); });
source.addEventListener("reasoning_delta", (event) => { markFirstEvent(); handlers.onReasoning && handlers.onReasoning(parse(event).text || ""); });
source.addEventListener("answer_delta", (event) => { markFirstEvent(); handlers.onAnswer && handlers.onAnswer(parse(event).text || ""); });
source.addEventListener("usage", (event) => { markFirstEvent(); handlers.onUsage && handlers.onUsage(parse(event)); });
source.addEventListener("error", (event) => { markFirstEvent(); handlers.onError && handlers.onError(parse(event)); });
source.addEventListener("done", (event) => { markFirstEvent(); meta.done = true; handlers.onDone && handlers.onDone(parse(event)); source.close(); });
source.onerror = () => {
if (meta.done) return;
handlers.onTransportError && handlers.onTransportError({ opened: meta.opened, closed: source.readyState === EventSource.CLOSED, firstEventReceived: meta.firstEventReceived });
source.close();
};
return { close: () => source.close() };
}
function flushStreamBuffer(streamState, key, target, force) {
const text = streamState[key] || "";
if (!text) return;
const shouldFlush = force || text.length >= 64 || /[。!?\n]$/.test(text);
if (!shouldFlush) return;
const cleaned = text.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
if (!cleaned) { streamState[key] = ""; return; }
if (target === "reasoning") appendReasoning(cleaned);
else if (target === "answer") appendAnswer(cleaned);
else appendSystemLog(cleaned);
streamState[key] = "";
}
function appendSystemLog(message, isError = false) {
const logOutput = document.querySelector("#system-log-output");
if (!logOutput) return;
markPanelReady("#system-log-output");
const p = document.createElement("p");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
p.className = isError ? "log-line is-error" : "log-line is-info";
p.textContent = `[${time}] ${isError ? '❌' : '️'} ${message}`;
logOutput.appendChild(p);
logOutput.scrollTop = logOutput.scrollHeight;
}
function appendReasoning(message) { appendPane("#reasoning-output", message, "is-reasoning"); }
function appendAnswer(message) { appendPane("#answer-output", message, "is-answer"); }
function appendPane(selector, message, toneClass) {
const logOutput = document.querySelector(selector);
if (!logOutput) return;
markPanelReady(selector);
const p = document.createElement("p");
p.className = `log-line ${toneClass}`.trim();
p.textContent = message;
logOutput.appendChild(p);
logOutput.scrollTop = logOutput.scrollHeight;
}
function appendLog(message, isError = false) { appendSystemLog(message, isError); }
function clearLog() {
document.querySelector("#system-log-output").innerHTML = "<p class='muted'>等待任务开始...</p>";
document.querySelector("#reasoning-output").innerHTML = "<p class='muted'>等待思考输出...</p>";
document.querySelector("#answer-output").innerHTML = "<p class='muted'>等待答案输出...</p>";
}
function markPanelReady(selector) {
const panel = document.querySelector(selector);
if (!panel) return;
const muted = panel.querySelector(".muted");
if (muted) muted.remove();
}
function renderTaskTable() {
const container = document.querySelector("#task-table");
if (!state.taskPage.items.length) {
container.innerHTML = "<p class='muted' style='padding:20px'>暂无任务记录</p>";
renderTaskPager();
return;
}
const rows = state.taskPage.items.map((task) => {
const files = (task.files || []).map((f) => buildDownloadLink(f, escapeHtml(f))).join("<br>");
const canCancel = task.status === "RUNNING" || task.status === "PENDING";
return `<tr>
<td style="font-family:var(--font-mono)">${escapeHtml(task.taskId.slice(0, 8))}</td>
<td>${escapeHtml(task.type)}</td>
<td><span class="tag ${task.status}">${task.status}</span></td>
<td>${task.progress || 0}%</td>
<td><div style="max-width:250px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${escapeHtml(task.message || "")}">${escapeHtml(task.message || "")}</div>${task.error ? `<br><span class='muted'>${escapeHtml(task.error)}</span>` : ""}</td>
<td>${files || "-"}</td>
<td>${canCancel ? `<button type="button" class="btn-cancel-task" data-task-id="${escapeHtml(task.taskId)}">取消</button>` : "-"}</td>
</tr>`;
}).join("");
container.innerHTML = `<table><thead><tr><th>任务ID</th><th>类型</th><th>状态</th><th>进度</th><th>说明</th><th>产物</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table>`;
document.querySelectorAll(".btn-cancel-task").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!btn.dataset.taskId) return;
setLoading(btn, true);
try {
const result = await apiFetch(`/api/tasks/${encodeURIComponent(btn.dataset.taskId)}/cancel`, { method: "POST" });
toast(result.message || "任务取消请求已处理");
await loadTaskPage();
await refreshAll();
} catch (err) { toast(err.message, true); }
finally { setLoading(btn, false); }
});
});
renderTaskPager();
}
async function loadTaskPage() {
const params = new URLSearchParams();
if (state.taskQuery.status) params.set("status", state.taskQuery.status);
if (state.taskQuery.type) params.set("type", state.taskQuery.type);
if (state.taskQuery.keyword) params.set("keyword", state.taskQuery.keyword);
params.set("page", String(state.taskQuery.page));
params.set("size", String(state.taskQuery.size));
try {
const data = await apiFetch(`/api/tasks/query?${params.toString()}`);
state.taskPage = { items: data.items || [], page: data.page || 1, size: data.size || state.taskQuery.size, total: data.total || 0 };
renderTaskTable();
} catch (err) { toast(err.message, true); }
}
function onTaskFilterSubmit() {
state.taskQuery.status = document.querySelector("#task-filter-status").value || "";
state.taskQuery.type = document.querySelector("#task-filter-type").value || "";
state.taskQuery.keyword = (document.querySelector("#task-filter-keyword").value || "").trim();
state.taskQuery.page = 1;
loadTaskPage();
}
function renderTaskPager() {
const pager = document.querySelector("#task-pager");
if (!pager) return;
const totalPages = Math.max(1, Math.ceil((state.taskPage.total || 0) / state.taskQuery.size));
const current = state.taskPage.page || 1;
pager.innerHTML = `<span>共 <strong>${state.taskPage.total || 0}</strong> 条记录,第 ${current} / ${totalPages} 页</span>
<div class="pager-actions">
<button type="button" ${current <= 1 ? "disabled" : ""} id="btn-page-prev">上一页</button>
<button type="button" ${current >= totalPages ? "disabled" : ""} id="btn-page-next">下一页</button>
</div>`;
if (document.querySelector("#btn-page-prev")) document.querySelector("#btn-page-prev").addEventListener("click", () => { if (state.taskQuery.page > 1) { state.taskQuery.page -= 1; loadTaskPage(); } });
if (document.querySelector("#btn-page-next")) document.querySelector("#btn-page-next").addEventListener("click", () => { if (state.taskQuery.page < totalPages) { state.taskQuery.page += 1; loadTaskPage(); } });
}
function renderFileTable() {
const container = document.querySelector("#file-table");
if (!state.files.length) { container.innerHTML = "<p class='muted' style='padding:20px'>暂无输出文件</p>"; return; }
const rows = state.files.map((file) => `<tr><td style="word-break:break-all">${escapeHtml(file.path)}</td><td style="font-family:var(--font-mono)">${formatBytes(file.size)}</td><td style="color:var(--text-secondary)">${formatTime(file.modifiedAt)}</td><td>${buildDownloadLink(file.path, "📥 下载")}</td></tr>`).join("");
container.innerHTML = `<table><thead><tr><th>文件路径</th><th>大小</th><th>更新时间</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table>`;
}
async function loadSettings() {
try {
const data = await apiFetch("/api/settings");
document.querySelector("#settings-form [name='provider']").value = data.provider || "deepseek";
document.querySelector("#settings-form [name='apiKey']").value = "";
document.querySelector("#settings-form [name='openaiBaseUrl']").value = data.openaiBaseUrl || "";
document.querySelector("#settings-form [name='openaiApiKey']").value = "";
document.querySelector("#settings-form [name='openaiStageOneModel']").value = data.openaiStageOneModel || "deepseek-v4-flash";
document.querySelector("#settings-form [name='openaiStageTwoModel']").value = data.openaiStageTwoModel || "deepseek-v4-pro";
document.querySelector("#settings-form [name='svnUsername']").value = data.svnUsername || "";
document.querySelector("#settings-form [name='svnPassword']").value = "";
document.querySelector("#settings-form [name='outputDir']").value = data.outputDir || "";
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
const settingsPreset = document.querySelector("#settings-default-preset");
if (settingsPreset && state.defaultPresetId) settingsPreset.value = state.defaultPresetId;
applyPresetToSvnForm(state.defaultPresetId);
updateSettingsProviderUI(data.provider || "deepseek");
renderSettingsState(data);
} catch (err) { toast(err.message, true); }
}
async function onSaveSettings(event) {
event.preventDefault();
const btn = event.target.querySelector("button[type='submit']");
setLoading(btn, true);
try {
const data = await apiFetch("/api/settings", { method: "PUT", body: JSON.stringify(readForm(event.target)) });
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
applyPresetToSvnForm(state.defaultPresetId);
updateSettingsProviderUI(data.provider || "deepseek");
renderSettingsState(data);
document.querySelector("#settings-form [name='apiKey']").value = "";
document.querySelector("#settings-form [name='openaiApiKey']").value = "";
document.querySelector("#settings-form [name='svnPassword']").value = "";
toast("✅ 设置保存成功");
} catch (err) { toast(err.message, true); }
finally { setLoading(btn, false); }
}
function readForm(form) { return Object.fromEntries(new FormData(form).entries()); }
function updateSettingsProviderUI(provider) {
const openaiGroup = document.querySelector("#openai-settings-group");
const deepseekGroup = document.querySelector("#deepseek-settings-group");
const showOpenai = provider === "openai-compatible";
if (openaiGroup) openaiGroup.hidden = !showOpenai;
if (deepseekGroup) deepseekGroup.hidden = showOpenai;
}
function renderSettingsState(data) {
const el = document.querySelector("#settings-state");
if (!el) return;
el.style.display = "block"; // 确保数据加载后显示
if ((data.provider || "deepseek") === "openai-compatible") {
el.innerHTML = `当前提供商:<strong style="color:white">OpenAI兼容</strong><br>Base URL${data.openaiBaseUrl || "(未配置)"}<br>API Key${data.openaiApiKeyConfigured ? "<span style='color:var(--success)'>已配置</span>" : "<span style='color:var(--warning)'>未配置</span>"}<br>Stage1${data.openaiStageOneModel || "-"}<br>Stage2${data.openaiStageTwoModel || "-"}<br>SVN 账号:${renderSvnCredentialState(data)}`;
} else {
el.innerHTML = `当前提供商:<strong style="color:white">DeepSeek</strong><br>API Key 状态:${data.apiKeyConfigured ? "<span style='color:var(--success)'>已配置</span>" : "<span style='color:var(--warning)'>未配置</span>"}(来源:${data.apiKeySource}<br>SVN 账号:${renderSvnCredentialState(data)}`;
}
}
function renderSvnCredentialState(data) {
const username = data.svnUsername || "(未配置用户名)";
const configured = data.svnCredentialsConfigured
? "<span style='color:var(--success)'>已配置</span>"
: "<span style='color:var(--warning)'>未配置</span>";
return `${escapeHtml(username)} / ${configured}<br>默认 SVN 项目仅用于连接测试优先选择`;
}
function setLoading(button, loading) {
if (!button) return;
button.disabled = loading;
if (loading) { button.dataset.originalText = button.textContent; button.textContent = "处理中..."; }
else { button.textContent = button.dataset.originalText || button.textContent; }
}
function toNumberOrNull(value) {
if (value === null || value === undefined || String(value).trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toast(message, isError = false) {
const el = document.querySelector("#toast");
el.textContent = message;
el.style.borderLeft = isError ? "4px solid var(--danger)" : "4px solid var(--success)";
el.classList.remove("show"); void el.offsetWidth; el.classList.add("show");
setTimeout(() => { el.classList.remove("show"); }, 3000);
}
function escapeHtml(text) { return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); }
function formatBytes(bytes) {
if (bytes === null || bytes === undefined) return "-";
const units = ["B", "KB", "MB", "GB"];
let value = Number(bytes); let idx = 0;
while (value >= 1024 && idx < units.length - 1) { value = value / 1024; idx += 1; }
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function formatTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { hour12: false });
}
function sortByTimeDesc(left, right) {
const l = left ? new Date(left).getTime() : 0;
const r = right ? new Date(right).getTime() : 0;
return r - l;
}
async function onAutoFillVersions() {
const btn = document.querySelector("#btn-auto-fill");
const [year, month] = document.querySelector("#version-month").value.split("-");
setLoading(btn, true);
appendLog(`开始查询 ${year}${month}月 的版本范围...`);
if (!state.presets || state.presets.length < 3) { appendLog("错误:未加载到完整 SVN 预设,请刷新页面后重试", true); setLoading(btn, false); return; }
const projects = [1, 2, 3].map((index) => ({
presetId: state.presets[index - 1].id, name: state.presets[index - 1].name,
startInput: document.querySelector(`#svn-form [name='startRevision_${index}']`),
endInput: document.querySelector(`#svn-form [name='endRevision_${index}']`),
}));
try {
for (const project of projects) {
appendLog(`正在查询 ${project.name} 的版本范围...`);
const traceId = `autofill-${project.presetId}-${Date.now()}`;
const requestPayload = {
presetId: project.presetId,
year: parseInt(year, 10),
month: parseInt(month, 10),
clientTraceId: traceId
};
const data = await apiFetch("/api/svn/version-range", { method: "POST", body: JSON.stringify(requestPayload) });
appendLog(`[AutoFill][Response] traceId=${traceId} payload=${safeJsonStringify(data)}`);
if (data.startRevision && data.endRevision) {
project.startInput.value = data.startRevision; project.endInput.value = data.endRevision;
appendLog(`${project.name} 版本范围: ${data.startRevision} - ${data.endRevision}`);
} else { appendLog(`⚠️ ${project.name} 该月份无提交记录`, true); }
}
appendLog("✅ 所有项目版本号填充完成"); toast("✅ 版本号填充完成");
} catch (err) { appendLog(`填充失败: ${err.message}`, true); toast(err.message, true); }
finally { setLoading(btn, false); }
}
function maskSecret(secret) { return !secret ? "(empty)" : `***len=${String(secret).length}`; }
function safeJsonStringify(value) { try { const raw = JSON.stringify(value); return (!raw) ? "{}" : (raw.length > 600 ? `${raw.slice(0, 600)}...(truncated)` : raw); } catch (err) { return `"<invalid-json:${err.message}>"`; } }
function pickConnectionPresetId() {
if (!state.presets || state.presets.length === 0) {
return "";
}
const hasDefault = state.defaultPresetId && state.presets.some((preset) => preset.id === state.defaultPresetId);
return hasDefault ? state.defaultPresetId : state.presets[0].id;
}</script></body></html>