feat(review): 故事板分镜图挂火山人像审核盾 + 红盾可重新送审

- 后端 StoryboardFrameSerializer 暴露 review_status + 驳回原因(之前没给,前端画不出盾)
- 前端故事板趴:每张分镜图(缩略图角上只留盾 / 大图左上完整徽章)挂 ReviewBadge,
  与人物/场景卡同款(审核中橙光 / 已过审绿 / 未过审红);故事板趴每 8s 轮询刷新审核态
- ReviewBadge:红盾(未过审)也可点「重新送审」——「审核超时」是误判(分镜图在 processing
  卡太久被本地超时兜底标失败,非火山真违规),重提即可再审;灰盾/红盾两处(分镜图+人物卡)行为一致
- compact 模式:嵌在 overflow:hidden 容器里的盾(分镜图)改用原生 title 提示,避免 CSS 气泡被裁看不见

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 17:23:18 +08:00
co-authored by Claude Opus 4.8
parent 4074c13733
commit c2c2dfdc77
5 changed files with 31 additions and 10 deletions
+10 -1
View File
@@ -102,15 +102,24 @@ class BaseAssetGroupSerializer(serializers.ModelSerializer):
class StoryboardFrameSerializer(serializers.ModelSerializer):
asset_url = serializers.SerializerMethodField()
# 分镜图含人脸,后端自动送火山人像审核;暴露审核态让前端挂盾牌(与角色/场景卡同款)
review_status = serializers.SerializerMethodField()
review_error = serializers.SerializerMethodField()
class Meta:
model = StoryboardFrame
fields = ["id", "script_segment", "asset", "asset_url", "sort_order", "prompt"]
fields = ["id", "script_segment", "asset", "asset_url", "sort_order", "prompt", "review_status", "review_error"]
read_only_fields = fields
def get_asset_url(self, obj) -> str:
return _asset_preview_url(obj.asset)
def get_review_status(self, obj) -> str:
return obj.asset.review_status if obj.asset_id else ""
def get_review_error(self, obj) -> str:
return (obj.asset.review_error or "") if obj.asset_id else ""
class StoryboardVersionSerializer(serializers.ModelSerializer):
frames = StoryboardFrameSerializer(many=True, read_only=True)
@@ -11,18 +11,20 @@ const META: Record<string, { label: string; cls: string }> = {
"": { label: "待审核", cls: "rv-idle" }
};
export function ReviewBadge({ status, onSubmit, error, busy }: {
export function ReviewBadge({ status, onSubmit, error, busy, compact }: {
status: ReviewStatus;
onSubmit?: () => void; // 灰盾(待审核)时点击触发提交
onSubmit?: () => void; // 灰盾(待审核)/红盾(未过审)时点击触发(重新)送审
error?: string; // 未过审原因(气泡里显)
busy?: boolean; // 提交中
compact?: boolean; // 用在 overflow:hidden 容器里(如故事板分镜图):改用原生 title 提示,避免 CSS 气泡被裁掉看不见
}) {
const key = status || "";
const meta = META[key] || META[""];
const clickable = key === "" && !!onSubmit && !busy;
// 灰盾(待审核)和红盾(未过审/常见是审核超时)都可点重新送审 —— 超时是误判,重提即可再审一遍
const clickable = (key === "" || key === "failed") && !!onSubmit && !busy;
const hint =
key === "active" ? "火山人像合规审核 · 已通过,可安全用于视频生成"
: key === "failed" ? `火山人像合规审核 · 未通过${error ? " · " + error : ",建议改提示词重新生成"}`
: key === "failed" ? `火山人像合规审核 · 未通过${error ? " · " + error : ""}${clickable ? " · 点击重新送审" : ",建议改提示词重新生成"}`
: key === "processing" ? "火山人像合规审核 · 审核中,通常很快"
: clickable ? "尚未送审 · 点击立即提交火山人像审核"
: "尚未送审 · 含人脸的视频素材需过火山人像审核";
@@ -31,12 +33,13 @@ export function ReviewBadge({ status, onSubmit, error, busy }: {
className={`review-badge ${meta.cls}${clickable ? " clickable" : ""}`}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
title={compact ? hint : undefined}
onClick={clickable ? (e) => { e.stopPropagation(); onSubmit!(); } : undefined}
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onSubmit!(); } } : undefined}
>
<span className="rv-shield" aria-hidden="true" />
<span className="rv-label">{busy ? "提交中…" : meta.label}</span>
<span className="review-pop" role="tooltip">{hint}</span>
{!compact && <span className="review-pop" role="tooltip">{hint}</span>}
</span>
);
}
+4
View File
@@ -411,6 +411,10 @@
.sb-gen-veil { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; border-radius: inherit; pointer-events: none; background: color-mix(in srgb, var(--surface) 62%, transparent); }
.sb-scene-thumb .sb-gen-veil .spinner { width: 16px; height: 16px; }
.sb-main-img .sb-gen-veil .spinner { width: 28px; height: 28px; }
/* 分镜图火山人像审核盾:缩略图角上只留盾(省地方),大图左上挂完整徽章 */
.sb-scene-thumb .placeholder .sb-frame-rv { position: absolute; top: 4px; left: 4px; z-index: 4; transform: scale(.82); transform-origin: top left; }
.sb-scene-thumb .placeholder .sb-frame-rv .rv-label { display: none; }
.sb-main-img .sb-main-rv { position: absolute; top: 10px; left: 10px; z-index: 4; }
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
+8 -3
View File
@@ -947,9 +947,9 @@ export function PipelinePage(props: {
setReviewBusyId(null);
}
}
// 在基础资产趴(stage 2)定时轮询审核状态,刷新徽章(processing → active绿 / failed红)
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)定时轮询审核状态,刷新徽章(processing → active绿 / failed红)
useEffect(() => {
if (viewStage !== 2) return;
if (viewStage !== 2 && viewStage !== 3) return;
let alive = true;
let sawProcessing = false;
let timer = 0;
@@ -2977,7 +2977,11 @@ export function PipelinePage(props: {
const url = frameUrl(frame);
return (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame"> {idx + 1}</span>{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}</div>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame"> {idx + 1}</span>
{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{frame.asset && <span className="sb-frame-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[frame.asset] || frame.review_status || "") as ReviewStatus} error={frame.review_error} onSubmit={() => void submitAssetReview(frame.asset)} busy={reviewBusyId === frame.asset} /></span>}
</div>
<div className="nm"> {idx + 1}</div>
<div className="sub">{times[idx] || `#${frame.sort_order + 1}`}</div>
</div>
@@ -3000,6 +3004,7 @@ export function PipelinePage(props: {
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `${sbSelected + 1}` }); } } : undefined}>
<span className="ph-frame">{sbActiveFrame ? `${sbSelected + 1}` : sbExpectedShots ? `${sbSelected + 1} · 待生成` : "// 故事板未生成"}</span>
{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{sbActiveFrame?.asset && <span className="sb-main-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[sbActiveFrame.asset] || sbActiveFrame.review_status || "") as ReviewStatus} error={sbActiveFrame.review_error} onSubmit={() => void submitAssetReview(sbActiveFrame.asset)} busy={reviewBusyId === sbActiveFrame.asset} /></span>}
</div>
);
})()}
+1 -1
View File
@@ -331,7 +331,7 @@ export type StoryboardVersion = {
id: string;
prompt: string;
is_adopted: boolean;
frames: Array<{ id: string; sort_order: number; prompt: string; asset: string; asset_url?: string }>;
frames: Array<{ id: string; sort_order: number; prompt: string; asset: string; asset_url?: string; review_status?: string; review_error?: string }>;
created_at: string;
updated_at: string;
};