From 9e791a4db975951f4cd47e1d8b2720f7bac002d0 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sun, 21 Jun 2026 19:44:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(projects+assets):=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E5=BC=95=E7=94=A8=E7=9C=9F=E5=AE=9E=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=9B=BE=20+=20=E8=B5=84=E4=BA=A7=E5=BA=93=E6=8E=92?= =?UTF-8?q?=E9=99=A4=E8=BD=AF=E5=88=A0=E8=B5=84=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目卡封面:废弃「项目名关键词→静态 mock 假图」映射(与真实数据脱节,导致全是假面膜图), 改用后端 cover_preview_url(取商品 cover_asset 主图);无商品图 → 干净占位 - 资产库 queryset/summary/facets 补 is_deleted=False:软删资产不再出现在资产库与 tab 计数 (此前 is_deleted 字段在资产库未生效,软删无效) - 单测 AssetSoftDeleteTests:列表 + 计数均排除软删 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/assets/tests.py | 19 ++++++++++++++++ core/backend/apps/assets/views.py | 6 ++--- core/frontend/src/routes/projects.tsx | 25 ++++++--------------- core/qa/visual-parity/_cleanup-verify.mjs | 27 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 core/qa/visual-parity/_cleanup-verify.mjs diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index b1814cd..9c8e779 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -86,6 +86,25 @@ class ModelLibraryApiTests(TestCase): self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT) +class AssetSoftDeleteTests(TestCase): + """资产库不展示软删资产(is_deleted=True):列表 + tab 计数都排除。""" + + def setUp(self): + self.user, self.team = _mk_team("usd", "TeamSD") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.live = Asset.objects.create(team=self.team, name="留存图", asset_type="image", source="ai_generated", category=Asset.Category.PERSON) + self.dead = Asset.objects.create(team=self.team, name="废图", asset_type="image", source="ai_generated", category=Asset.Category.PERSON, is_deleted=True) + + def test_list_excludes_soft_deleted(self): + ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]} + self.assertIn(str(self.live.id), ids) + self.assertNotIn(str(self.dead.id), ids) + + def test_summary_excludes_soft_deleted(self): + self.assertEqual(self.client.get("/api/assets/summary/").json()["people"], 1) + + class ReviewScopeTests(TestCase): """送审范围 = 角色定妆照(person)/ 三视图(tri_view)/ 分镜图(storyboard);图片趴不送审。""" diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 5bb80d3..ca44fe8 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -79,7 +79,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): """服务端过滤,支持各页按需懒加载(不再前端取全量后客户端切片)。 参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_(metadata 过滤)/ordering。 默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。""" - qs = super().get_queryset() + qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库 p = self.request.query_params if p.get("tab"): qs = qs.filter(_tab_q(p["tab"])) @@ -109,7 +109,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): @action(detail=False, methods=["get"]) def summary(self, request): """资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。""" - base = Asset.objects.filter(team=self.get_team()) + base = Asset.objects.filter(team=self.get_team(), is_deleted=False) tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "materials", "uploads", "unclassified"] return Response({t: base.filter(_tab_q(t)).count() for t in tabs}) @@ -117,7 +117,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): def facets(self, request): """某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。 参数:tab、meta_keys=gender,age,role,...(逗号分隔)。""" - base = Asset.objects.filter(team=self.get_team()) + base = Asset.objects.filter(team=self.get_team(), is_deleted=False) if request.query_params.get("tab"): base = base.filter(_tab_q(request.query_params["tab"])) sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s) diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index dfd91f4..c171f6d 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -396,20 +396,9 @@ function projShotMeta(project: Project): string { const dur = projDurationLabel(project); return dur ? `${shots} 镜 · ${dur}` : `${shots} 镜`; } -// 复刻 mock-media coverFor:按项目名关键词映射封面图(无匹配 → 占位) -function projCover(name: string): string { - const t = name.replace(/\s+/g, ""); - if (/蓝牙|耳机|南卡/.test(t)) return "cover-earbuds.png"; - if (/速食|牛肉面|泡面|面条/.test(t)) return "cover-noodle.png"; - if (/防晒/.test(t)) return "cover-sunscreen.png"; - if (/咖啡|冻干/.test(t)) return "cover-coffee.png"; - if (/空气炸锅|小熊/.test(t)) return "cover-air-fryer.png"; - if (/瑜伽裤|露露/.test(t)) return "cover-yoga.png"; - if (/v1|final|已完成|成片|敷面膜|化妆台/.test(t)) return "cover-mask-final.png"; - if (/面膜|补水|玻尿酸|透真/.test(t)) return "cover-mask-v3.png"; - return ""; -} -const projMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties); +// 项目卡封面 = 该项目商品的主图(后端 cover_preview_url,取商品 cover_asset)。无图 → 占位。 +// (原先按项目名关键词映射静态 mock 假图,与真实数据脱节,已废弃。) +const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string }> = [ { filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" } @@ -638,7 +627,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op {pageItems.map((project) => { const no = projStageNo(project); - const cover = projCover(project.name); + const cover = project.cover_preview_url || ""; const isSel = selected.has(project.id); return ( (editMode ? toggleSelect(project.id) : openPipeline(project.id))}> @@ -647,7 +636,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op {editMode && ( )} -
9:16
+
9:16
{project.name}
{projShotMeta(project)}
@@ -681,13 +670,13 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op ) : (
{pageItems.map((project) => { - const cover = projCover(project.name); + const cover = project.cover_preview_url || ""; const no = projStageNo(project); const isSel = selected.has(project.id); return (
(editMode ? toggleSelect(project.id) : openPipeline(project.id))}> -
9:16
+
9:16
{project.name}
diff --git a/core/qa/visual-parity/_cleanup-verify.mjs b/core/qa/visual-parity/_cleanup-verify.mjs new file mode 100644 index 0000000..1bcdb82 --- /dev/null +++ b/core/qa/visual-parity/_cleanup-verify.mjs @@ -0,0 +1,27 @@ +import { chromium } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +const BASE = process.env.BASE || "http://localhost:5200"; +const OUT = path.resolve("../../../_qa_shots/cleanup"); +fs.mkdirSync(OUT, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const r = { consoleErrors: [] }; +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); +p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => r.consoleErrors.push("PAGEERR:" + e.message)); +await p.goto(BASE + "/login", { waitUntil: "load" }); +await p.waitForTimeout(400); +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(BASE + "/projects", { waitUntil: "load" }); await p.waitForTimeout(2600); +await p.screenshot({ path: path.join(OUT, "projects.png"), fullPage: true }); +await p.goto(BASE + "/library", { waitUntil: "load" }); await p.waitForTimeout(2600); +r.libTabs = (await p.locator(".tab").allInnerTexts().catch(() => [])).join(" | "); +await p.screenshot({ path: path.join(OUT, "library.png"), fullPage: true }); +await p.goto(BASE + "/models", { waitUntil: "load" }); await p.waitForTimeout(2600); +r.modelCards = await p.locator(".model-card").count(); +await p.screenshot({ path: path.join(OUT, "models.png"), fullPage: true }); +await browser.close(); +console.log(JSON.stringify(r, null, 2));