feat(web): 增强任务治理与系统诊断能力

新增任务持久化、筛选分页、取消任务、健康检查与 AI 输入校验,并完善前端历史管理交互与容错重试机制。补充对应单元测试,提升系统稳定性和可运维性。
This commit is contained in:
2026-03-08 23:35:36 +08:00
parent e26fb9cebb
commit bdf6367404
21 changed files with 1049 additions and 34 deletions
+120 -6
View File
@@ -1,6 +1,9 @@
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",
@@ -51,6 +54,11 @@ function bindForms() {
const svnPresetSelect = document.querySelector("#svn-preset-select");
svnPresetSelect.addEventListener("change", onSvnPresetChange);
const taskFilterBtn = document.querySelector("#btn-task-filter");
if (taskFilterBtn) {
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
}
}
function switchView(view) {
@@ -65,7 +73,7 @@ function switchView(view) {
document.querySelector("#view-desc").textContent = viewMeta[view].desc;
if (view === "history") {
renderTaskTable();
loadTaskPage();
renderFileTable();
}
if (view === "ai") {
@@ -88,15 +96,17 @@ async function apiFetch(url, options = {}) {
async function refreshAll() {
try {
const [tasksResp, filesResp] = await Promise.all([
const [tasksResp, filesResp, healthResp] = await Promise.all([
apiFetch("/api/tasks"),
apiFetch("/api/files"),
apiFetch("/api/health/details"),
]);
state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
state.health = healthResp || null;
renderDashboard();
if (state.activeView === "history") {
renderTaskTable();
loadTaskPage();
renderFileTable();
}
if (state.activeView === "ai") {
@@ -180,10 +190,19 @@ 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 = "";
@@ -328,13 +347,15 @@ async function onRunAi(event) {
function renderTaskTable() {
const container = document.querySelector("#task-table");
if (!state.tasks.length) {
if (!state.taskPage.items.length) {
container.innerHTML = "<p class='muted'>暂无任务记录</p>";
renderTaskPager();
return;
}
const rows = state.tasks.map((task) => {
const rows = state.taskPage.items.map((task) => {
const files = (task.files || []).map((f) => `<a href="/api/files/download?path=${encodeURIComponent(f)}">${escapeHtml(f)}</a>`).join("<br>");
const canCancel = task.status === "RUNNING" || task.status === "PENDING";
return `<tr>
<td>${escapeHtml(task.taskId.slice(0, 8))}</td>
<td>${escapeHtml(task.type)}</td>
@@ -342,13 +363,106 @@ function renderTaskTable() {
<td>${task.progress || 0}%</td>
<td>${escapeHtml(task.message || "")}${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></tr></thead>
<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 () => {
const taskId = btn.dataset.taskId;
if (!taskId) {
return;
}
setLoading(btn, true);
try {
const result = await apiFetch(`/api/tasks/${encodeURIComponent(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>共 ${state.taskPage.total || 0} 条,第 ${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>
`;
const prev = document.querySelector("#btn-page-prev");
const next = document.querySelector("#btn-page-next");
if (prev) {
prev.addEventListener("click", () => {
if (state.taskQuery.page > 1) {
state.taskQuery.page -= 1;
loadTaskPage();
}
});
}
if (next) {
next.addEventListener("click", () => {
if (state.taskQuery.page < totalPages) {
state.taskQuery.page += 1;
loadTaskPage();
}
});
}
}
function renderFileTable() {
+28 -1
View File
@@ -26,7 +26,7 @@
</header>
<section class="view active" id="view-dashboard" aria-live="polite">
<div class="grid cols-3" id="stats-cards">
<div class="grid cols-4" id="stats-cards">
<article class="card stat">
<h3>任务总数</h3>
<p id="stat-total">0</p>
@@ -39,8 +39,17 @@
<h3>失败任务</h3>
<p id="stat-failed">0</p>
</article>
<article class="card stat">
<h3>系统状态</h3>
<p id="stat-health">-</p>
</article>
</div>
<article class="card" id="health-card">
<h3>健康检查</h3>
<p class="muted" id="health-details">加载中...</p>
</article>
<div class="grid cols-2">
<article class="card">
<h3>最近任务</h3>
@@ -94,7 +103,25 @@
<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">查询</button>
</div>
<div id="task-table" class="table-wrap"></div>
<div class="pager" id="task-pager"></div>
</article>
<article class="card">
<h3>输出文件</h3>
+36
View File
@@ -115,6 +115,11 @@ body {
margin-bottom: 16px;
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 16px;
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -276,6 +281,27 @@ button:disabled {
overflow-x: auto;
}
.history-toolbar {
display: grid;
grid-template-columns: 180px 180px minmax(220px, 1fr) 120px;
gap: 10px;
margin-bottom: 12px;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
color: var(--muted);
font-size: 14px;
}
.pager .pager-actions {
display: flex;
gap: 8px;
}
table {
width: 100%;
border-collapse: collapse;
@@ -315,6 +341,11 @@ td {
color: var(--danger);
}
.tag.CANCELLED {
background: #e4e7ec;
color: #344054;
}
.muted {
color: var(--muted);
}
@@ -353,11 +384,16 @@ td {
}
.grid.cols-3,
.grid.cols-4,
.grid.cols-2,
.form-grid {
grid-template-columns: 1fr;
}
.history-toolbar {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}