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
+38 -1
View File
@@ -1,9 +1,20 @@
from decimal import Decimal
from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import QualityWord
from apps.ai.models import AITask, QualityWord
from apps.assets.models import Asset
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
COST_ANOMALY_RATIO = Decimal("1.5")
def is_cost_anomaly(estimated, actual) -> bool:
est = estimated or Decimal("0")
act = actual or Decimal("0")
return bool(est > 0 and act > est * COST_ANOMALY_RATIO)
class QualityWordSerializer(serializers.ModelSerializer):
class Meta:
@@ -73,3 +84,29 @@ class AdminUserSerializer(serializers.ModelSerializer):
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
for m in obj.team_memberships.select_related("team").all()
]
class AdminTaskSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
model_name = serializers.CharField(source="model_config.name", read_only=True, default=None)
cost_anomaly = serializers.SerializerMethodField()
class Meta:
model = AITask
fields = [
"id", "task_type", "status", "team", "team_name", "model_name",
"estimated_cost", "actual_cost", "cost_anomaly", "error_code", "created_at",
]
read_only_fields = fields
def get_cost_anomaly(self, obj) -> bool:
return is_cost_anomaly(obj.estimated_cost, obj.actual_cost)
class AdminTaskDetailSerializer(AdminTaskSerializer):
class Meta(AdminTaskSerializer.Meta):
fields = AdminTaskSerializer.Meta.fields + [
"project", "idempotency_key", "request_payload", "response_payload",
"error_message", "submitted_at", "completed_at",
]
read_only_fields = fields
+69
View File
@@ -298,3 +298,72 @@ class AdminAssetReviewTests(TestCase):
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing
mock_poll.assert_called_once()
class AdminTaskMonitorTests(TestCase):
"""Phase 6:AI 任务监控(列/筛/详情/成本异常)+ 失败重投(celery 全 mock)+ 权限。"""
def setUp(self):
from apps.ai.models import AITask, ModelConfig, ModelProvider
self.AITask = AITask
self.admin = User.objects.create_user(username="padmin6", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal6", password="x")
self.team = Team.objects.create(name="T6", owner=self.normal)
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
prov = ModelProvider.objects.create(name="prov6", display_name="P6")
self.mc = ModelConfig.objects.create(provider=prov, name="m6", display_name="M6", capability=ModelConfig.Capability.IMAGE)
def mk(tt, st, est, act, key):
return AITask.objects.create(
team=self.team, model_config=self.mc, task_type=tt, status=st,
estimated_cost=est, actual_cost=act, idempotency_key=key,
)
self.t_ok = mk(AITask.Type.PRODUCT_IMAGE, AITask.Status.SUCCEEDED, "1.0", "1.0", "k-ok")
self.t_failed = mk(AITask.Type.PRODUCT_IMAGE, AITask.Status.FAILED, "1.0", "1.0", "k-failed")
self.t_anom = mk(AITask.Type.PERSON_IMAGE, AITask.Status.SUCCEEDED, "1.0", "5.0", "k-anom")
self.t_script = mk(AITask.Type.SCRIPT_GENERATION, AITask.Status.FAILED, "1.0", "1.0", "k-script")
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_list_permission_and_count(self):
self.assertEqual(self.nc.get("/api/admin/tasks/").status_code, 403)
r = self.ac.get("/api/admin/tasks/")
self.assertEqual(r.status_code, 200)
self.assertGreaterEqual(r.data["count"], 4)
def test_filter_status_type_anomaly(self):
self.assertTrue(all(t["status"] == "failed" for t in self.ac.get("/api/admin/tasks/?status=failed").data["results"]))
self.assertTrue(all(t["task_type"] == "person_image" for t in self.ac.get("/api/admin/tasks/?task_type=person_image").data["results"]))
ids = {t["id"] for t in self.ac.get("/api/admin/tasks/?anomaly=1").data["results"]}
self.assertIn(str(self.t_anom.id), ids)
self.assertNotIn(str(self.t_ok.id), ids)
def test_cost_anomaly_flag(self):
self.assertTrue(self.ac.get(f"/api/admin/tasks/{self.t_anom.id}/").data["cost_anomaly"])
self.assertFalse(self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/").data["cost_anomaly"])
def test_detail_has_payloads(self):
d = self.ac.get(f"/api/admin/tasks/{self.t_failed.id}/")
self.assertEqual(d.status_code, 200)
self.assertIn("request_payload", d.data)
self.assertIn("response_payload", d.data)
def test_retry_dispatch_and_audit(self):
with patch("apps.ai.tasks.generate_standalone_image_task.delay") as mock_delay:
r = self.ac.post(f"/api/admin/tasks/{self.t_failed.id}/retry/")
self.assertEqual(r.status_code, 200)
mock_delay.assert_called_once()
self.assertTrue(AdminAuditLog.objects.filter(action="task.retry").exists())
def test_retry_non_failed_rejected(self):
self.assertEqual(self.ac.post(f"/api/admin/tasks/{self.t_ok.id}/retry/").status_code, 400)
def test_retry_unsupported_type_rejected(self):
self.assertEqual(self.ac.post(f"/api/admin/tasks/{self.t_script.id}/retry/").status_code, 400)
def test_retry_requires_admin(self):
self.assertEqual(self.nc.post(f"/api/admin/tasks/{self.t_failed.id}/retry/").status_code, 403)
+6
View File
@@ -5,6 +5,9 @@ from .views import (
admin_asset_reviews_poll,
admin_asset_reviews_submit,
admin_invitations,
admin_task_detail,
admin_task_retry,
admin_tasks,
admin_quality_word_detail,
admin_quality_words,
admin_revoke_invitation,
@@ -31,4 +34,7 @@ urlpatterns = [
path("asset-reviews/", admin_asset_reviews, name="admin-asset-reviews"),
path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"),
path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"),
path("tasks/", admin_tasks, name="admin-tasks"),
path("tasks/<uuid:task_id>/", admin_task_detail, name="admin-task-detail"),
path("tasks/<uuid:task_id>/retry/", admin_task_retry, name="admin-task-retry"),
]
+75 -2
View File
@@ -1,6 +1,8 @@
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
from django.db.models import Count, Q
from decimal import Decimal
from django.db.models import Count, F, Q
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
@@ -10,13 +12,16 @@ from apps.accounts.audit import log_admin_action
from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import QualityWord
from apps.ai.models import AITask, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.common.pagination import DefaultPagination
from .serializers import (
COST_ANOMALY_RATIO,
AdminReviewAssetSerializer,
AdminTaskDetailSerializer,
AdminTaskSerializer,
AdminTeamMemberSerializer,
AdminTeamSerializer,
AdminUserSerializer,
@@ -326,3 +331,71 @@ def admin_asset_reviews_poll(request):
for asset in qs:
statuses[str(asset.id)] = poll_asset_review(asset)
return Response({"polled": len(statuses), "statuses": statuses})
# ─────────────────────────── AI 任务监控 + 成本异常 ───────────────────────────
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_tasks(request):
"""全局 AITask 列表(?status= / ?task_type= / ?team= / ?anomaly=1 成本异常 筛 + 分页)。"""
qs = AITask.objects.select_related("team", "model_config").order_by("-created_at")
st = request.query_params.get("status")
if st in dict(AITask.Status.choices):
qs = qs.filter(status=st)
tt = request.query_params.get("task_type")
if tt in dict(AITask.Type.choices):
qs = qs.filter(task_type=tt)
team_id = request.query_params.get("team")
if team_id:
qs = qs.filter(team_id=team_id)
if request.query_params.get("anomaly") in {"1", "true"}:
qs = qs.filter(estimated_cost__gt=0, actual_cost__gt=F("estimated_cost") * COST_ANOMALY_RATIO)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminTaskSerializer(page, many=True).data)
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_task_detail(request, task_id):
task = AITask.objects.select_related("team", "model_config").filter(id=task_id).first()
if task is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(AdminTaskDetailSerializer(task).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_task_retry(request, task_id):
"""失败任务重投(best-effort):仅 FAILED + 可重投的图像类(基础资产 / 独立生图)。
其余类型(脚本 / 故事板 / 视频 / 配音 / 导出)请到对应页面重跑,这里 400 不冒险误投。"""
task = AITask.objects.filter(id=task_id).first()
if task is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if task.status != AITask.Status.FAILED:
return Response({"detail": "仅失败任务可重投"}, status=status.HTTP_400_BAD_REQUEST)
image_types = {AITask.Type.PRODUCT_IMAGE, AITask.Type.PERSON_IMAGE, AITask.Type.SCENE_IMAGE}
if task.task_type not in image_types:
return Response({"detail": "该任务类型暂不支持后台重投,请到对应页面重新生成"}, status=status.HTTP_400_BAD_REQUEST)
from apps.ai.tasks import generate_base_asset_task, generate_standalone_image_task
try:
if task.project_id:
generate_base_asset_task.delay(str(task.id))
else:
generate_standalone_image_task.delay(str(task.id))
except Exception: # noqa: BLE001 — 投递失败如实返回,不静默
return Response({"detail": "重投调度失败,请确认 worker 在线"}, status=status.HTTP_502_BAD_GATEWAY)
log_admin_action(
request,
"task.retry",
target_type="ai_task",
target_id=task.id,
target_name=task.task_type,
)
return Response({"retried": True, "task_id": str(task.id)})
+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;
+83
View File
@@ -0,0 +1,83 @@
// Phase 6 e2e:AI 任务监控 —— 列表、失败/成本异常筛选、详情抽屉(payload)、重投按钮在场。
// 不点真重投(避免触发真实生成);重投逻辑由后端 mock 单测覆盖。wrapper 预建一条 failed+异常 任务,跑完删。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://127.0.0.1:5188";
const API = process.env.API || "http://127.0.0.1:8010";
const OUT = path.resolve("output/admin");
fs.mkdirSync(OUT, { recursive: true });
async function apiLogin(u, p) {
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
return res.json();
}
const admin = await apiLogin("admin", "admin123");
const r = { page: {}, detail: {}, failedTab: {}, anomaly: {}, consoleErrors: [], pass: false };
const browser = await chromium.launch({ headless: true });
const hook = (p, tag) => {
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
};
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
const p = await ctx.newPage(); hook(p, "tasks");
await p.goto(BASE + "/admin/tasks", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForFunction(() => {
const t = document.querySelector(".admin-table");
const h3 = document.querySelector(".empty-state.show h3");
return Boolean(t) || (h3 && h3.textContent !== "加载中…");
}, { timeout: 20000 }).catch(() => {});
r.page.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
r.page.tabs = await p.locator(".tabs-sub .tab-sub").count();
r.page.anomalyChip = (await p.locator(".admin-anomaly-chip").count()) >= 1;
r.page.rows = await p.locator(".admin-table tbody tr").count();
await p.screenshot({ path: path.join(OUT, "p6-tasks-list.png"), fullPage: true });
// 详情抽屉(第一行)
if (r.page.rows >= 1) {
await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().click();
await p.waitForSelector(".drawer", { timeout: 6000 });
await p.waitForTimeout(500);
r.detail.drawerShown = (await p.locator(".drawer").count()) >= 1;
r.detail.hasJson = (await p.locator(".admin-json").count()) >= 1;
await p.screenshot({ path: path.join(OUT, "p6-task-detail.png") });
await p.locator(".drawer-bg").click({ position: { x: 12, y: 12 } }); // 点抽屉外遮罩关闭
await p.waitForTimeout(400);
}
// 失败筛选 → 该 tab 行(若有)首行带「重投」按钮
await p.locator('.tabs-sub .tab-sub:has-text("失败")').click();
await p.waitForTimeout(800);
r.failedTab.rows = await p.locator(".admin-table tbody tr").count();
if (r.failedTab.rows >= 1) {
const btns = await p.locator(".admin-table tbody tr:first-child .col-actions .btn").allInnerTexts();
r.failedTab.hasRetry = btns.some((t) => t.includes("重投"));
}
// 成本异常筛选
await p.locator(".admin-anomaly-chip").click();
await p.waitForTimeout(800);
r.anomaly.rows = await p.locator(".admin-table tbody tr").count();
await p.screenshot({ path: path.join(OUT, "p6-anomaly.png"), fullPage: true });
await ctx.close();
await browser.close();
const checks = {
pageLoads: r.page.title === "任务监控" && r.page.tabs === 3 && r.page.anomalyChip === true,
hasRows: r.page.rows >= 1,
detailDrawer: r.detail.drawerShown === true && r.detail.hasJson === true,
failedRetryBtn: r.failedTab.rows >= 1 && r.failedTab.hasRetry === true,
anomalyFilter: r.anomaly.rows >= 1,
zeroConsoleErrors: r.consoleErrors.length === 0,
};
r.checks = checks;
r.pass = Object.values(checks).every(Boolean);
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "p6-summary.json"), JSON.stringify(r, null, 2));
process.exit(r.pass ? 0 : 1);
+20
View File
@@ -144,3 +144,23 @@
- 无头 e2e:`_admin-p5.mjs`(5188;wrapper 预建 1 条 person 资产保证有行,跑完删除)—— **5 断言全过 + 0 console error**:页面+5筛选 tab+刷新按钮、60 行、多选出 bulk-bar+批量送审可点(**不点真送审**,避免 Volcano 真消耗)、刷新状态(只读 poll)。截图 `output/admin/p5-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
- 视觉:队列表 + 绿/红/灰状态盾,符合 restraint。
---
## ✅ Phase 6 · AI 任务监控 + 成本异常(Admin)— 完成 2026-06-19
**后端**
- `GET tasks/`(全局 AITask,?status/task_type/team/anomaly 筛 + 分页)+ `GET tasks/<id>/`(详情含 request/response payload + error)+ `POST tasks/<id>/retry/`(失败重投)。
- 成本异常:`is_cost_anomaly`(实际 > 预估 × 1.5 且预估>0)+ `?anomaly=1` F() 表达式筛;AdminTaskSerializer 暴露 `cost_anomaly`
- 重投 best-effort:仅 FAILED + 图像类(基础资产→generate_base_asset_task / 独立生图→generate_standalone_image_task),其余类型 400(不冒险误投);IsPlatformAdmin + 审计。
**前端**
- `adminApi` tasks/taskDetail/retryTask;`AdminTask`/`AdminTaskDetail` 类型。
- Admin「任务监控」页:状态筛 tab(全部/失败/成功)+「仅成本异常」chip + 表格(类型/团队/模型/状态/成本 预估→实际+异常标/时间)+ **详情抽屉**(payload JSON)+ 失败行「重投」。
- admin-page.css 补 JSON 块 / 抽屉宽度 / 异常 chip(仅 token)。**修真 bug**:抽屉缺 `.show` 类导致面板停在屏外,补上后正常滑入。
**测试底座(全过)**
- 后端单测:`apps.adminpanel` **36 项 OK**(列权限/状态·类型·异常筛/成本异常标志/详情 payload/重投 dispatch(mock celery)+ 非失败拒 + 不支持类型拒 + 权限 403)。
- 无头 e2e:`_admin-p6.mjs`(5188;wrapper 建 1 条 failed+异常任务,跑完删)—— **6 断言全过 + 0 console error**:页面+3tab+异常chip、60 行、详情抽屉含 JSON、失败 tab(43 条真实失败)首行有重投、异常筛精确 1 条;**不点真重投**。截图 `output/admin/p6-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
- 视觉:任务表 + 详情抽屉(成本⚠ / 错误红框 / payload),符合 restraint。