From 708b9ea5728cdc6aeeccd506a6d5edf19aa0fa96 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sun, 21 Jun 2026 22:46:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(review):=20=E5=85=A8=E7=AB=99=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E3=80=8C=E5=AE=A1=E6=A0=B8=E7=9B=BE=E3=80=8D=E5=BE=BD?= =?UTF-8?q?=E7=AB=A0=20+=20=E7=81=B0=E7=9B=BE=E7=82=B9=E5=87=BB=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E6=8F=90=E4=BA=A4=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 火山人像审核状态此前三处文案各异(已审核/通过/审核✓…)。统一成一个共享 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 --- core/backend/apps/assets/tests.py | 22 +++++ core/backend/apps/assets/views.py | 13 +++ core/frontend/src/api.ts | 4 + core/frontend/src/components/review-badge.css | 84 +++++++++++++++++++ core/frontend/src/components/review-badge.tsx | 42 ++++++++++ core/frontend/src/routes/library.tsx | 27 ++++-- core/frontend/src/routes/pipeline.tsx | 23 ++++- core/frontend/src/routes/products.tsx | 18 +++- core/qa/visual-parity/_review-badge.mjs | 20 +++++ 9 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 core/frontend/src/components/review-badge.css create mode 100644 core/frontend/src/components/review-badge.tsx create mode 100644 core/qa/visual-parity/_review-badge.mjs diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index baf1f67..24b0eaf 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -141,6 +141,28 @@ class VideoPacksTests(TestCase): self.assertEqual(len(data[0]["clips"]), 1) +class SubmitReviewTests(TestCase): + """手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。""" + + def setUp(self): + self.user, self.team = _mk_team("usr", "TeamSR") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.person = Asset.objects.create(team=self.team, name="角色", asset_type="image", source="ai_generated", category=Asset.Category.PERSON) + self.product = Asset.objects.create(team=self.team, name="商品图", asset_type="image", source="upload", category=Asset.Category.PRODUCT_IMAGE) + + def test_submit_review_person_ok(self): + with patch("apps.assets.review.submit_asset_for_review") as sub: + res = self.client.post(f"/api/assets/{self.person.id}/submit-review/") + self.assertEqual(res.status_code, 200) + sub.assert_called_once() + self.assertIn("review_status", res.json()) + + def test_submit_review_rejects_non_review_category(self): + res = self.client.post(f"/api/assets/{self.product.id}/submit-review/") + self.assertEqual(res.status_code, 400) + + class ReviewScopeTests(TestCase): """送审范围 = 角色定妆照(person)/ 三视图(tri_view)/ 分镜图(storyboard);图片趴不送审。""" diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 2346b2f..396631a 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -116,6 +116,19 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): tabs = ["tryon", "kits", "creations", "others"] return Response({t: base.filter(_tab_q(t)).count() for t in tabs}) + @action(detail=True, methods=["post"], url_path="submit-review") + def submit_review(self, request, pk=None): + """手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核 —— 用户点灰盾「待审核」时触发。 + 正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。""" + from apps.assets.review import submit_asset_for_review + + asset = self.get_object() # team-scoped + is_deleted 过滤 + if asset.category not in Asset.REVIEW_CATEGORIES: + return Response({"detail": "该素材无需审核"}, status=status.HTTP_400_BAD_REQUEST) + submit_asset_for_review(asset) + asset.refresh_from_db() + return Response({"review_status": asset.review_status, "review_error": asset.review_error or ""}) + @action(detail=False, methods=["get"], url_path="video-packs") def video_packs(self, request): """视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源: diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 28908c7..dada15b 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -440,6 +440,10 @@ export const api = { videoPacks() { return request("/api/assets/video-packs/"); }, + // 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时) + submitAssetReview(id: string) { + return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" }); + }, // 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」 assetFacets(tab?: string, metaKeys: string[] = []) { const qs = new URLSearchParams(); diff --git a/core/frontend/src/components/review-badge.css b/core/frontend/src/components/review-badge.css new file mode 100644 index 0000000..8824a67 --- /dev/null +++ b/core/frontend/src/components/review-badge.css @@ -0,0 +1,84 @@ +/* 火山人像审核盾 · 全站统一徽章 · 仅用 design token */ +.review-badge { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: var(--r-pill); + border: 1px solid transparent; + font-size: 11.5px; + font-weight: 500; + letter-spacing: .02em; + white-space: nowrap; +} + +/* 盾形:用 SVG 蒙版裁出盾,background 填当前态颜色(全色填充,无对勾/叉) */ +.review-badge .rv-shield { + width: 12px; + height: 13px; + flex-shrink: 0; + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z'/%3E%3C/svg%3E") center / contain no-repeat; +} + +.review-badge.rv-pass { color: var(--accent-forest); background: var(--forest-bg); border-color: var(--forest-bd); } +.review-badge.rv-fail { color: var(--accent-crimson); background: var(--crimson-bg); border-color: var(--crimson-bd); } +.review-badge.rv-proc { color: var(--heat); background: var(--heat-12); border-color: var(--heat-20); } +.review-badge.rv-idle { color: var(--black-alpha-56); background: var(--black-alpha-4); border-color: var(--border-faint); } + +/* 审核中:橙盾上一道亮光从左扫到右(蒙版只让盾发亮,柔和不闪) */ +.review-badge.rv-proc .rv-shield { + background: linear-gradient(100deg, var(--heat) 38%, #ffe0c2 50%, var(--heat) 62%); + background-size: 250% 100%; + animation: rv-sweep 1.5s linear infinite; +} +@keyframes rv-sweep { from { background-position: 150% 0; } to { background-position: -150% 0; } } + +/* 灰盾可点 → 提交审核 */ +.review-badge.clickable { cursor: pointer; } +.review-badge.clickable:hover { border-color: var(--black-alpha-24); color: var(--accent-black); } + +/* hover 气泡说明(全站统一气泡) */ +.review-pop { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + width: max-content; + max-width: 240px; + padding: 8px 10px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + box-shadow: 0 12px 32px rgba(0, 0, 0, .14); + font-size: 12px; + font-weight: 400; + line-height: 1.5; + color: var(--accent-black); + letter-spacing: 0; + white-space: normal; + text-align: left; + opacity: 0; + visibility: hidden; + transition: opacity var(--t-base, .15s), transform var(--t-base, .15s), visibility var(--t-base, .15s); + pointer-events: none; + z-index: 60; +} +.review-pop::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--surface); +} +.review-badge:hover .review-pop, +.review-badge:focus-within .review-pop { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} diff --git a/core/frontend/src/components/review-badge.tsx b/core/frontend/src/components/review-badge.tsx new file mode 100644 index 0000000..fccac9c --- /dev/null +++ b/core/frontend/src/components/review-badge.tsx @@ -0,0 +1,42 @@ +import "./review-badge.css"; + +// 火山人像合规审核盾(全站统一):全绿=已过审 / 全红=未过审 / 橙光扫=审核中 / 全灰=待审核。 +// 灰盾(未送审)可点 → 提交审核(兜底,火山很快)。只在送审类(角色/三视图/分镜)上用。 +export type ReviewStatus = "active" | "failed" | "processing" | "" | undefined; + +const META: Record = { + active: { label: "已过审", cls: "rv-pass" }, + failed: { label: "未过审", cls: "rv-fail" }, + processing: { label: "审核中", cls: "rv-proc" }, + "": { label: "待审核", cls: "rv-idle" } +}; + +export function ReviewBadge({ status, onSubmit, error, busy }: { + status: ReviewStatus; + onSubmit?: () => void; // 仅灰盾(待审核)时点击触发提交 + error?: string; // 未过审原因(气泡里显) + busy?: boolean; // 提交中 +}) { + const key = status || ""; + const meta = META[key] || META[""]; + const clickable = key === "" && !!onSubmit && !busy; + const hint = + key === "active" ? "火山人像合规审核 · 已通过,可安全用于视频生成" + : key === "failed" ? `火山人像合规审核 · 未通过${error ? " · " + error : ",建议改提示词重新生成"}` + : key === "processing" ? "火山人像合规审核 · 审核中,通常很快" + : clickable ? "尚未送审 · 点击立即提交火山人像审核" + : "尚未送审 · 含人脸的视频素材需过火山人像审核"; + return ( + { e.stopPropagation(); onSubmit!(); } : undefined} + onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onSubmit!(); } } : undefined} + > + + ); +} diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index 35d35ca..835b284 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -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(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 }: {

