889 lines
64 KiB
HTML
889 lines
64 KiB
HTML
<!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>项目 2:PRS-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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); }
|
||
|
||
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> |