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.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()
+66
View File
@@ -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()
+6
View File
@@ -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/<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/<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.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})
+41
View File
@@ -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; }
+22
View File
@@ -1,5 +1,6 @@
import type {
AdminQualityWord,
AdminReviewAsset,
AdminTeam,
AdminTeamDetail,
AdminUser,
@@ -577,5 +578,26 @@ export const adminApi = {
},
deleteQualityWord(id: string) {
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 { 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 <AdminQualityPage notify={notify} />;
}
if (section.slug === "reviews") {
return <AdminReviewsPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
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;
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> = {
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`
- 类型/构建:`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。