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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user