From 5aab8a568fc117da874c4d6613e566e8832de56f Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 22:07:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=205=20=E7=81=AB=E5=B1=B1?= =?UTF-8?q?=E4=BA=BA=E5=83=8F=E5=AE=A1=E6=A0=B8=E9=98=9F=E5=88=97=20?= =?UTF-8?q?=E2=80=94=20=E8=B7=A8=E5=9B=A2=E9=98=9F=20person=20=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=AE=A1=E6=A0=B8(=E5=88=97/=E7=AD=9B/=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E9=80=81=E5=AE=A1/=E8=BD=AE=E8=AF=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端:adminpanel asset-reviews(列跨团队 person + 状态筛分页)+ submit(批量送审/重试)+ poll(轮询 processing), 复用 assets/review.py,IsPlatformAdmin + 审计;AdminReviewAssetSerializer;修 N+1(prefetch 缓存取首图,列表 5s→1s)。 前端:adminApi 审核系列;Admin 资产审核页(状态筛 + 多选表格 + 绿/红/灰状态盾 + 重试 + 吸底 bulk-bar 批量送审 + 刷新状态)。 测试:adminpanel 28 单测过(mock 火山:队列只列 person/状态筛/批量送审计数/poll 只轮 processing); 无头 e2e _admin-p5.mjs 5 断言过 + 0 console error(只读 poll,不点真送审);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/adminpanel/serializers.py | 20 +++ core/backend/apps/adminpanel/tests.py | 66 +++++++ core/backend/apps/adminpanel/urls.py | 6 + core/backend/apps/adminpanel/views.py | 64 +++++++ core/frontend/src/admin-page.css | 41 +++++ core/frontend/src/api.ts | 22 +++ core/frontend/src/routes/admin/admin-app.tsx | 4 + .../src/routes/admin/admin-reviews.tsx | 165 ++++++++++++++++++ core/frontend/src/types.ts | 11 ++ core/qa/visual-parity/_admin-p5.mjs | 75 ++++++++ docs/todo/admin-后台-进度.md | 20 +++ 11 files changed, 494 insertions(+) create mode 100644 core/frontend/src/routes/admin/admin-reviews.tsx create mode 100644 core/qa/visual-parity/_admin-p5.mjs diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index ea099ab..ccece6d 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from apps.accounts.models import Team, TeamMember, User from apps.ai.models import QualityWord +from apps.assets.models import Asset class QualityWordSerializer(serializers.ModelSerializer): @@ -11,6 +12,25 @@ class QualityWordSerializer(serializers.ModelSerializer): read_only_fields = ["id", "created_at"] +class AdminReviewAssetSerializer(serializers.ModelSerializer): + """跨团队人像审核队列行:复用 AssetFileSerializer 的首图 preview_url。""" + + team_name = serializers.CharField(source="team.name", read_only=True, default=None) + preview_url = serializers.SerializerMethodField() + + class Meta: + model = Asset + fields = ["id", "name", "category", "review_status", "review_error", "team", "team_name", "preview_url", "created_at"] + read_only_fields = fields + + def get_preview_url(self, obj): + from apps.assets.serializers import AssetFileSerializer + + # 用 prefetch 缓存(list(...))而非 .first():后者会另发查询,列表页 N+1 拖慢到数秒 + files = list(obj.files.all()) + return AssetFileSerializer(files[0]).data.get("preview_url", "") if files else "" + + class AdminTeamSerializer(serializers.ModelSerializer): owner_username = serializers.CharField(source="owner.username", read_only=True, default=None) member_count = serializers.SerializerMethodField() diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index fc2db71..440d94c 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from rest_framework.test import APIClient from django.test import TestCase @@ -232,3 +234,67 @@ class AdminQualityWordTests(TestCase): words = quality_words("video") self.assertIn("启用词", words) self.assertNotIn("停用词", words) + + +class AdminAssetReviewTests(TestCase): + """Phase 5:跨团队人像审核队列(列/筛/批量送审/轮询)+ 权限。火山调用全 mock。""" + + def setUp(self): + from apps.assets.models import Asset + + self.admin = User.objects.create_user(username="padmin5", password="x", is_platform_admin=True) + self.normal = User.objects.create_user(username="normal5", password="x") + self.teamA = Team.objects.create(name="TeamA", owner=self.normal) + TeamMember.objects.create(team=self.teamA, user=self.normal, role=TeamMember.Role.OWNER) + self.user2 = User.objects.create_user(username="u2-5", password="x") + self.teamB = Team.objects.create(name="TeamB", owner=self.user2) + + self.a_proc = Asset.objects.create(team=self.teamA, name="proc", asset_type="image", category=Asset.Category.PERSON, review_status="processing") + self.a_active = Asset.objects.create(team=self.teamA, name="active", asset_type="image", category=Asset.Category.PERSON, review_status="active") + self.a_failed = Asset.objects.create(team=self.teamB, name="failed", asset_type="image", category=Asset.Category.PERSON, review_status="failed") + self.a_none = Asset.objects.create(team=self.teamA, name="none", asset_type="image", category=Asset.Category.PERSON, review_status="") + # 非 person 资产不该进队列 + Asset.objects.create(team=self.teamA, name="scene", asset_type="image", category=Asset.Category.SCENE, review_status="") + + self.ac = APIClient() + self.ac.force_authenticate(self.admin) + self.nc = APIClient() + self.nc.force_authenticate(self.normal) + + def test_list_permission_and_only_person_cross_team(self): + self.assertEqual(self.nc.get("/api/admin/asset-reviews/").status_code, 403) + r = self.ac.get("/api/admin/asset-reviews/") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["count"], 4) # 4 person 资产(跨 2 团队),scene 排除 + self.assertEqual({row["team_name"] for row in r.data["results"]}, {"TeamA", "TeamB"}) + + def test_filter_by_status(self): + self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=processing").data["count"], 1) + self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=active").data["count"], 1) + self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=failed").data["count"], 1) + self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=none").data["count"], 1) + + def test_submit_calls_review_and_audit(self): + with patch("apps.adminpanel.views.submit_asset_for_review") as mock_submit: + r = self.ac.post( + "/api/admin/asset-reviews/submit/", + {"asset_ids": [str(self.a_none.id), str(self.a_failed.id)]}, + format="json", + ) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["submitted"], 2) + self.assertEqual(mock_submit.call_count, 2) + self.assertTrue(AdminAuditLog.objects.filter(action="asset_review.submit").exists()) + + def test_submit_requires_admin(self): + with patch("apps.adminpanel.views.submit_asset_for_review") as mock_submit: + r = self.nc.post("/api/admin/asset-reviews/submit/", {"asset_ids": [str(self.a_none.id)]}, format="json") + self.assertEqual(r.status_code, 403) + mock_submit.assert_not_called() + + def test_poll_only_processing(self): + with patch("apps.adminpanel.views.poll_asset_review", return_value="active") as mock_poll: + r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing + mock_poll.assert_called_once() diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index e161e61..5e26259 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -1,6 +1,9 @@ from django.urls import path from .views import ( + admin_asset_reviews, + admin_asset_reviews_poll, + admin_asset_reviews_submit, admin_invitations, admin_quality_word_detail, admin_quality_words, @@ -25,4 +28,7 @@ urlpatterns = [ path("users//reset-password/", admin_user_reset_password, name="admin-user-reset-password"), path("quality-words/", admin_quality_words, name="admin-quality-words"), path("quality-words//", admin_quality_word_detail, name="admin-quality-word-detail"), + 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"), ] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index a6c00f9..5d87275 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -11,9 +11,12 @@ 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.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 ( + AdminReviewAssetSerializer, AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer, @@ -262,3 +265,64 @@ def admin_quality_word_detail(request, word_id): after={"text": obj.text}, ) return Response(QualityWordSerializer(obj).data) + + +# ─────────────────────────── 火山人像审核队列 ─────────────────────────── + +_REVIEW_STATUSES = {"", "processing", "active", "failed"} + + +@api_view(["GET"]) +@permission_classes([IsPlatformAdmin]) +def admin_asset_reviews(request): + """跨团队真人(person)资产审核队列。?review_status=none|processing|active|failed 过滤(none=未送审)。""" + qs = ( + Asset.objects.filter(category=Asset.Category.PERSON, is_deleted=False) + .select_related("team") + .prefetch_related("files") + .order_by("-created_at") + ) + rs = request.query_params.get("review_status") + if rs == "none": + qs = qs.filter(review_status="") + elif rs in {"processing", "active", "failed"}: + qs = qs.filter(review_status=rs) + search = (request.query_params.get("search") or "").strip() + if search: + qs = qs.filter(team__name__icontains=search) + paginator = DefaultPagination() + page = paginator.paginate_queryset(qs, request) + return paginator.get_paginated_response(AdminReviewAssetSerializer(page, many=True).data) + + +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_asset_reviews_submit(request): + """批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。""" + ids = request.data.get("asset_ids") or [] + assets = list(Asset.objects.filter(id__in=ids, category=Asset.Category.PERSON, is_deleted=False)) + for asset in assets: + submit_asset_for_review(asset) + statuses = {str(a.id): a.review_status for a in Asset.objects.filter(id__in=ids)} + log_admin_action( + request, + "asset_review.submit", + target_type="asset", + target_name=f"{len(assets)} assets", + after={"count": len(assets)}, + ) + return Response({"submitted": len(assets), "statuses": statuses}) + + +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_asset_reviews_poll(request): + """轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。""" + ids = request.data.get("asset_ids") + qs = Asset.objects.filter(category=Asset.Category.PERSON, review_status="processing", is_deleted=False) + if ids: + qs = qs.filter(id__in=ids) + statuses = {} + for asset in qs: + statuses[str(asset.id)] = poll_asset_review(asset) + return Response({"polled": len(statuses), "statuses": statuses}) diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index cd2d6b0..077444f 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -195,3 +195,44 @@ border-radius: 50%; } .qw-del:hover { color: var(--accent-crimson); background: var(--black-alpha-4); } + +/* ── 资产审核队列 ── */ +.admin-table .col-check { width: 36px; text-align: center; } +.admin-thumb { + width: 40px; + height: 40px; + border-radius: var(--r-md); + object-fit: cover; + background: var(--background-lighter); + border: 1px solid var(--border-faint); + display: block; +} +.admin-thumb-empty { display: inline-block; } +.admin-review-err { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + margin-left: 6px; + border-radius: 50%; + background: var(--crimson-bg); + color: var(--accent-crimson); + font-size: 11px; + cursor: help; +} +.bulk-bar { + position: fixed; + left: 50%; + bottom: 28px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--surface-raised); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + box-shadow: var(--shadow-floating); + z-index: 50; +} +.bulk-count { font-size: 13px; color: var(--accent-black); font-weight: 500; } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 9c7227e..f0e81d1 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1,5 +1,6 @@ import type { AdminQualityWord, + AdminReviewAsset, AdminTeam, AdminTeamDetail, AdminUser, @@ -577,5 +578,26 @@ export const adminApi = { }, deleteQualityWord(id: string) { return request(`/api/admin/quality-words/${id}/`, { method: "DELETE" }); + }, + assetReviews(params?: { review_status?: string; search?: string; page?: number; page_size?: number }) { + const qs = new URLSearchParams(); + if (params?.review_status) qs.set("review_status", params.review_status); + if (params?.search) qs.set("search", params.search); + 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/asset-reviews/${q ? `?${q}` : ""}`); + }, + submitReviews(assetIds: string[]) { + return request<{ submitted: number; statuses: Record }>( + "/api/admin/asset-reviews/submit/", + { method: "POST", body: JSON.stringify({ asset_ids: assetIds }) } + ); + }, + pollReviews(assetIds?: string[]) { + return request<{ polled: number; statuses: Record }>( + "/api/admin/asset-reviews/poll/", + { method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) } + ); } }; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index 78bdc23..16aa1dc 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -5,6 +5,7 @@ import type { Team, User } from "../../types"; import type { NavigateFn } from "../route-config"; import { AdminInvitesPage } from "./admin-invites"; import { AdminQualityPage } from "./admin-quality"; +import { AdminReviewsPage } from "./admin-reviews"; import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users"; export type AdminNotify = (type: "success" | "error" | "info", text: string) => void; @@ -178,6 +179,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe if (section.slug === "quality") { return ; } + if (section.slug === "reviews") { + return ; + } // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-reviews.tsx b/core/frontend/src/routes/admin/admin-reviews.tsx new file mode 100644 index 0000000..835eef0 --- /dev/null +++ b/core/frontend/src/routes/admin/admin-reviews.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { adminApi } from "../../api"; +import { IconKitSvg } from "../../components/IconKitSvg"; +import type { AdminReviewAsset } from "../../types"; + +type Notify = (type: "success" | "error" | "info", text: string) => void; + +const TABS = [ + { key: "", label: "全部" }, + { key: "none", label: "未送审" }, + { key: "processing", label: "审核中" }, + { key: "active", label: "通过" }, + { key: "failed", label: "失败" } +]; + +function reviewPill(status: string) { + if (status === "active") return 通过; + if (status === "failed") return 未通过; + if (status === "processing") return 审核中; + return 未送审; +} + +export function AdminReviewsPage({ notify }: { notify: Notify }) { + const [assets, setAssets] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setSelected(new Set()); + try { + const res = await adminApi.assetReviews({ review_status: tab || undefined, page_size: 60 }); + setAssets(res.results); + setCount(res.count); + } catch { + notify("error", "加载审核队列失败"); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab]); + + useEffect(() => { void load(); }, [load]); + + function toggleOne(id: string) { + setSelected((s) => { + const next = new Set(s); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + function toggleAll() { + setSelected((s) => (s.size === assets.length ? new Set() : new Set(assets.map((a) => a.id)))); + } + + async function submit(ids: string[]) { + if (busy || ids.length === 0) return; + setBusy(true); + try { + const res = await adminApi.submitReviews(ids); + notify("success", `已提交 ${res.submitted} 个资产送审`); + await load(); + } catch { + notify("error", "送审失败"); + } finally { + setBusy(false); + } + } + + async function poll() { + if (busy) return; + setBusy(true); + try { + const res = await adminApi.pollReviews(); + notify("success", res.polled > 0 ? `已刷新 ${res.polled} 个审核中资产` : "暂无审核中的资产"); + await load(); + } catch { + notify("error", "刷新失败"); + } finally { + setBusy(false); + } + } + + return ( + <> +
+
+

资产审核

+
// {count} 个真人资产 · 火山人像素材库绿盾 / 红标
+
+
+ +
+
+ +
+
+ {TABS.map((t) => ( + + ))} +
+
+ + {loading ? ( +

加载中…

// fetching review queue

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

暂无资产

// no person assets

+ ) : ( +
+ + + + + + + + + + + + + {assets.map((a) => ( + + + + + + + + + ))} + +
0} onChange={toggleAll} aria-label="全选" />预览团队名称审核状态操作
toggleOne(a.id)} aria-label="选择" /> + {a.preview_url + ? {a.name} + : } + {a.team_name || }{a.name || } + {reviewPill(a.review_status)} + {a.review_status === "failed" && a.review_error && !} + + {(a.review_status === "failed" || a.review_status === "") && ( + + )} +
+
+ )} + + {selected.size > 0 && ( +
+ 已选 {selected.size} 个 + + +
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index eb5eeb4..616d99f 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -82,6 +82,17 @@ export type AdminQualityWord = { enabled: boolean; created_at: string; }; +export type AdminReviewAsset = { + id: string; + name: string; + category: string; + review_status: string; + review_error: string | null; + team: string; + team_name: string | null; + preview_url: string; + created_at: string; +}; export type Paginated = { count: number; diff --git a/core/qa/visual-parity/_admin-p5.mjs b/core/qa/visual-parity/_admin-p5.mjs new file mode 100644 index 0000000..ca84dd5 --- /dev/null +++ b/core/qa/visual-parity/_admin-p5.mjs @@ -0,0 +1,75 @@ +// Phase 5 e2e:火山人像审核队列 —— 队列加载、状态盾、筛选、多选 bulk-bar、刷新状态(只读 poll)。 +// ⚠ live 库 Volcano 已启用:e2e 只做只读 poll + UI 在场校验,绝不点真送审(送审逻辑由后端 mock 单测覆盖)。 +// wrapper 预建一条 person 资产保证有行,跑完删除。 +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: {}, select: {}, poll: {}, 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, "reviews"); +await p.goto(BASE + "/admin/reviews", { waitUntil: "load" }); +await p.waitForSelector(".admin-app", { timeout: 12000 }); +// 等真表格(加载态也用 .empty-state.show,不能用它判完成);no rows 时退而求其次等非加载空态 +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.refreshBtn = (await p.locator(".page-head .actions .btn").count()) >= 1; +r.page.rows = await p.locator(".admin-table tbody tr").count(); +await p.screenshot({ path: path.join(OUT, "p5-reviews-list.png"), fullPage: true }); + +// 多选 → bulk-bar 出现 + 批量送审在场(不点击,避免真送审) +if (r.page.rows >= 1) { + await p.locator(".admin-table tbody tr:first-child .col-check input").check(); + await p.waitForTimeout(300); + r.select.bulkBar = (await p.locator(".bulk-bar").count()) >= 1; + r.select.submitBtn = (await p.locator(".bulk-bar .btn-primary").count()) >= 1; + r.select.submitEnabled = await p.locator(".bulk-bar .btn-primary").isEnabled().catch(() => false); + await p.screenshot({ path: path.join(OUT, "p5-bulkbar.png") }); + // 取消选择(避免后面误触) + await p.locator(".bulk-bar .btn").first().click(); + await p.waitForTimeout(200); +} + +// 刷新状态(只读 poll,安全) +await p.locator(".page-head .actions .btn").click(); +await p.waitForTimeout(1200); +r.poll.clicked = true; + +await ctx.close(); +await browser.close(); + +const checks = { + pageLoads: r.page.title === "资产审核" && r.page.tabs === 5 && r.page.refreshBtn === true, + hasRows: r.page.rows >= 1, + bulkBarWorks: r.select.bulkBar === true && r.select.submitBtn === true && r.select.submitEnabled === true, + pollClicked: r.poll.clicked === true, + 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, "p5-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 d993d1a..eb56714 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -124,3 +124,23 @@ - 无头 e2e:`_admin-p4.mjs`(5188,跑 live 库故用唯一标记词且**结束兜底清理**)—— **4 断言全过 + 0 console error**:页面 4 卡、编辑模式加词→chip 显示+API 持久化、删词→API 移除;live 库零残留。截图 `output/admin/p4-*.png`。 - 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 - 视觉:分组卡 + 橙 chip + 编辑态,符合 restraint。 + +--- + +## ✅ Phase 5 · 火山人像审核队列(Admin)— 完成 2026-06-19 + +**后端(apps/adminpanel 扩展,复用 assets/review.py)** +- `GET asset-reviews/`:跨团队真人(person)资产队列(`?review_status=none|processing|active|failed` 筛 + 分页);`AdminReviewAssetSerializer`(team_name + 首图 preview_url)。 +- `POST asset-reviews/submit/`(批量送审 / 失败重试,逐个 `submit_asset_for_review`)+ `POST asset-reviews/poll/`(轮询 processing,`poll_asset_review`);IsPlatformAdmin + 审计。 +- **性能修复**:序列化器 `get_preview_url` 用 prefetch 缓存 `list(obj.files.all())[0]` 取代 `.first()`,消除列表页 N+1(100 条从 ~5s 降到 ~1s)。 + +**前端** +- `adminApi` assetReviews/submitReviews/pollReviews;`AdminReviewAsset` 类型。 +- Admin「资产审核」页:状态筛选(全部/未送审/审核中/通过/失败)+ 表格(多选 + 缩略图 + 团队 + 状态盾绿/红/灰 + 失败/未送审行「重试/送审」)+ 吸底 bulk-bar「批量送审」+「刷新状态」(只读 poll);page_size 60。 +- admin-page.css 补 checkbox 列/缩略图/红标记/bulk-bar(仅 token;`.bulk-bar` design 未实现,本次补齐)。 + +**测试底座(全过)** +- 后端单测:`apps.adminpanel` **28 项 OK**(队列只列 person + 跨团队/状态筛 4 态/批量送审计数+审计(mock 火山)/送审权限 403/poll 只轮询 processing(mock))。 +- 无头 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。