From ca1e50d32ce29e8e58a67635645e2dad5499679f Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 22:19:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=206=20AI=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7+=E6=88=90=E6=9C=AC=E5=BC=82=E5=B8=B8=20?= =?UTF-8?q?=E2=80=94=20=E5=85=A8=E5=B1=80=E4=BB=BB=E5=8A=A1=E5=88=97/?= =?UTF-8?q?=E7=AD=9B/=E8=AF=A6=E6=83=85=E6=8A=BD=E5=B1=89/=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=87=8D=E6=8A=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: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 --- core/backend/apps/adminpanel/serializers.py | 39 +++- core/backend/apps/adminpanel/tests.py | 69 +++++++ core/backend/apps/adminpanel/urls.py | 6 + core/backend/apps/adminpanel/views.py | 77 +++++++- core/frontend/src/admin-page.css | 20 ++ core/frontend/src/api.ts | 19 ++ core/frontend/src/routes/admin/admin-app.tsx | 4 + .../frontend/src/routes/admin/admin-tasks.tsx | 172 ++++++++++++++++++ core/frontend/src/types.ts | 22 +++ core/qa/visual-parity/_admin-p6.mjs | 83 +++++++++ docs/todo/admin-后台-进度.md | 20 ++ 11 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 core/frontend/src/routes/admin/admin-tasks.tsx create mode 100644 core/qa/visual-parity/_admin-p6.mjs diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index ccece6d..073866b 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -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 diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 440d94c..366d6c6 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index 5e26259..173c2c7 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -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//", admin_task_detail, name="admin-task-detail"), + path("tasks//retry/", admin_task_retry, name="admin-task-retry"), ] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 5d87275..1b9659a 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -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)}) diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 077444f..989940d 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -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); } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index f0e81d1..e88ac74 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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>(`/api/admin/tasks/${q ? `?${q}` : ""}`); + }, + taskDetail(id: string) { + return request(`/api/admin/tasks/${id}/`); + }, + retryTask(id: string) { + return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" }); } }; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index 16aa1dc..b8ba03c 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -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 ; } + if (section.slug === "tasks") { + return ; + } // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-tasks.tsx b/core/frontend/src/routes/admin/admin-tasks.tsx new file mode 100644 index 0000000..8d76a33 --- /dev/null +++ b/core/frontend/src/routes/admin/admin-tasks.tsx @@ -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 成功; + if (status === "failed") return 失败; + if (["cancelled", "compensating"].includes(status)) return {status}; + return 进行中; +} + +export function AdminTasksPage({ notify }: { notify: Notify }) { + const [tasks, setTasks] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState(""); + const [anomalyOnly, setAnomalyOnly] = useState(false); + const [detail, setDetail] = useState(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 ( + <> +
+
+

任务监控

+
// {count} 个任务 · 全局 AI 任务 · 成本异常 · 失败重投
+
+
+ +
+
+ {TABS.map((t) => ( + + ))} +
+ +
+ + {loading ? ( +

加载中…

// fetching tasks

+ ) : tasks.length === 0 ? ( +

暂无任务

// no tasks

+ ) : ( +
+ + + + + + {tasks.map((t) => ( + + + + + + + + + + ))} + +
类型团队模型状态成本(预估→实际)时间操作
{t.task_type}{t.team_name || }{t.model_name || }{statusPill(t.status)} + ¥{t.estimated_cost} → ¥{t.actual_cost} + {t.cost_anomaly && 异常} + {fmtDate(t.created_at)} + + {t.status === "failed" && ( + + )} +
+
+ )} + + {(detail || detailLoading) && ( +
{ if (e.target === e.currentTarget) setDetail(null); }}> +
+
+

任务详情

+ +
+
+ {detailLoading || !detail ? ( +

加载中…

+ ) : ( + <> +
+
状态{statusPill(detail.status)}
+
团队{detail.team_name || "—"}
+
模型{detail.model_name || "—"}
+
成本¥{detail.estimated_cost} → ¥{detail.actual_cost}{detail.cost_anomaly ? " ⚠" : ""}
+
+ {detail.error_message && ( + <> +
错误
+
{detail.error_message}
+ + )} +
请求 payload
+
{JSON.stringify(detail.request_payload, null, 2)}
+
响应 payload
+
{JSON.stringify(detail.response_payload, null, 2)}
+ + )} +
+
+
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 616d99f..9bb08e7 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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; + response_payload: Record; + error_message: string; + submitted_at: string | null; + completed_at: string | null; +}; export type Paginated = { count: number; diff --git a/core/qa/visual-parity/_admin-p6.mjs b/core/qa/visual-parity/_admin-p6.mjs new file mode 100644 index 0000000..b46780a --- /dev/null +++ b/core/qa/visual-parity/_admin-p6.mjs @@ -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); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index eb56714..bc79666 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -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//`(详情含 request/response payload + error)+ `POST tasks//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。