feat(admin): Phase 6 AI任务监控+成本异常 — 全局任务列/筛/详情抽屉/失败重投

后端:adminpanel tasks(全局 AITask + status/type/team/anomaly 筛分页)+ detail(payload)+ retry(失败重投,
仅图像类 best-effort 否则 400);is_cost_anomaly(实际>预估×1.5)+ F() 异常筛;IsPlatformAdmin + 审计。
前端:adminApi tasks 系列;Admin 任务监控页(状态 tab + 仅成本异常 chip + 表格成本预估→实际+异常标 + 详情抽屉 payload + 失败重投)。
修真 bug:抽屉缺 .show 类停屏外,补上正常滑入。
测试:adminpanel 36 单测过(筛/异常/详情/重投 mock celery + 非失败/不支持/权限拒);
无头 e2e _admin-p6.mjs 6 断言过 + 0 console error(不点真重投);tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 22:19:24 +08:00
co-authored by Claude Opus 4.8
parent 5aab8a568f
commit ca1e50d32c
11 changed files with 528 additions and 3 deletions
+20
View File
@@ -236,3 +236,23 @@
z-index: 50;
}
.bulk-count { font-size: 13px; color: var(--accent-black); font-weight: 500; }
/* ── 任务监控 ── */
.admin-anomaly-chip { height: 28px; padding: 0 14px; font-size: 12.5px; cursor: pointer; }
.admin-drawer { width: 620px; max-width: 92vw; }
.admin-json {
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 12px 14px;
margin: 0 0 16px;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.6;
color: var(--black-alpha-72, var(--accent-black));
white-space: pre-wrap;
word-break: break-all;
max-height: 260px;
overflow: auto;
}
.admin-json-err { color: var(--accent-crimson); background: var(--crimson-bg); border-color: var(--crimson-bd); }
+19
View File
@@ -1,6 +1,8 @@
import type {
AdminQualityWord,
AdminReviewAsset,
AdminTask,
AdminTaskDetail,
AdminTeam,
AdminTeamDetail,
AdminUser,
@@ -599,5 +601,22 @@ export const adminApi = {
"/api/admin/asset-reviews/poll/",
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
);
},
tasks(params?: { status?: string; task_type?: string; team?: string; anomaly?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.task_type) qs.set("task_type", params.task_type);
if (params?.team) qs.set("team", params.team);
if (params?.anomaly) qs.set("anomaly", params.anomaly);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminTask>>(`/api/admin/tasks/${q ? `?${q}` : ""}`);
},
taskDetail(id: string) {
return request<AdminTaskDetail>(`/api/admin/tasks/${id}/`);
},
retryTask(id: string) {
return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" });
}
};
@@ -6,6 +6,7 @@ import type { NavigateFn } from "../route-config";
import { AdminInvitesPage } from "./admin-invites";
import { AdminQualityPage } from "./admin-quality";
import { AdminReviewsPage } from "./admin-reviews";
import { AdminTasksPage } from "./admin-tasks";
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
@@ -182,6 +183,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "reviews") {
return <AdminReviewsPage notify={notify} />;
}
if (section.slug === "tasks") {
return <AdminTasksPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />;
}
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from "react";
import { Activity, X } from "lucide-react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminTask, AdminTaskDetail } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
const TABS = [
{ key: "", label: "全部" },
{ key: "failed", label: "失败" },
{ key: "succeeded", label: "成功" }
];
function fmtDate(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function statusPill(status: string) {
if (status === "succeeded") return <span className="pill ok"><span className="dot" /></span>;
if (status === "failed") return <span className="pill err"><span className="dot" /></span>;
if (["cancelled", "compensating"].includes(status)) return <span className="pill neutral"><span className="dot" />{status}</span>;
return <span className="pill info"><span className="dot" /></span>;
}
export function AdminTasksPage({ notify }: { notify: Notify }) {
const [tasks, setTasks] = useState<AdminTask[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState("");
const [anomalyOnly, setAnomalyOnly] = useState(false);
const [detail, setDetail] = useState<AdminTaskDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.tasks({ status: tab || undefined, anomaly: anomalyOnly ? "1" : undefined, page_size: 60 });
setTasks(res.results);
setCount(res.count);
} catch {
notify("error", "加载任务失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, anomalyOnly]);
useEffect(() => { void load(); }, [load]);
async function openDetail(id: string) {
setDetailLoading(true);
setDetail(null);
try {
setDetail(await adminApi.taskDetail(id));
} catch {
notify("error", "加载详情失败");
} finally {
setDetailLoading(false);
}
}
async function retry(t: AdminTask) {
if (busy) return;
setBusy(true);
try {
await adminApi.retryTask(t.id);
notify("success", "已重投,稍后刷新查看");
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "重投失败");
} finally {
setBusy(false);
}
}
return (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 个任务</span> · 全局 AI 任务 · 成本异常 · 失败重投</div>
</div>
</div>
<div className="admin-toolbar">
<div className="tabs-sub">
{TABS.map((t) => (
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => setTab(t.key)}>{t.label}</button>
))}
</div>
<button type="button" className={`chip admin-anomaly-chip${anomalyOnly ? " active" : ""}`} onClick={() => setAnomalyOnly((v) => !v)}>
</button>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3></h3><p>// fetching tasks</p></div>
) : tasks.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3></h3><p>// no tasks</p></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr><th></th><th></th><th></th><th></th><th>()</th><th></th><th className="col-actions"></th></tr>
</thead>
<tbody>
{tasks.map((t) => (
<tr key={t.id}>
<td className="mono admin-code">{t.task_type}</td>
<td>{t.team_name || <span className="muted"></span>}</td>
<td>{t.model_name || <span className="muted"></span>}</td>
<td>{statusPill(t.status)}</td>
<td className="mono">
¥{t.estimated_cost} ¥{t.actual_cost}
{t.cost_anomaly && <span className="pill err admin-inline-pill"></span>}
</td>
<td className="mono col-time">{fmtDate(t.created_at)}</td>
<td className="col-actions">
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}></button>
{t.status === "failed" && (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => retry(t)}></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(detail || detailLoading) && (
<div className="drawer-bg show" onClick={(e) => { if (e.target === e.currentTarget) setDetail(null); }}>
<div className="drawer admin-drawer show" role="dialog" aria-modal="true" aria-label="任务详情">
<div className="drawer-h">
<h3><Activity size={15} style={{ verticalAlign: "-2px", marginRight: 6 }} /></h3>
<button className="x" type="button" aria-label="关闭" onClick={() => setDetail(null)}><X size={14} /></button>
</div>
<div className="drawer-b">
{detailLoading || !detail ? (
<p className="admin-modal-desc"></p>
) : (
<>
<div className="admin-detail-meta">
<div><span className="k"></span><span className="v">{statusPill(detail.status)}</span></div>
<div><span className="k"></span><span className="v">{detail.team_name || "—"}</span></div>
<div><span className="k"></span><span className="v">{detail.model_name || "—"}</span></div>
<div><span className="k"></span><span className="v mono">¥{detail.estimated_cost} ¥{detail.actual_cost}{detail.cost_anomaly ? " ⚠" : ""}</span></div>
</div>
{detail.error_message && (
<>
<div className="admin-detail-subhead"></div>
<pre className="admin-json admin-json-err">{detail.error_message}</pre>
</>
)}
<div className="admin-detail-subhead"> payload</div>
<pre className="admin-json">{JSON.stringify(detail.request_payload, null, 2)}</pre>
<div className="admin-detail-subhead"> payload</div>
<pre className="admin-json">{JSON.stringify(detail.response_payload, null, 2)}</pre>
</>
)}
</div>
</div>
</div>
)}
</>
);
}
+22
View File
@@ -93,6 +93,28 @@ export type AdminReviewAsset = {
preview_url: string;
created_at: string;
};
export type AdminTask = {
id: string;
task_type: string;
status: string;
team: string;
team_name: string | null;
model_name: string | null;
estimated_cost: string;
actual_cost: string;
cost_anomaly: boolean;
error_code: string;
created_at: string;
};
export type AdminTaskDetail = AdminTask & {
project: string | null;
idempotency_key: string;
request_payload: Record<string, unknown>;
response_payload: Record<string, unknown>;
error_message: string;
submitted_at: string | null;
completed_at: string | null;
};
export type Paginated<T> = {
count: number;