From da8c6a70a7ae45faf52e4a599128dfac4d3b05eb Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 05:19:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20Wave=203=20batch3=20=E2=80=94=20?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=BA=93=E8=A7=86=E5=9B=BE=E5=88=87=E6=8D=A2?= =?UTF-8?q?/=E5=A4=9A=E9=80=89/=E7=B4=A0=E6=9D=90=E4=B8=89=E6=80=81=20+=20?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=BF=9B=E5=BA=A6=E6=9D=A1/=E6=89=B9?= =?UTF-8?q?=E9=87=8F=20+=20=E5=B7=A5=E4=BD=9C=E5=8F=B0=E7=9C=9F=E5=AE=9EKP?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商品库+详情(sub-agent):网格/列表视图切换、分类筛选改多选(Set+计数徽标)、素材卡三态pill (接真实 review_status 循环切换)、采用版本联动素材卡状态+prepend、素材区视图/状态筛选/重置 项目列表(sub-agent):网格卡补 .prog 5段进度条+N/5、失败 pill 修返回 err(原 fail 无定义退化无色)、 批量栏+card-check多选+批量删、镜数/时长用真实字段(缺则降级不造假)、去重复删除入口 工作台(sub-agent):KPI delta 接真实本月新建数(全量未加载则不显)、删无真实源的假delta、 余额用真实冻结/总资金占比、日期改 mono、最近项目镜数用真实 video_segment_count 验证: tsc 0 · build 0 · 走查 视图切换/列表态/分类chip/进度条/批量勾选/KPI 全过 · 0 console error ⚠️ 真实数据缺(未造假,降级处理):非person素材无review_status初值;dashboard无月度预算/上月对比字段。移交监督。 Co-Authored-By: Claude Opus 4.8 --- core/frontend/src/products-page.css | 20 +++ core/frontend/src/projects-page.css | 47 ++++++- core/frontend/src/routes/dashboard.tsx | 29 +++- core/frontend/src/routes/products.tsx | 170 +++++++++++++++++++++--- core/frontend/src/routes/projects.tsx | 113 +++++++++++++--- core/qa/visual-parity/_wave3-batch3.mjs | 76 +++++++++++ 6 files changed, 410 insertions(+), 45 deletions(-) create mode 100644 core/qa/visual-parity/_wave3-batch3.mjs diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index 4d971e0..6d72092 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -6,6 +6,13 @@ .products-main .result-meta { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; } .chip-wrap { position: relative; } + /* 商品分类多选 chip · 菜单多选项右侧计数 + chip 上橙色已选计数徽标(忠实移植 products.html) + 注:共享 .mi 已是 flex/align-center(design-restraint),此处只补 cat 专属计数,不重写共享类 */ + .chip-wrap[data-key="cat"] .chip-menu { min-width: 220px; } + .chip-wrap[data-key="cat"] .mi .cat-count { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; } + .chip-wrap[data-key="cat"] .mi.selected .cat-count { color: var(--heat); } + .chip .chip-count { display: inline-flex; align-items: center; justify-content: center; height: 18px; min-width: 18px; padding: 0 5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); border-radius: var(--r-pill); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; margin-left: 2px; } + .product-grid-wrap { margin: 0 -8px; padding: 2px 8px 24px; } .product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } .product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; } @@ -39,6 +46,19 @@ .view-tog button.active { background: var(--accent-black); color: var(--accent-white); } .view-tog button svg { width: 13px; height: 13px; } + /* 列表视图:网格→单列,每行横排(缩图 100×70 + 信息行 + 底栏统计)·忠实移植 products.html */ + .product-grid.list-view { display: flex; flex-direction: column; gap: 8px; } + .product-grid.list-view .product-card { display: grid; grid-template-columns: 100px 1fr auto; gap: 16px; padding: 12px 16px; align-items: center; } + .product-grid.list-view .product-thumb { width: 100px; height: 70px; aspect-ratio: auto; margin: 0; border-radius: var(--r-sm); flex-shrink: 0; } + .product-grid.list-view .product-card .product-thumb { border: 0; } + .product-grid.list-view .product-body { padding: 0; display: flex; align-items: center; gap: 14px; } + .product-grid.list-view .product-name { font-size: 14px; } + .product-grid.list-view .product-cat { margin-top: 0; } + .product-grid.list-view .product-date { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 0; } + .product-grid.list-view .product-footer { border-top: 0; padding: 0; background: transparent; column-gap: 6px; } + /* 列表态卡片左上 checkbox 贴合更紧 */ + .product-grid.list-view .product-card .card-check { top: 50%; left: 6px; transform: translateY(-50%); } + /* 编辑模式 checkbox(默认隐藏) */ .product-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--border-loud); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; } .product-card .card-check svg { width: 11px; height: 11px; visibility: hidden; } diff --git a/core/frontend/src/projects-page.css b/core/frontend/src/projects-page.css index 58348e5..5929c2e 100644 --- a/core/frontend/src/projects-page.css +++ b/core/frontend/src/projects-page.css @@ -21,9 +21,16 @@ /* ─── Grid view ─── */ .proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } - .proj-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; display: flex; flex-direction: column; position: relative; } + .proj-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; display: flex; flex-direction: column; position: relative; } .proj-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); } .proj-card .card-thumb { aspect-ratio: 9/16; max-height: 280px; border-radius: var(--r-md) var(--r-md) 0 0; } + + /* 网格卡编辑态勾选圈(默认隐藏,body.edit-mode 显示;选中橙边 + 橙底白勾)· 蓝本同 products-page */ + .proj-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--black-alpha-32); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; } + .proj-card .card-check svg { width: 11px; height: 11px; opacity: 0; } + + /* 网格卡进度条 N/5 行内对齐(.prog / .hstack / .muted-2 走全局 design-restraint) */ + .proj-card .hstack { gap: 8px; } .row-more { position: relative; display: inline-flex; cursor: pointer; align-items: center; color: var(--black-alpha-56); padding: 4px; } .row-more:hover { color: var(--accent-black); } .row-more-tip { position: absolute; top: calc(100% + 6px); right: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 4px 16px rgba(0,0,0,.08); padding: 4px; min-width: 110px; opacity: 0; pointer-events: none; transform: translateY(-2px); transition: opacity .15s, transform .15s; z-index: 12; } @@ -35,10 +42,46 @@ .btn.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); } .proj-card .card-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; flex: 1; } .proj-card .card-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.4; } - .proj-card .card-sub { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; } + .proj-card .card-sub { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; } .proj-card .card-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--border-faint); margin-top: auto; } .proj-card .card-time { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; } + /* 列表行编辑态:行首勾选圈(替代行末单删)+ 选中行橙底高亮 */ + table.t tbody tr.row-selected { background: var(--heat-12); } + .row-check { width: 20px; height: 20px; flex-shrink: 0; border-radius: 50%; background: var(--surface); border: 2px solid var(--black-alpha-32); display: grid; place-items: center; color: var(--accent-white); transition: background .15s, border-color .15s; } + .row-check svg { width: 11px; height: 11px; opacity: 0; } + .row-check.on { background: var(--heat); border-color: var(--heat); } + .row-check.on svg { opacity: 1; } + body.edit-mode table.t tbody tr { cursor: pointer; } + /* chip 下拉容器(默认收起)· chip 本体/caret 走全局 restraint .chip */ .chip-wrap { position: relative; } } + +/* ─── 批量管理(忠实移植自 V1 projects.html · body.edit-mode 全局态,控件仅项目页出现) ─── */ +body.edit-mode .proj-card { cursor: pointer; } +body.edit-mode .proj-card .card-check { display: grid; } +body.edit-mode .proj-card.selected .card-check { background: var(--heat); border-color: var(--heat); } +body.edit-mode .proj-card.selected .card-check svg { opacity: 1; } +body.edit-mode .proj-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; } + +/* 吸底批量操作栏 · 仅 body.edit-mode 显示(蓝本同 products-page,黑底白字胶囊,删除红) */ +.bulk-bar { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: var(--accent-black); color: var(--accent-white); border-radius: var(--r-md); + padding: 10px 14px 10px 18px; display: none; align-items: center; gap: 16px; + box-shadow: 0 8px 24px rgba(0,0,0,.18); z-index: 100; font-size: 13px; +} +body.edit-mode .bulk-bar { display: inline-flex; } +.bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; } +.bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; } +.bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); } +.bulk-bar button { height: 30px; padding: 0 12px; background: transparent; border: 1px solid rgba(255,255,255,.24); border-radius: var(--r-sm); color: var(--accent-white); font-size: 12.5px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; transition: background .15s, border-color .15s; } +.bulk-bar button:hover { background: rgba(255,255,255,.08); } +.bulk-bar button.danger { background: var(--accent-crimson); border-color: var(--accent-crimson); } +.bulk-bar button.danger:hover { filter: brightness(1.06); } +.bulk-bar button:disabled { opacity: .4; cursor: not-allowed; } +.bulk-bar button svg { width: 12px; height: 12px; } +.bulk-bar .clear-sel { color: rgba(255,255,255,.6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; } +.bulk-bar .clear-sel:hover { color: var(--accent-white); } +.bulk-bar .clear-sel:disabled { opacity: .4; cursor: not-allowed; } diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index ad6c42f..28c3bb1 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -31,12 +31,31 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill const completed = projects.filter((project) => project.status === "completed").length; const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length; const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品"; + + // 日期 mono:`// 06.19 · 周四`(对齐 V1 mono 注释风,补零到两位) + const now = new Date(); + const pad2 = (n: number) => String(n).padStart(2, "0"); + const weekdays = ["日", "一", "二", "三", "四", "五", "六"]; + const dateMono = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())} · 周${weekdays[now.getDay()]}`; + + // 「总项目」delta = 本月新建数(真实,按 created_at 算)。 + // 但 projects 仅首页分页数据;当存在未加载的项目(projCount > 已加载)时,本月数会少算 → 宁可不显示也不显假值。 + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); + const newThisMonth = projects.filter((p) => p.created_at && new Date(p.created_at).getTime() >= monthStart).length; + const allProjectsLoaded = projects.length >= projCount; + const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined; + + // 余额 mini progress:真实冻结占比 = 冻结 /(可用 + 冻结);无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」,改显真实「冻结 ¥X / 共 ¥Y」。 + const balanceNum = Number(billing?.account.balance ?? 0); + const reservedNum = Number(billing?.account.reserved_balance ?? 0); + const fundsTotal = balanceNum + reservedNum; + const reservedPct = fundsTotal > 0 ? Math.min(100, Math.round((reservedNum / fundsTotal) * 100)) : 0; return ( <>

欢迎回来{userName ? `,${userName.split("@")[0]}` : ""}

-
// {new Date().toLocaleDateString("zh-CN")}·你有 {running} 个项目 正在进行中
+
// {dateMono}·你有 {running} 个项目 正在进行中
-
++
-

最近项目

{loading && projects.length === 0 ? : projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
+
++
+

最近项目

{loading && projects.length === 0 ? : projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
); } -export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta: string }) { - return
{label} {badge}
{value}
{delta}
; +export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta?: string }) { + return
{label} {badge}
{value}
{delta ?
{delta}
: null}
; } export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) { diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index a58c7b7..5188de6 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -93,11 +93,24 @@ export function ProductsPage({ products, projects = [], loading = false, navigat // 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择 const [createdName, setCreatedName] = useState(null); const [openChip, setOpenChip] = useState<"" | "cat" | "date">(""); - const [catFilter, setCatFilter] = useState(""); + // 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标 + const [catFilter, setCatFilter] = useState>(new Set()); const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all"); + // 视图切换:网格 / 列表(.view-tog 黑激活;列表态卡片横排) + const [view, setView] = useState<"grid" | "list">("grid"); + const toggleCat = (cat: string) => setCatFilter((prev) => { + const next = new Set(prev); + next.has(cat) ? next.delete(cat) : next.add(cat); + return next; + }); // 筛选选项:商品分类来自真实 products,创建时间为固定区间 const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[]; + // 各分类下的商品计数(菜单右侧 mono 数字) + const catCounts = products.reduce((acc, p) => { + if (p.category) acc[p.category] = (acc[p.category] || 0) + 1; + return acc; + }, {} as Record); const DATE_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [ { value: "all", label: "全部时间" }, { value: "7", label: "近 7 天" }, @@ -124,7 +137,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat const filtered = products.filter((product) => { if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除 const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase()); - const matchCat = !catFilter || product.category === catFilter; + const matchCat = catFilter.size === 0 || (product.category ? catFilter.has(product.category) : false); let matchDate = true; if (dateFilter !== "all" && product.created_at) { const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000; @@ -134,7 +147,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat }); // 分页:每页 10 个,搜索/筛选变化回第 1 页 const [page, setPage] = useState(1); - useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]); + const catFilterKey = Array.from(catFilter).sort().join("|"); + useEffect(() => { setPage(1); }, [query, catFilterKey, dateFilter]); const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE)); const curPage = Math.min(page, totalPages); const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE); @@ -165,17 +179,21 @@ export function ProductsPage({ products, projects = [], loading = false, navigat setQuery(event.target.value)} />
-
-
{ setCatFilter(""); setOpenChip(""); }}> + {/* 全部分类 = 清空多选(不关菜单,方便连续多选) */} +
setCatFilter(new Set())}> 全部分类
{categories.length > 0 &&
} {categories.map((cat) => ( -
{ setCatFilter(cat); setOpenChip(""); }}> +
toggleCat(cat)}> {cat} + {catCounts[cat] || 0}
))}
@@ -192,8 +210,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat ))}
- {(query || catFilter || dateFilter !== "all") && ( - )} @@ -201,13 +219,21 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
// {loading && products.length === 0 ? "加载中…" : <>显示 {pageItems.length} / {filtered.length} 个商品} +
+ + +
{loading && products.length === 0 ? ( ) : (
-
+
{pageItems.map((product) => ( = { pass: "通过", fail: "不通过", archive: "归档" }; +type PdAssetStatus = "pass" | "fail" | "archive"; +const PD_ASSET_STATUS_LABEL: Record = { pass: "通过", fail: "不通过", archive: "归档" }; +// 点击循环顺序:通过 → 不通过 → 归档 → 通过(忠实移植 product-detail.html) +const PD_STATUS_CYCLE: PdAssetStatus[] = ["pass", "fail", "archive"]; +const PD_STATUS_FILTER_OPTS: Array<{ value: "" | PdAssetStatus; label: string }> = [ + { value: "", label: "全部状态" }, + { value: "pass", label: "通过" }, + { value: "fail", label: "不通过" }, + { value: "archive", label: "归档" } +]; +// 真实火山审核状态 → 三态基线:active(绿盾)→通过 · failed(红标)→不通过 · 其它("" / processing 等)→归档(中性,未定论) +function pdBaseStatus(asset: Asset): PdAssetStatus { + const r = asset.review_status || ""; + if (r === "active") return "pass"; + if (r === "failed") return "fail"; + return "archive"; +} const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"]; @@ -531,12 +573,18 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na const [triAdoptedId, setTriAdoptedId] = useState(""); const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0]; const triUrl = triViewing?.url || ""; - // 素材 tab 筛选 / 排序 / 分页 - const [openFilter, setOpenFilter] = useState<"" | "type" | "sort">(""); + // 素材 tab 筛选 / 排序 / 分页 / 视图 + const [openFilter, setOpenFilter] = useState<"" | "type" | "status" | "sort">(""); const [typeFilter, setTypeFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState<"" | "pass" | "fail" | "archive">(""); + const [assetView, setAssetView] = useState<"grid" | "list">("grid"); const [assetSortDesc, setAssetSortDesc] = useState(true); const [assetLimit, setAssetLimit] = useState(12); const [videoSortDesc, setVideoSortDesc] = useState(true); + // 素材审核状态本地覆盖(点击循环 / 采用联动写入);未覆盖则回退真实 review_status 映射 + const [statusOverrides, setStatusOverrides] = useState>({}); + // 采用三视图后,本地 prepend 的新素材卡(供「采用版本联动 + prepend 新卡」即时呈现,不等服务端回灌) + const [adoptedAssets, setAdoptedAssets] = useState([]); // 图片预览灯箱 const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); @@ -606,11 +654,30 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na setTriGenerating(false); } } - // 采用当前查看版本:标记为已采用 → 由该版本三视图作为商品图进 AI 素材库 + // 采用当前查看版本:标记为已采用 → 该版本三视图作为商品图进 AI 素材库,并联动素材卡状态 function adoptTriView() { if (!triViewing) return; setTriAdoptedId(triViewing.id); - if (triViewing.asset && onAdoptTriView) void Promise.resolve(onAdoptTriView(triViewing.asset)).then(() => setAssetReload((n) => n + 1)); + const adopted = triViewing.asset; + if (adopted) { + // 联动:被采用的三视图 → 通过(绿);该商品其余三视图 → 不通过(红)。其他类型素材不受影响。 + setStatusOverrides((prev) => { + const next = { ...prev }; + const triIds = new Set(); + for (const a of allAssetsBase) { + if (a.category === "tri_view" || a.asset_type === "tri_view") triIds.add(a.id); + } + triIds.add(adopted.id); + triIds.forEach((id) => { next[id] = id === adopted.id ? "pass" : "fail"; }); + return next; + }); + // prepend 新卡:若服务端素材列表里还没有这条采用资产,先本地置顶呈现(去重) + setAdoptedAssets((prev) => { + if (prev.some((a) => a.id === adopted.id) || allImageAssets.some((a) => a.id === adopted.id)) return prev; + return [adopted, ...prev]; + }); + } + if (adopted && onAdoptTriView) void Promise.resolve(onAdoptTriView(adopted)).then(() => setAssetReload((n) => n + 1)); } // 商品图网格 · 用后端内嵌的 preview_url(images[].preview_url / cover_preview_url),不再反查全局 assets @@ -624,13 +691,22 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na // AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product / // ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。 const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]); - const allImageAssets = productAssets.filter( + const serverImageAssets = productAssets.filter( (asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type) || asset.asset_type === "image" ); + // 采用三视图后本地 prepend 的新卡:置顶且与服务端列表去重(服务端回灌同 id 后即不再重复) + const serverIds = new Set(serverImageAssets.map((a) => a.id)); + const allImageAssets = [...adoptedAssets.filter((a) => !serverIds.has(a.id)), ...serverImageAssets]; + // 联动采用状态前的「全部素材基线」(供 adoptTriView 找出该商品所有三视图) + const allAssetsBase = allImageAssets; + // 单个素材当前显示状态:本地覆盖(点击循环 / 采用联动)优先,否则回退真实 review_status 映射 + const statusOf = (asset: Asset): PdAssetStatus => statusOverrides[asset.id] || pdBaseStatus(asset); // 类型筛选选项(当前素材里真实存在的 category) const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean))); + // 全集计数(给状态筛选下拉做计数前缀,不随当前筛选变化) const filteredAssets = allImageAssets .filter((asset) => !typeFilter || asset.category === typeFilter) + .filter((asset) => !statusFilter || statusOf(asset) === statusFilter) .slice() .sort((a, b) => { const cmp = (b.created_at || "").localeCompare(a.created_at || ""); @@ -638,6 +714,17 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na }); const assetCount = filteredAssets.length; const imageAssets = filteredAssets.slice(0, assetLimit); + const hasActiveAssetFilter = Boolean(typeFilter || statusFilter); + const resetAssetFilters = () => { setTypeFilter(""); setStatusFilter(""); setAssetLimit(12); setOpenFilter(""); }; + // 点击素材状态 pill 循环:通过 → 不通过 → 归档。三视图状态由「采用版本」决定,不在此循环(忠实移植 V1)。 + const cycleAssetStatus = (asset: Asset) => { + if (asset.category === "tri_view" || asset.asset_type === "tri_view") return; + setStatusOverrides((prev) => { + const cur = prev[asset.id] || pdBaseStatus(asset); + const nextStatus = PD_STATUS_CYCLE[(PD_STATUS_CYCLE.indexOf(cur) + 1) % PD_STATUS_CYCLE.length]; + return { ...prev, [asset.id]: nextStatus }; + }); + }; // 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序) const videoProjects = [...projects].sort((a, b) => { @@ -930,7 +1017,30 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na ))}
+ {/* 状态筛选 chip(通过/不通过/归档) */} +
+ +
+ {PD_STATUS_FILTER_OPTS.map((opt) => ( +
{ setStatusFilter(opt.value); setAssetLimit(12); setOpenFilter(""); }}> + {opt.label} +
+ ))} +
+
+ {/* 视图切换:网格 / 列表(黑激活) */} +
+ + +
) : ( -
+
{imageAssets.map((asset) => { const url = pdAssetPreview(asset); - const status: "pass" | "fail" | "archive" = "pass"; + const status = statusOf(asset); + const isTri = asset.category === "tri_view" || asset.asset_type === "tri_view"; return (
setPreview({ src: url, name: asset.name }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, name: asset.name }); } } : undefined}> @@ -971,7 +1088,20 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na {pdAssetTypeLabel(asset)} {url ? null : 3:4}
-
{PD_ASSET_STATUS_LABEL[status]}{(asset.created_at || "").slice(0, 10)}
+
+ {/* 状态 pill 三态可点循环;三视图状态由「采用版本」决定,此处不可点 */} + cycleAssetStatus(asset)} + onKeyDown={(event) => { if (!isTri && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); cycleAssetStatus(asset); } }} + >{PD_ASSET_STATUS_LABEL[status]} + {(asset.created_at || "").slice(0, 10)} +
); })} diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 16b60c1..815c893 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -368,8 +368,34 @@ function projBucket(project: Project) { return project.status === "completed" ? function projStatusLabel(project: Project) { return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record)[project.status] || "进行中"; } -function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "fail" : "info"; } +// 失败态用现成 .pill.err(crimson),不存在 .pill.fail → 退化无色 +function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; } function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); } +// 当前脚本版本:优先已采用,否则取第一版(轻量列表序列化可能不含 segments,故全程可选链) +function projScript(project: Project) { + const list = project.script_versions || []; + return list.find((v) => v.is_adopted) || list[0]; +} +// 真实镜数:优先后端片段计数 / 片段数组长度;均缺时回落脚本片段数,再回落 0(不造假) +function projShots(project: Project): number { + return project.video_segment_count ?? project.video_segments?.length ?? projScript(project)?.segments?.length ?? 0; +} +// 真实成片时长档:① 向导选的时长 id("0-15"→"0-15s") ② 脚本各镜时长求和("0-{n}s") ③ 视频片段目标时长求和 ④ 缺则空串(副标题只显示「N 镜」,不硬编码 0-60s) +function projDurationLabel(project: Project): string { + const wiz = project.metadata?.wizard?.duration; + if (wiz) return /s$/i.test(wiz) ? wiz : `${wiz}s`; + const scriptSecs = (projScript(project)?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0); + if (scriptSecs > 0) return `0-${Math.round(scriptSecs)}s`; + const videoSecs = (project.video_segments || []).reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0); + if (videoSecs > 0) return `0-${Math.round(videoSecs)}s`; + return ""; +} +// 副标题:N 镜[ · 时长档](真实数据,缺时长档则只显示镜数) +function projShotMeta(project: Project): string { + const shots = projShots(project); + const dur = projDurationLabel(project); + return dur ? `${shots} 镜 · ${dur}` : `${shots} 镜`; +} // 复刻 mock-media coverFor:按项目名关键词映射封面图(无匹配 → 占位) function projCover(name: string): string { const t = name.replace(/\s+/g, ""); @@ -403,10 +429,22 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op const [query, setQuery] = useState(""); const [editMode, setEditMode] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + // 批量管理:编辑态多选集合 + 批量删除确认开关。退出编辑态自动清空选择。 + const [selected, setSelected] = useState>(new Set()); + const [bulkDeleting, setBulkDeleting] = useState(false); + const [bulkConfirm, setBulkConfirm] = useState(false); useEffect(() => { document.body.classList.toggle("edit-mode", editMode); return () => document.body.classList.remove("edit-mode"); }, [editMode]); + useEffect(() => { if (!editMode) setSelected(new Set()); }, [editMode]); + function toggleSelect(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">(""); const [catFilter, setCatFilter] = useState(""); const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all"); @@ -478,6 +516,19 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op setDeleteTarget(null); } + // 批量删除选中项目:逐个调 onDelete(App 端每删一项做一次刷新),完成后清空选择并关确认框 + async function confirmBulkDelete() { + if (selected.size === 0 || bulkDeleting) return; + setBulkDeleting(true); + try { + for (const id of Array.from(selected)) await onDelete(id); + setSelected(new Set()); + setBulkConfirm(false); + } finally { + setBulkDeleting(false); + } + } + return (
@@ -587,37 +638,40 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op {pageItems.map((project) => { const no = projStageNo(project); - const shots = project.video_segment_count ?? project.video_segments?.length ?? 4; const cover = projCover(project.name); + const isSel = selected.has(project.id); return ( - openPipeline(project.id)}> + (editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
+ {editMode && ( + + )}
9:16
-
{project.name}
{shots} 镜 · 0-60s
+
{project.name}
{projShotMeta(project)}
{productTitle(project.product)} {(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}
-
{[1, 2, 3, 4, 5].map((i) => )}
+
{[1, 2, 3, 4, 5].map((i) => )}
{no}/5
{projStatusLabel(project)} {projDate(project)} -
- {editMode && ( - { event.preventDefault(); event.stopPropagation(); setDeleteTarget(project); }} title="删除项目"> - )} - { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"> - event.stopPropagation()}> - -
-
-
+ {/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */} + {!editMode && ( +
+ { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"> + event.stopPropagation()}> + +
+
+
+ )} ); @@ -628,12 +682,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op ) : (
{pageItems.map((project) => { const cover = projCover(project.name); + const no = projStageNo(project); + const isSel = selected.has(project.id); return ( -
openPipeline(project.id)}> +
(editMode ? toggleSelect(project.id) : openPipeline(project.id))}> +
9:16
-
{project.name}
-
{productTitle(project.product)}
+
+
{project.name}
+
{productTitle(project.product)} · {projShots(project)} 镜
+
+
+
{[1, 2, 3, 4, 5].map((i) => )}
+ {no}/5 +
{projStatusLabel(project)}{projDate(project)}
@@ -642,7 +705,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op )} {filtered.length === 0 && navigate("projectWizard")} />} + + {/* 批量管理栏 · 编辑态吸底浮动(显隐由 body.edit-mode 控)· 蓝本同 products-page */} +
+ 已选 {selected.size} + + + + +
+ setDeleteTarget(null)} onConfirm={confirmDelete} /> + setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
); } diff --git a/core/qa/visual-parity/_wave3-batch3.mjs b/core/qa/visual-parity/_wave3-batch3.mjs new file mode 100644 index 0000000..04bce60 --- /dev/null +++ b/core/qa/visual-parity/_wave3-batch3.mjs @@ -0,0 +1,76 @@ +// Wave 3 batch3 walkthrough (scratch): products 视图切换/多选 + projects 进度条/批量 + dashboard. +import { chromium } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; + +const BASE = process.env.BASE || "http://localhost:5173"; +const TOKEN = process.env.TOKEN || ""; +const OUT = path.resolve("../../../_qa_shots/wave3-batch3"); +fs.mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" }); +const seed = await ctx.newPage(); +await seed.goto(BASE + "/", { waitUntil: "domcontentloaded" }); +await seed.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN); +await seed.close(); + +const r = { products: {}, projects: {}, dashboard: {}, consoleErrors: [] }; + +async function page(route, tag) { + const p = await ctx.newPage(); + p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:` + m.text()); }); + p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:` + e.message)); + await p.goto(BASE + route, { waitUntil: "load" }); + await p.waitForTimeout(1800); + return p; +} + +// products +{ + const p = await page("/products", "products"); + r.products.viewTog = await p.locator(".view-tog").count(); + r.products.productCards = await p.locator(".product-card").count(); + await p.screenshot({ path: path.join(OUT, "products-grid.png") }); + // 切列表视图 + const listBtn = p.locator(".view-tog button").last(); + if (await listBtn.count()) { + await listBtn.click(); + await p.waitForTimeout(400); + r.products.listViewApplied = await p.locator(".product-grid.list-view").count(); + await p.screenshot({ path: path.join(OUT, "products-list.png") }); + } + // 分类多选 chip + r.products.catChip = await p.locator(".chip-wrap").count(); + await p.close(); +} + +// projects +{ + const p = await page("/projects", "projects"); + r.projects.progBars = await p.locator(".proj-card .prog, .prog").count(); + r.projects.projCards = await p.locator(".proj-card").count(); + await p.screenshot({ path: path.join(OUT, "projects-grid.png") }); + // 进编辑态(找含「编辑」/「批量」的按钮) + const editBtn = p.locator("button:has-text('编辑'), button:has-text('批量'), .btn-edit-toggle").first(); + if (await editBtn.count()) { + await editBtn.click(); + await p.waitForTimeout(400); + r.projects.cardChecks = await p.locator(".card-check, .row-check").count(); + await p.screenshot({ path: path.join(OUT, "projects-edit.png") }); + } + await p.close(); +} + +// dashboard +{ + const p = await page("/dashboard", "dashboard"); + r.dashboard.kpiStats = await p.locator(".stat, .stats .stat").count(); + r.dashboard.recentRows = await p.locator(".recent-row").count(); + await p.screenshot({ path: path.join(OUT, "dashboard.png") }); + await p.close(); +} + +await browser.close(); +console.log(JSON.stringify(r, null, 2)); +fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));