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
+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;