{asset.name || "资产详情"}

/ {kindLabel} - {review === "active" && 已审核} - {review === "processing" && 审核中} - {review === "failed" && 审核未过} + {REVIEW_CATS.includes(asset.category) && ( + + )}
diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index ef29aa3..2ea3090 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -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>({}); const assetReview = (id?: string | null) => (id ? (reviews[id] || byId.get(id)?.review_status || "") : ""); + // 手动兜底:灰盾点击 → 提交审核,乐观置 processing(下次轮询回真态) + const [reviewBusyId, setReviewBusyId] = useState(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: {
openAssetDetail(kind, entity)}>{entity.name} - {rs === "active" && 审核✓} - {rs === "failed" && 审核✗} - {rs === "processing" && 审核中} + {kind === "person" && grp.adopted_asset && ( + submitAssetReview(grp.adopted_asset!)} + /> + )}
setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index a0f0dae..b5e5901 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -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(null); + // 审核盾:提交后本地覆盖该资产状态 + 提交中标记 + const [reviewOverrides, setReviewOverrides] = useState>({}); + const [reviewSubmitting, setReviewSubmitting] = useState(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([]); @@ -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 (
setPreview({ src: u, name: a.name }) : undefined}> {isV && u ?
- {PD_REVIEW_CATS.has(a.category) && {PD_ASSET_STATUS_LABEL[st]}} + {PD_REVIEW_CATS.has(a.category) && ( + submitAssetReview(a.id)} /> + )} {(a.created_at || "").slice(0, 10)}
diff --git a/core/qa/visual-parity/_review-badge.mjs b/core/qa/visual-parity/_review-badge.mjs new file mode 100644 index 0000000..4fd53c7 --- /dev/null +++ b/core/qa/visual-parity/_review-badge.mjs @@ -0,0 +1,20 @@ +import { chromium } from "playwright"; +import path from "node:path"; import fs from "node:fs"; +const PID="53d7a7c3-2f41-431b-82d3-3acc97900310"; +const OUT=path.resolve("../../../_qa_shots/rv"); fs.mkdirSync(OUT,{recursive:true}); +const b=await chromium.launch({headless:true}); const r={consoleErrors:[]}; +const p=await (await b.newContext({viewport:{width:1440,height:1000}})).newPage(); +p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())}); p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message)); +await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300); +await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026"); +await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000}); +await p.goto("http://localhost:5200/products/"+PID); await p.waitForTimeout(2500); +await p.locator(".pd-group-card",{hasText:"角色"}).first().click(); await p.waitForTimeout(1200); +r.badgeCount=await p.locator(".review-badge").count(); +r.badgeText=await p.locator(".review-badge .rv-label").first().innerText().catch(()=>""); +r.badgeClickable=await p.locator(".review-badge.clickable").count(); +// hover 看气泡 +await p.locator(".review-badge").first().hover(); await p.waitForTimeout(400); +r.popVisible=await p.locator(".review-pop").first().isVisible().catch(()=>false); +await p.screenshot({path:path.join(OUT,"rv-modal.png")}); +await b.close(); console.log(JSON.stringify(r,null,2));