feat(review): 全站统一「审核盾」徽章 + 灰盾点击手动提交兜底

火山人像审核状态此前三处文案各异(已审核/通过/审核✓…)。统一成一个共享 ReviewBadge:
全色填充盾(无对勾叉)+ 文案 + hover 气泡说明,4 态:
  🟢已过审(active) / 🔴未过审(failed) / 🟠审核中(processing,橙盾光扫动画) / 待审核(空)
灰盾(未送审)可点 → 提交审核(兜底,不加固定按钮):后端新增 POST /api/assets/{id}/submit-review/
(团队级,仅送审三类),前端点击乐观置 processing。气泡组件顺带成为全站首个统一 tooltip。

- 后端 submit-review 端点 + 单测(送审类 200 / 非送审类 400)
- 共享 components/review-badge.{tsx,css}(盾用 SVG 蒙版填色 + 橙态 rv-sweep 光扫)
- 资产库详情弹窗 / 商品详情素材组弹窗 / 视频流程基础资产 三处全换成它(各自接 onSubmit)

验收:assets 16 测绿;tsc+build 绿;无头核对四态(灰可点+气泡/绿已过审/橙审核中不可点)、0 console error
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-21 22:46:51 +08:00
parent 9108b7679d
commit 708b9ea572
9 changed files with 242 additions and 11 deletions
+21 -6
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Shield, Trash2, Upload, X } from "lucide-react";
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2, Upload, X } from "lucide-react";
import { api } from "../api";
import { SkeletonGrid } from "../components/loading";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import type { Asset } from "../types";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { Pager } from "../components/pager";
@@ -130,8 +131,22 @@ function AssetDetailModal({ asset, close, onZoom }: {
{ k: "入库时间", v: createdAt }
];
// 人物审核盾:仅真人有 review_status(processing/active 绿/failed 红)
const review = asset.review_status;
// 审核盾:仅送审类(角色/三视图/分镜)显示;灰盾可点提交。本地覆盖提交后即时反映,切资产清空
const REVIEW_CATS = ["person", "tri_view", "storyboard"];
const [reviewOverride, setReviewOverride] = useState<string | null>(null);
const [submittingReview, setSubmittingReview] = useState(false);
useEffect(() => { setReviewOverride(null); }, [asset?.id]);
const review = (reviewOverride ?? asset.review_status ?? "") as ReviewStatus;
async function submitReview() {
if (!asset) return;
setSubmittingReview(true);
try {
const r = await api.submitAssetReview(asset.id);
setReviewOverride(r.review_status || "processing");
} catch { /* 提交失败保持灰盾,用户可再点 */ } finally {
setSubmittingReview(false);
}
}
function renderLead(): ReactNode {
if (leadSrc && isVideo) {
@@ -151,9 +166,9 @@ function AssetDetailModal({ asset, close, onZoom }: {
<div className="adx-h">
<h2>{asset.name || "资产详情"}</h2>
<span className="adx-kind">/ {kindLabel}</span>
{review === "active" && <span className="pill ok adx-shield"><Shield size={11} /> </span>}
{review === "processing" && <span className="pill info adx-shield"></span>}
{review === "failed" && <span className="pill err adx-shield"></span>}
{REVIEW_CATS.includes(asset.category) && (
<ReviewBadge status={review} error={asset.review_error} busy={submittingReview} onSubmit={submitReview} />
)}
<button className="x adx-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
</div>
+20 -3
View File
@@ -8,6 +8,7 @@ import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { ActorLibrary } from "../components/actor-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { IconKitSvg } from "../components/IconKitSvg";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
@@ -859,6 +860,17 @@ export function PipelinePage(props: {
// 火山人像审核状态(真人资产绿/红盾):本地覆盖 map(轮询刷新),回退 asset.review_status
const [reviews, setReviews] = useState<Record<string, string>>({});
const assetReview = (id?: string | null) => (id ? (reviews[id] || byId.get(id)?.review_status || "") : "");
// 手动兜底:灰盾点击 → 提交审核,乐观置 processing(下次轮询回真态)
const [reviewBusyId, setReviewBusyId] = useState<string | null>(null);
async function submitAssetReview(id: string) {
setReviewBusyId(id);
try {
const r = await api.submitAssetReview(id);
setReviews((m) => ({ ...m, [id]: r.review_status || "processing" }));
} catch { /* 失败保持灰盾可再点 */ } finally {
setReviewBusyId(null);
}
}
// 在基础资产趴(stage 2)定时轮询审核状态,刷新徽章(processing → active绿 / failed红)
useEffect(() => {
if (viewStage !== 2) return;
@@ -2805,9 +2817,14 @@ export function PipelinePage(props: {
<div className="hstack">
<strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openAssetDetail(kind, entity)}>{entity.name}</strong>
<span className="spacer"></span>
{rs === "active" && <span className="pill ok" title="火山真人审核已通过"><span className="dot"></span></span>}
{rs === "failed" && <span className="pill err" title={`审核未过:${grp.adopted_asset_review_error || byId.get(grp.adopted_asset!)?.review_error || "建议改提示词重新生成"}`}><span className="dot"></span></span>}
{rs === "processing" && <span className="pill neutral"><span className="dot"></span></span>}
{kind === "person" && grp.adopted_asset && (
<ReviewBadge
status={rs as ReviewStatus}
error={grp.adopted_asset_review_error || byId.get(grp.adopted_asset)?.review_error}
busy={reviewBusyId === grp.adopted_asset}
onSubmit={() => submitAssetReview(grp.adopted_asset!)}
/>
)}
</div>
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
<div className="hstack" style={{ marginTop: 10 }}>
+16 -2
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { ArrowLeft, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
import { Pager } from "../components/pager";
import { SkeletonGrid } from "../components/loading";
@@ -635,6 +636,18 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
// 素材成组(A):点组卡 → 弹窗看这组所有素材
const [openGroup, setOpenGroup] = useState<PdGroup | null>(null);
// 审核盾:提交后本地覆盖该资产状态 + 提交中标记
const [reviewOverrides, setReviewOverrides] = useState<Record<string, string>>({});
const [reviewSubmitting, setReviewSubmitting] = useState<string | null>(null);
async function submitAssetReview(id: string) {
setReviewSubmitting(id);
try {
const r = await api.submitAssetReview(id);
setReviewOverrides((m) => ({ ...m, [id]: r.review_status || "processing" }));
} catch { /* 失败保持灰盾可再点 */ } finally {
setReviewSubmitting(null);
}
}
// ── 该商品的 AI 素材:服务端按 ?product 懒加载(不再吃全局 assets 全量数组)──
const [productAssets, setProductAssets] = useState<Asset[]>([]);
@@ -1177,14 +1190,15 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
{openGroup.assets.map((a) => {
const u = pdAssetPreview(a);
const isV = a.asset_type === "video";
const st = statusOf(a);
return (
<div className="pd-group-item" key={a.id}>
<div className="pd-group-thumb placeholder" role={u && !isV ? "button" : undefined} style={u && !isV ? { cursor: "zoom-in" } : undefined} onClick={u && !isV ? () => setPreview({ src: u, name: a.name }) : undefined}>
{isV && u ? <video src={u} controls muted playsInline preload="metadata" /> : u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame"></span>}
</div>
<div className="pd-group-item-meta">
{PD_REVIEW_CATS.has(a.category) && <span className={`pill ${st}`} data-status={st}>{PD_ASSET_STATUS_LABEL[st]}</span>}
{PD_REVIEW_CATS.has(a.category) && (
<ReviewBadge status={(reviewOverrides[a.id] ?? a.review_status ?? "") as ReviewStatus} error={a.review_error} busy={reviewSubmitting === a.id} onSubmit={() => submitAssetReview(a.id)} />
)}
<span className="date mono">{(a.created_at || "").slice(0, 10)}</span>
</div>
</div>