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:
co-authored by
Claude Opus 4.8
parent
4074c13733
commit
c2c2dfdc77
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user