feat(admin): Phase 5 火山人像审核队列 — 跨团队 person 资产审核(列/筛/批量送审/轮询)

后端: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 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 22:07:11 +08:00
co-authored by Claude Opus 4.8
parent 01d4f235ed
commit 5aab8a568f
11 changed files with 494 additions and 0 deletions
@@ -2,6 +2,7 @@ from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import QualityWord from apps.ai.models import QualityWord
from apps.assets.models import Asset
class QualityWordSerializer(serializers.ModelSerializer): class QualityWordSerializer(serializers.ModelSerializer):
@@ -11,6 +12,25 @@ class QualityWordSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "created_at"] 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): class AdminTeamSerializer(serializers.ModelSerializer):
owner_username = serializers.CharField(source="owner.username", read_only=True, default=None) owner_username = serializers.CharField(source="owner.username", read_only=True, default=None)
member_count = serializers.SerializerMethodField() member_count = serializers.SerializerMethodField()
+66
View File
@@ -1,3 +1,5 @@
from unittest.mock import patch
from rest_framework.test import APIClient from rest_framework.test import APIClient
from django.test import TestCase from django.test import TestCase
@@ -232,3 +234,67 @@ class AdminQualityWordTests(TestCase):
words = quality_words("video") words = quality_words("video")
self.assertIn("启用词", words) self.assertIn("启用词", words)
self.assertNotIn("停用词", 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()
+6
View File
@@ -1,6 +1,9 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
admin_asset_reviews,
admin_asset_reviews_poll,
admin_asset_reviews_submit,
admin_invitations, admin_invitations,
admin_quality_word_detail, admin_quality_word_detail,
admin_quality_words, admin_quality_words,
@@ -25,4 +28,7 @@ urlpatterns = [
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"), path("users/<uuid:user_id>/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_words, name="admin-quality-words"),
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"), path("quality-words/<uuid:word_id>/", 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"),
] ]
+64
View File
@@ -11,9 +11,12 @@ from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import QualityWord 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 apps.common.pagination import DefaultPagination
from .serializers import ( from .serializers import (
AdminReviewAssetSerializer,
AdminTeamMemberSerializer, AdminTeamMemberSerializer,
AdminTeamSerializer, AdminTeamSerializer,
AdminUserSerializer, AdminUserSerializer,
@@ -262,3 +265,64 @@ def admin_quality_word_detail(request, word_id):
after={"text": obj.text}, after={"text": obj.text},
) )
return Response(QualityWordSerializer(obj).data) 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})
+41
View File
@@ -195,3 +195,44 @@
border-radius: 50%; border-radius: 50%;
} }
.qw-del:hover { color: var(--accent-crimson); background: var(--black-alpha-4); } .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; }
+22
View File
@@ -1,5 +1,6 @@
import type { import type {
AdminQualityWord, AdminQualityWord,
AdminReviewAsset,
AdminTeam, AdminTeam,
AdminTeamDetail, AdminTeamDetail,
AdminUser, AdminUser,
@@ -577,5 +578,26 @@ export const adminApi = {
}, },
deleteQualityWord(id: string) { deleteQualityWord(id: string) {
return request<void>(`/api/admin/quality-words/${id}/`, { method: "DELETE" }); return request<void>(`/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<Paginated<AdminReviewAsset>>(`/api/admin/asset-reviews/${q ? `?${q}` : ""}`);
},
submitReviews(assetIds: string[]) {
return request<{ submitted: number; statuses: Record<string, string> }>(
"/api/admin/asset-reviews/submit/",
{ method: "POST", body: JSON.stringify({ asset_ids: assetIds }) }
);
},
pollReviews(assetIds?: string[]) {
return request<{ polled: number; statuses: Record<string, string> }>(
"/api/admin/asset-reviews/poll/",
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
);
} }
}; };
@@ -5,6 +5,7 @@ import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config"; import type { NavigateFn } from "../route-config";
import { AdminInvitesPage } from "./admin-invites"; import { AdminInvitesPage } from "./admin-invites";
import { AdminQualityPage } from "./admin-quality"; import { AdminQualityPage } from "./admin-quality";
import { AdminReviewsPage } from "./admin-reviews";
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users"; import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void; 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") { if (section.slug === "quality") {
return <AdminQualityPage notify={notify} />; return <AdminQualityPage notify={notify} />;
} }
if (section.slug === "reviews") {
return <AdminReviewsPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面 // 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />; return <AdminPlaceholder section={section} />;
} }
@@ -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 <span className="pill ok"><span className="dot" />通过</span>;
if (status === "failed") return <span className="pill err"><span className="dot" />未通过</span>;
if (status === "processing") return <span className="pill info"><span className="dot" />审核中</span>;
return <span className="pill neutral"><span className="dot" />未送审</span>;
}
export function AdminReviewsPage({ notify }: { notify: Notify }) {
const [assets, setAssets] = useState<AdminReviewAsset[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState("");
const [selected, setSelected] = useState<Set<string>>(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 (
<>
<div className="page-head">
<div>
<h1>资产审核</h1>
<div className="sub"><span className="mono">// {count} 个真人资产</span> · 火山人像素材库绿盾 / 红标</div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={busy} onClick={() => void poll()}>
<RefreshCw size={14} /> 刷新状态
</button>
</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>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>加载中…</h3><p>// fetching review queue</p></div>
) : assets.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>暂无资产</h3><p>// no person assets</p></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr>
<th className="col-check"><input type="checkbox" checked={selected.size === assets.length && assets.length > 0} onChange={toggleAll} aria-label="全选" /></th>
<th>预览</th>
<th>团队</th>
<th>名称</th>
<th>审核状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{assets.map((a) => (
<tr key={a.id}>
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
<td>
{a.preview_url
? <img className="admin-thumb" src={a.preview_url} alt={a.name} loading="lazy" />
: <span className="admin-thumb admin-thumb-empty" />}
</td>
<td>{a.team_name || <span className="muted">—</span>}</td>
<td>{a.name || <span className="muted">—</span>}</td>
<td>
{reviewPill(a.review_status)}
{a.review_status === "failed" && a.review_error && <span className="admin-review-err mono" title={a.review_error}>!</span>}
</td>
<td className="col-actions">
{(a.review_status === "failed" || a.review_status === "") && (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void submit([a.id])}>
{a.review_status === "failed" ? "重试" : "送审"}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selected.size > 0 && (
<div className="bulk-bar show">
<span className="bulk-count">已选 {selected.size} 个</span>
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}>取消</button>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}>批量送审</button>
</div>
)}
</>
);
}
+11
View File
@@ -82,6 +82,17 @@ export type AdminQualityWord = {
enabled: boolean; enabled: boolean;
created_at: string; 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<T> = { export type Paginated<T> = {
count: number; count: number;
+75
View File
@@ -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);
+20
View File
@@ -124,3 +124,23 @@
- 无头 e2e:`_admin-p4.mjs`(5188,跑 live 库故用唯一标记词且**结束兜底清理**)—— **4 断言全过 + 0 console error**:页面 4 卡、编辑模式加词→chip 显示+API 持久化、删词→API 移除;live 库零残留。截图 `output/admin/p4-*.png`。 - 无头 e2e:`_admin-p4.mjs`(5188,跑 live 库故用唯一标记词且**结束兜底清理**)—— **4 断言全过 + 0 console error**:页面 4 卡、编辑模式加词→chip 显示+API 持久化、删词→API 移除;live 库零残留。截图 `output/admin/p4-*.png`。
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 - 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
- 视觉:分组卡 + 橙 chip + 编辑态,符合 restraint。 - 视觉:分组卡 + 橙 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。