feat(web): 增强任务治理与系统诊断能力
新增任务持久化、筛选分页、取消任务、健康检查与 AI 输入校验,并完善前端历史管理交互与容错重试机制。补充对应单元测试,提升系统稳定性和可运维性。
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user