(taskInfo.getFiles()));
+ if (detail != null && !detail.trim().isEmpty()) {
+ payload.put("detail", detail);
+ }
+ payload.put("error", taskInfo.getError());
+ return payload;
+ }
+
+ private boolean isTerminal(TaskStatus status) {
+ return status == TaskStatus.SUCCESS || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED;
+ }
+
@PreDestroy
public void destroy() {
executor.shutdownNow();
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..d628371
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js
index dfb1635..b7d1c0d 100644
--- a/src/main/resources/static/app.js
+++ b/src/main/resources/static/app.js
@@ -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 = "暂无 Markdown 文件,请先执行 SVN 抓取。
";
- 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 = "等待任务开始...
";
+ reasoning.innerHTML = "等待思考输出...
";
+ answer.innerHTML = "等待答案输出...
";
+}
+
+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);
+ }
+}
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
index f031a42..2cbfd5e 100644
--- a/src/main/resources/static/index.html
+++ b/src/main/resources/static/index.html
@@ -11,9 +11,8 @@