feat(web): unify web entry, preset config, SSE streaming and dual-pane live logs

This commit is contained in:
liumangmang
2026-04-03 15:40:31 +08:00
parent 2d6c64ecff
commit 2150dfe24e
42 changed files with 1917 additions and 1533 deletions
+23
View File
@@ -0,0 +1,23 @@
# 服务器配置
server.port=18088
server.servlet.context-path=/
# 文件上传配置
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB
# 日志配置
logging.level.com.svnlog=INFO
logging.level.org.springframework=INFO
# SVN 预设配置
svn.default-preset-id=preset-1
svn.presets[0].id=preset-1
svn.presets[0].name=PRS-7050场站智慧管控
svn.presets[0].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00
svn.presets[1].id=preset-2
svn.presets[1].name=PRS-7950在线巡视
svn.presets[1].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00
svn.presets[2].id=preset-3
svn.presets[2].name=PRS-7950在线巡视电科院测试版
svn.presets[2].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024
+419 -133
View File
@@ -14,8 +14,7 @@ const CUSTOM_PRESET_ID = "custom";
const viewMeta = {
dashboard: { title: "工作台", desc: "查看系统状态与最近产物" },
svn: { title: "SVN 日志抓取", desc: "配置 SVN 参数并生成 Markdown" },
ai: { title: "AI 工作量分析", desc: "选择 Markdown 后生成工作量 Excel" },
svn: { title: "SVN 日志抓取", desc: "一键抓取SVN日志并导出工作量Excel" },
history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" },
settings: { title: "系统设置", desc: "配置 API Key 与输出目录" },
};
@@ -26,6 +25,20 @@ document.addEventListener("DOMContentLoaded", async () => {
await loadPresets();
await refreshAll();
await loadSettings();
// 自动填充当月默认值
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
// 月份选择器:YYYY-MM
document.querySelector("#version-month").value = `${year}-${month}`;
// 工作周期:YYYY年MM月
document.querySelector("#svn-form [name='period']").value = `${year}${month}`;
// 输出文件名:YYYYMM工作量统计.xlsx
document.querySelector("#svn-form [name='outputFileName']").value = `${year}${month}工作量统计.xlsx`;
// 绑定自动填充版本按钮
document.querySelector("#btn-auto-fill").addEventListener("click", onAutoFillVersions);
state.polling = setInterval(refreshAll, 5000);
});
@@ -46,15 +59,11 @@ function bindForms() {
const svnForm = document.querySelector("#svn-form");
svnForm.addEventListener("submit", onRunSvn);
const aiForm = document.querySelector("#ai-form");
aiForm.addEventListener("submit", onRunAi);
const settingsForm = document.querySelector("#settings-form");
settingsForm.addEventListener("submit", onSaveSettings);
const svnPresetSelect = document.querySelector("#svn-preset-select");
svnPresetSelect.addEventListener("change", onSvnPresetChange);
const taskFilterBtn = document.querySelector("#btn-task-filter");
if (taskFilterBtn) {
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
@@ -76,9 +85,7 @@ function switchView(view) {
loadTaskPage();
renderFileTable();
}
if (view === "ai") {
renderMdFilePicker();
}
}
async function apiFetch(url, options = {}) {
@@ -109,9 +116,7 @@ async function refreshAll() {
loadTaskPage();
renderFileTable();
}
if (state.activeView === "ai") {
renderMdFilePicker();
}
} catch (err) {
toast(err.message, true);
}
@@ -130,60 +135,31 @@ async function loadPresets() {
}
function renderPresetSelects() {
const svnSelect = document.querySelector("#svn-preset-select");
const settingsSelect = document.querySelector("#settings-default-preset");
svnSelect.innerHTML = "";
if (!settingsSelect) return;
settingsSelect.innerHTML = "";
state.presets.forEach((preset) => {
const option1 = document.createElement("option");
option1.value = preset.id;
option1.textContent = `${preset.name}`;
svnSelect.appendChild(option1);
const option2 = document.createElement("option");
option2.value = preset.id;
option2.textContent = `${preset.name}`;
settingsSelect.appendChild(option2);
const option = document.createElement("option");
option.value = preset.id;
option.textContent = `${preset.name}`;
settingsSelect.appendChild(option);
});
const customOption = document.createElement("option");
customOption.value = CUSTOM_PRESET_ID;
customOption.textContent = "自定义 SVN 地址";
svnSelect.appendChild(customOption);
const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : CUSTOM_PRESET_ID);
svnSelect.value = selected;
settingsSelect.value = selected;
const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : "");
if (selected) {
settingsSelect.value = selected;
}
}
// 已移除单个项目预设功能,使用固定三个项目配置
function onSvnPresetChange(event) {
applyPresetToSvnForm(event.target.value);
// 保留空实现兼容
}
function applyPresetToSvnForm(presetId) {
const form = document.querySelector("#svn-form");
const select = document.querySelector("#svn-preset-select");
const projectInput = form.querySelector("[name='projectName']");
const urlInput = form.querySelector("[name='url']");
if (presetId === CUSTOM_PRESET_ID) {
select.value = CUSTOM_PRESET_ID;
projectInput.readOnly = false;
urlInput.readOnly = false;
return;
}
const preset = state.presets.find((item) => item.id === presetId);
if (!preset) {
return;
}
select.value = preset.id;
projectInput.value = preset.name;
urlInput.value = preset.url;
projectInput.readOnly = true;
urlInput.readOnly = true;
// 保留空实现兼容
}
function renderDashboard() {
@@ -229,22 +205,20 @@ function renderDashboard() {
}
async function onTestConnection() {
const form = document.querySelector("#svn-form");
const payload = readForm(form);
if (!payload.url || !payload.username || !payload.password) {
toast("请先填写 SVN 地址、账号和密码", true);
return;
}
const btn = document.querySelector("#btn-test-connection");
setLoading(btn, true);
try {
const firstPreset = state.presets && state.presets.length > 0 ? state.presets[0] : null;
if (!firstPreset || !firstPreset.id) {
throw new Error("未加载到 SVN 预设,请刷新页面后重试");
}
await apiFetch("/api/svn/test-connection", {
method: "POST",
body: JSON.stringify({
url: payload.url,
username: payload.username,
password: payload.password,
presetId: firstPreset.id,
username: "liujing2",
password: "sunri@20230620*#&",
}),
});
toast("SVN 连接成功");
@@ -255,93 +229,344 @@ async function onTestConnection() {
}
}
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}`);
}
// 等待2秒再查询
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (err) {
throw err;
}
}
}
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 {
const data = await apiFetch("/api/svn/fetch", {
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,
username: "liujing2",
password: "sunri@20230620*#&",
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文件`);
// 调用AI分析接口
appendLog("正在提交AI分析任务...");
const aiData = await apiFetch("/api/ai/analyze", {
method: "POST",
body: JSON.stringify({
projectName: payload.projectName || "",
url: payload.url,
username: payload.username,
password: payload.password,
startRevision: toNumberOrNull(payload.startRevision),
endRevision: toNumberOrNull(payload.endRevision),
filterUser: payload.filterUser || "",
}),
});
toast(`SVN 抓取任务已创建:${data.taskId}`);
switchView("history");
refreshAll();
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
}
}
function renderMdFilePicker() {
const box = document.querySelector("#md-file-picker");
const mdFiles = state.files.filter((f) => f.path.toLowerCase().endsWith(".md"));
box.innerHTML = "";
if (mdFiles.length === 0) {
box.innerHTML = "<p class='muted'>暂无 Markdown 文件,请先执行 SVN 抓取。</p>";
return;
}
mdFiles.forEach((file, idx) => {
const path = file.path;
const id = `md-file-${idx}`;
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.id = id;
input.value = path;
label.setAttribute("for", id);
const span = document.createElement("span");
span.textContent = `${path} (${formatBytes(file.size)})`;
label.appendChild(input);
label.appendChild(span);
box.appendChild(label);
});
}
async function onRunAi(event) {
event.preventDefault();
const form = event.target;
const payload = readForm(form);
const checked = [...document.querySelectorAll("#md-file-picker input[type='checkbox']:checked")]
.map((input) => input.value);
if (!checked.length) {
toast("请至少选择一个 Markdown 文件", true);
return;
}
const btn = document.querySelector("#btn-ai-run");
setLoading(btn, true);
try {
const data = await apiFetch("/api/ai/analyze", {
method: "POST",
body: JSON.stringify({
filePaths: checked,
filePaths: mdFiles,
period: payload.period || "",
apiKey: payload.apiKey || "",
apiKey: "", // 使用内置API Key
outputFileName: payload.outputFileName || "",
}),
});
toast(`AI 分析任务已创建:${data.taskId}`);
switchView("history");
appendSystemLog(`AI分析任务已创建 (任务ID: ${aiData.taskId.slice(0,8)})`);
appendSystemLog("正在进行AI分析,请耐心等待...");
const streamState = {
reasoningBuffer: "",
answerBuffer: "",
streamAvailable: true,
};
aiStream = openTaskEventStream(aiData.taskId, {
onPhase: (payload) => {
if (payload && payload.message) {
appendSystemLog(payload.message);
}
},
onReasoning: (text) => {
if (!text) return;
streamState.reasoningBuffer += text;
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", false);
},
onAnswer: (text) => {
if (!text) return;
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) => {
if (payload && payload.detail) {
appendSystemLog(`流式错误: ${payload.detail}`, true);
}
},
onTransportError: () => {
if (streamState.streamAvailable) {
streamState.streamAvailable = false;
appendSystemLog("实时流中断,已回退到轮询模式");
}
},
});
// 等待AI任务完成,实时显示日志
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`);
if (task.status === "SUCCESS") {
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
flushStreamBuffer(streamState, "answerBuffer", "answer", true);
appendSystemLog("AI分析完成");
if (task.message) appendSystemLog(task.message);
aiStream.close();
break;
}
if (task.status === "FAILED" || task.status === "CANCELLED") {
flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true);
flushStreamBuffer(streamState, "answerBuffer", "answer", true);
aiStream.close();
throw new Error(`AI分析失败: ${task.error || task.message}`);
}
// 显示AI思考过程
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)}`);
// 找到生成的Excel文件并自动下载
if (aiTask.files && aiTask.files.length > 0) {
const excelFile = aiTask.files.find(f => f.endsWith(".xlsx"));
if (excelFile) {
appendSystemLog("Excel生成成功,开始下载...");
// 触发下载
window.open(`/api/files/download?path=${encodeURIComponent(excelFile)}`, "_blank");
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 parse = (event) => {
try {
return JSON.parse(event.data || "{}");
} catch (err) {
return {};
}
};
source.addEventListener("phase", (event) => {
handlers.onPhase && handlers.onPhase(parse(event));
});
source.addEventListener("reasoning_delta", (event) => {
const payload = parse(event);
handlers.onReasoning && handlers.onReasoning(payload.text || "");
});
source.addEventListener("answer_delta", (event) => {
const payload = parse(event);
handlers.onAnswer && handlers.onAnswer(payload.text || "");
});
source.addEventListener("usage", (event) => {
handlers.onUsage && handlers.onUsage(parse(event));
});
source.addEventListener("error", (event) => {
handlers.onError && handlers.onError(parse(event));
});
source.onerror = () => {
handlers.onTransportError && handlers.onTransportError();
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(/\s+/g, " ").trim();
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");
markPanelReady("#system-log-output");
const p = document.createElement("p");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
p.style.margin = "2px 0";
if (isError) {
p.style.color = "#dc2626";
p.textContent = `[${time}] ❌ ${message}`;
} else {
p.style.color = "#1e293b";
p.textContent = `[${time}] ${message}`;
}
logOutput.appendChild(p);
logOutput.scrollTop = logOutput.scrollHeight;
}
function appendReasoning(message) {
appendPane("#reasoning-output", message, "#334155");
}
function appendAnswer(message) {
appendPane("#answer-output", message, "#166534");
}
function appendPane(selector, message, color) {
const logOutput = document.querySelector(selector);
markPanelReady(selector);
const p = document.createElement("p");
p.style.margin = "2px 0";
p.style.color = color;
p.textContent = message;
logOutput.appendChild(p);
logOutput.scrollTop = logOutput.scrollHeight;
}
// 兼容旧调用
function appendLog(message, isError = false) {
appendSystemLog(message, isError);
}
function clearLog() {
const system = document.querySelector("#system-log-output");
const reasoning = document.querySelector("#reasoning-output");
const answer = document.querySelector("#answer-output");
system.innerHTML = "<p class='muted'>等待任务开始...</p>";
reasoning.innerHTML = "<p class='muted'>等待思考输出...</p>";
answer.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();
}
}
@@ -600,3 +825,64 @@ function sortByTimeDesc(left, right) {
const r = right ? new Date(right).getTime() : 0;
return r - l;
}
// 自动填充版本号
async function onAutoFillVersions() {
const btn = document.querySelector("#btn-auto-fill");
const monthInput = document.querySelector("#version-month");
const [year, month] = monthInput.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) => {
const preset = state.presets[index - 1];
return {
presetId: preset.id,
name: preset.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 data = await apiFetch("/api/svn/version-range", {
method: "POST",
body: JSON.stringify({
presetId: project.presetId,
username: "liujing2",
password: "sunri@20230620*#&",
year: parseInt(year),
month: parseInt(month),
filterUser: "liujing2@SZNARI" // 只查询该用户的提交(完整用户名)
}),
});
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);
}
}
+75 -30
View File
@@ -11,9 +11,8 @@
<aside class="sidebar" aria-label="主导航">
<h1>SVN 工作台</h1>
<nav>
<button class="nav-item active" data-view="dashboard">工作台</button>
<button class="nav-item" data-view="svn">SVN 日志抓取</button>
<button class="nav-item" data-view="ai">AI 工作量分析</button>
<button class="nav-item" data-view="dashboard">工作台</button>
<button class="nav-item active" data-view="svn">SVN 日志抓取</button>
<button class="nav-item" data-view="history">任务历史</button>
<button class="nav-item" data-view="settings">系统设置</button>
</nav>
@@ -64,41 +63,87 @@
<section class="view" id="view-svn">
<article class="card form-card">
<h3>SVN 抓取参数</h3>
<h3>SVN 批量抓取参数</h3>
<div class="alert info span-2" style="margin-bottom:16px;padding:12px;border-radius:10px;background:#d1f0eb;color:#0f766e">
默认已填充3个常用项目路径,可选择月份自动填充版本号,或手动填写
</div>
<div class="span-2" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:10px;">
<div class="grid cols-3" style="gap:10px;align-items:end;">
<label>统计月份
<input type="month" id="version-month">
</label>
<div style="grid-column: span 2;">
<button type="button" id="btn-auto-fill" class="primary" style="width:100%">一键填充所有项目版本号</button>
</div>
</div>
</div>
<form id="svn-form" class="form-grid">
<label>预置项目
<select name="presetId" id="svn-preset-select" aria-label="预置 SVN 项目"></select>
</label>
<label>项目名<input name="projectName" placeholder="如:PRS-7050"></label>
<label>SVN 地址<input required name="url" placeholder="https://..." aria-label="SVN 地址"></label>
<label><input required name="username" placeholder="请输入账号"></label>
<label>密码<input required type="password" name="password" placeholder="请输入密码"></label>
<label>开始版本号<input name="startRevision" inputmode="numeric" placeholder="默认最新"></label>
<label>结束版本号<input name="endRevision" inputmode="numeric" placeholder="默认最新"></label>
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤"></label>
<!-- 项目1 -->
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px">
<h4 style="margin:0 0 10px 0">项目 1:PRS-7050 场站智慧管控</h4>
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_1" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本<input name="endRevision_1" inputmode="numeric" placeholder="请输入结束版本"></label>
</div>
</div>
<!-- 项目2 -->
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px">
<h4 style="margin:0 0 10px 0">项目 2PRS-7950 在线巡视</h4>
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_2" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本号<input name="endRevision_2" inputmode="numeric" placeholder="请输入结束版本"></label>
</div>
</div>
<!-- 项目3 -->
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:12px">
<h4 style="margin:0 0 10px 0">项目 3:PRS-7950 在线巡视电科院测试版</h4>
<div class="grid cols-2" style="gap:10px">
<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">开始抓取并导出</button>
<button type="submit" id="btn-svn-run" class="primary">一键抓取并导出 Excel</button>
</div>
</form>
</article>
<!-- 执行日志面板 -->
<article class="card" id="log-panel" style="display:none;margin-top:16px;">
<h3>执行进度</h3>
<div class="live-grid">
<section class="live-column reasoning">
<header>思考过程</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-ai">
<article class="card form-card">
<h3>AI 分析参数</h3>
<form id="ai-form" class="form-grid">
<label class="span-2">选择 Markdown 输入文件</label>
<div class="span-2 file-picker" id="md-file-picker" role="group" aria-label="Markdown 文件选择"></div>
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
<label class="span-2">临时 API Key(可选)<input type="password" name="apiKey" placeholder="优先使用设置页或环境变量"></label>
<div class="actions span-2">
<button type="submit" id="btn-ai-run" class="primary">开始 AI 分析并导出 Excel</button>
</div>
</form>
</article>
</section>
<section class="view" id="view-history">
<article class="card">
+45
View File
@@ -368,6 +368,45 @@ td {
display: block;
}
.live-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.live-column header,
.system-log-wrap > header {
font-size: 13px;
font-weight: 700;
color: var(--muted);
margin-bottom: 6px;
}
.live-output,
.system-output {
height: 240px;
overflow-y: auto;
border-radius: 8px;
padding: 12px;
font-family: monospace;
font-size: 13px;
line-height: 1.6;
border: 1px solid var(--border);
background: #f8f9fa;
}
.live-column.reasoning .live-output {
background: #f6fbff;
}
.live-column.answer .live-output {
background: #f5fdf8;
}
.system-log-wrap {
margin-top: 10px;
}
@media (max-width: 1024px) {
.app-shell {
grid-template-columns: 1fr;
@@ -399,6 +438,12 @@ td {
}
}
@media (max-width: 900px) {
.live-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;