feat(core): Wave 3 batch3 — 商品库视图切换/多选/素材三态 + 项目进度条/批量 + 工作台真实KPI

商品库+详情(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 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 05:19:32 +08:00
co-authored by Claude Opus 4.8
parent 38f56cb037
commit da8c6a70a7
6 changed files with 410 additions and 45 deletions
+20
View File
@@ -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; } .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-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-wrap { margin: 0 -8px; padding: 2px 8px 24px; }
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } .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; } .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.active { background: var(--accent-black); color: var(--accent-white); }
.view-tog button svg { width: 13px; height: 13px; } .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(默认隐藏) */ /* 编辑模式 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 { 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; } .product-card .card-check svg { width: 11px; height: 11px; visibility: hidden; }
+45 -2
View File
@@ -21,9 +21,16 @@
/* ─── Grid view ─── */ /* ─── Grid view ─── */
.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } .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: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; } .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 { 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: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; } .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); } .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-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-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-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; } .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 下拉容器(默认收起)· chip 本体/caret 走全局 restraint .chip */
.chip-wrap { position: relative; } .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; }
+24 -5
View File
@@ -31,12 +31,31 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
const completed = projects.filter((project) => project.status === "completed").length; const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length; const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品"; 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 ( return (
<> <>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>{userName ? `${userName.split("@")[0]}` : ""}</h1> <h1>{userName ? `${userName.split("@")[0]}` : ""}</h1>
<div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div> <div className="sub"><span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div>
</div> </div>
<div className="actions"> <div className="actions">
<button className="btn" type="button" onClick={() => navigate("products")}> <button className="btn" type="button" onClick={() => navigate("products")}>
@@ -49,14 +68,14 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
</button> </button>
</div> </div>
</div> </div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={`↑ 本月 +${Math.max(projCount, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub"> {money(billing?.account.reserved_balance)}</div></button></div> <div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} /><KpiStat label="进行中" badge="WIP" value={running} /><KpiStat label="成片" badge="DONE" value={completed} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub"> {money(reservedNum)} / {money(fundsTotal)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div> <div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
</> </>
); );
} }
export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta: string }) { export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta?: string }) {
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div><div className="delta up">{delta}</div></div>; return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</div>;
} }
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) { export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
+150 -20
View File
@@ -93,11 +93,24 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择 // 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
const [createdName, setCreatedName] = useState<string | null>(null); const [createdName, setCreatedName] = useState<string | null>(null);
const [openChip, setOpenChip] = useState<"" | "cat" | "date">(""); const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
const [catFilter, setCatFilter] = useState(""); // 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
const [catFilter, setCatFilter] = useState<Set<string>>(new Set());
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all"); 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,创建时间为固定区间 // 筛选选项:商品分类来自真实 products,创建时间为固定区间
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[]; 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<string, number>);
const DATE_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [ const DATE_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [
{ value: "all", label: "全部时间" }, { value: "all", label: "全部时间" },
{ value: "7", label: "近 7 天" }, { value: "7", label: "近 7 天" },
@@ -124,7 +137,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
const filtered = products.filter((product) => { const filtered = products.filter((product) => {
if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除 if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除
const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase()); 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; let matchDate = true;
if (dateFilter !== "all" && product.created_at) { if (dateFilter !== "all" && product.created_at) {
const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000; 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 页 // 分页:每页 10 个,搜索/筛选变化回第 1 页
const [page, setPage] = useState(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 totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
const curPage = Math.min(page, totalPages); const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE); 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
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} /> <input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
</div> </div>
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat"> <div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}> <button className={`chip${catFilter.size ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
<span className="chip-label">{catFilter || "商品分类"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg> <span className="chip-label"></span>
{catFilter.size > 0 && <span className="chip-count">{catFilter.size}</span>}
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button> </button>
<div className="chip-menu"> <div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}> {/* 全部分类 = 清空多选(不关菜单,方便连续多选) */}
<div className={`mi mi-all${catFilter.size === 0 ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => setCatFilter(new Set())}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg> <svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div> </div>
{categories.length > 0 && <div className="mi-sep" />} {categories.length > 0 && <div className="mi-sep" />}
{categories.map((cat) => ( {categories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}> <div className={`mi${catFilter.has(cat) ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => toggleCat(cat)}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat} <svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
<span className="cat-count">{catCounts[cat] || 0}</span>
</div> </div>
))} ))}
</div> </div>
@@ -192,8 +210,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
))} ))}
</div> </div>
</div> </div>
{(query || catFilter || dateFilter !== "all") && ( {(query || catFilter.size > 0 || dateFilter !== "all") && (
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(""); setDateFilter("all"); setOpenChip(""); }}> <button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setOpenChip(""); }}>
<X size={13} /> <X size={13} />
</button> </button>
)} )}
@@ -201,13 +219,21 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
<div className="result-meta" id="result-meta"> <div className="result-meta" id="result-meta">
<span>// {loading && products.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</>}</span> <span>// {loading && products.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</>}</span>
<div className="view-tog" style={{ marginLeft: "auto" }} id="view-tog" role="group" aria-label="视图切换">
<button type="button" className={view === "grid" ? "active" : ""} aria-pressed={view === "grid"} title="网格视图" onClick={() => setView("grid")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
</button>
<button type="button" className={view === "list" ? "active" : ""} aria-pressed={view === "list"} title="列表视图" onClick={() => setView("list")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
</button>
</div>
</div> </div>
{loading && products.length === 0 ? ( {loading && products.length === 0 ? (
<SkeletonGrid count={8} /> <SkeletonGrid count={8} />
) : ( ) : (
<div className="product-grid-wrap"> <div className="product-grid-wrap">
<div className="product-grid" id="product-grid"> <div className={`product-grid${view === "list" ? " list-view" : ""}`} id="product-grid">
{pageItems.map((product) => ( {pageItems.map((product) => (
<ProductCard <ProductCard
key={product.id} key={product.id}
@@ -475,7 +501,23 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。 // 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡 // 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡
// 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。 // 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。
const PD_ASSET_STATUS_LABEL: Record<"pass" | "fail" | "archive", string> = { pass: "通过", fail: "不通过", archive: "归档" }; type PdAssetStatus = "pass" | "fail" | "archive";
const PD_ASSET_STATUS_LABEL: Record<PdAssetStatus, string> = { 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", "个护清洁", "运动户外", "母婴亲子"]; const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
@@ -531,12 +573,18 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const [triAdoptedId, setTriAdoptedId] = useState<string>(""); const [triAdoptedId, setTriAdoptedId] = useState<string>("");
const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0]; const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0];
const triUrl = triViewing?.url || ""; const triUrl = triViewing?.url || "";
// 素材 tab 筛选 / 排序 / 分页 // 素材 tab 筛选 / 排序 / 分页 / 视图
const [openFilter, setOpenFilter] = useState<"" | "type" | "sort">(""); const [openFilter, setOpenFilter] = useState<"" | "type" | "status" | "sort">("");
const [typeFilter, setTypeFilter] = useState(""); const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"" | "pass" | "fail" | "archive">("");
const [assetView, setAssetView] = useState<"grid" | "list">("grid");
const [assetSortDesc, setAssetSortDesc] = useState(true); const [assetSortDesc, setAssetSortDesc] = useState(true);
const [assetLimit, setAssetLimit] = useState(12); const [assetLimit, setAssetLimit] = useState(12);
const [videoSortDesc, setVideoSortDesc] = useState(true); const [videoSortDesc, setVideoSortDesc] = useState(true);
// 素材审核状态本地覆盖(点击循环 / 采用联动写入);未覆盖则回退真实 review_status 映射
const [statusOverrides, setStatusOverrides] = useState<Record<string, "pass" | "fail" | "archive">>({});
// 采用三视图后,本地 prepend 的新素材卡(供「采用版本联动 + prepend 新卡」即时呈现,不等服务端回灌)
const [adoptedAssets, setAdoptedAssets] = useState<Asset[]>([]);
// 图片预览灯箱 // 图片预览灯箱
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
@@ -606,11 +654,30 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
setTriGenerating(false); setTriGenerating(false);
} }
} }
// 采用当前查看版本:标记为已采用 → 该版本三视图作为商品图进 AI 素材库 // 采用当前查看版本:标记为已采用 → 该版本三视图作为商品图进 AI 素材库,并联动素材卡状态
function adoptTriView() { function adoptTriView() {
if (!triViewing) return; if (!triViewing) return;
setTriAdoptedId(triViewing.id); 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<string>();
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 // 商品图网格 · 用后端内嵌的 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 / // AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。 // ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]); 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" (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) // 类型筛选选项(当前素材里真实存在的 category)
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean))); const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
// 全集计数(给状态筛选下拉做计数前缀,不随当前筛选变化)
const filteredAssets = allImageAssets const filteredAssets = allImageAssets
.filter((asset) => !typeFilter || asset.category === typeFilter) .filter((asset) => !typeFilter || asset.category === typeFilter)
.filter((asset) => !statusFilter || statusOf(asset) === statusFilter)
.slice() .slice()
.sort((a, b) => { .sort((a, b) => {
const cmp = (b.created_at || "").localeCompare(a.created_at || ""); 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 assetCount = filteredAssets.length;
const imageAssets = filteredAssets.slice(0, assetLimit); 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 渲染真实项目名 / 状态 / 阶段(按更新时间排序) // 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序)
const videoProjects = [...projects].sort((a, b) => { const videoProjects = [...projects].sort((a, b) => {
@@ -930,7 +1017,30 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
))} ))}
</div> </div>
</div> </div>
{/* 状态筛选 chip(通过/不通过/归档) */}
<div className={`chip-wrap${openFilter === "status" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="status">
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "status" ? "" : "status"))}>
{PD_STATUS_FILTER_OPTS.find((o) => o.value === statusFilter)?.label || "全部状态"}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{PD_STATUS_FILTER_OPTS.map((opt) => (
<div className={`mi${statusFilter === opt.value ? " selected" : ""}`} key={opt.value || "all"} role="button" tabIndex={0} onClick={() => { setStatusFilter(opt.value); setAssetLimit(12); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<div className="right"> <div className="right">
{/* 视图切换:网格 / 列表(黑激活) */}
<div className="view-tog" role="group" aria-label="素材视图切换">
<button type="button" className={assetView === "grid" ? "active" : ""} aria-pressed={assetView === "grid"} title="网格视图" onClick={() => setAssetView("grid")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
</button>
<button type="button" className={assetView === "list" ? "active" : ""} aria-pressed={assetView === "list"} title="列表视图" onClick={() => setAssetView("list")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
</button>
</div>
<div className={`chip-wrap${openFilter === "sort" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="sort"> <div className={`chip-wrap${openFilter === "sort" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="sort">
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "sort" ? "" : "sort"))}> <button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "sort" ? "" : "sort"))}>
{assetSortDesc ? "最新生成" : "最早生成"} {assetSortDesc ? "最新生成" : "最早生成"}
@@ -950,20 +1060,27 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
{assetsLoading && assetCount === 0 ? ( {assetsLoading && assetCount === 0 ? (
<SkeletonGrid count={6} /> <SkeletonGrid count={6} />
) : assetCount === 0 && hasActiveAssetFilter ? (
// 有筛选但无结果 → 空筛选提示 + 重置入口(忠实移植 .empty-filter)
<div className="empty-filter">
// 当前筛选下没有匹配的素材
<span className="reset" role="button" tabIndex={0} onClick={resetAssetFilters} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); resetAssetFilters(); } }}></span>
</div>
) : assetCount === 0 ? ( ) : assetCount === 0 ? (
<div className="empty-state show pd-empty-assets"> <div className="empty-state show pd-empty-assets">
<div className="ic-empty"> <div className="ic-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
</div> </div>
<h3>{typeFilter ? "该类型暂无 AI 素材" : "还没有 AI 素材"}</h3> <h3> AI </h3>
<p>// 用右侧「快速操作」生成模特上身图 / 平台套图 / 商品三视图</p> <p>// 用右侧「快速操作」生成模特上身图 / 平台套图 / 商品三视图</p>
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}></button> <button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}></button>
</div> </div>
) : ( ) : (
<div className="asset-grid"> <div className={`asset-grid${assetView === "list" ? " list-view" : ""}`}>
{imageAssets.map((asset) => { {imageAssets.map((asset) => {
const url = pdAssetPreview(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 ( return (
<div className="asset-card" key={asset.id}> <div className="asset-card" key={asset.id}>
<div className="thumb placeholder" role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} style={url ? { cursor: "zoom-in" } : undefined} onClick={url ? () => 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}> <div className="thumb placeholder" role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} style={url ? { cursor: "zoom-in" } : undefined} onClick={url ? () => 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
<span className="type-pill">{pdAssetTypeLabel(asset)}</span> <span className="type-pill">{pdAssetTypeLabel(asset)}</span>
{url ? null : <span className="ph-frame">3:4</span>} {url ? null : <span className="ph-frame">3:4</span>}
</div> </div>
<div className="meta"><span className={`pill ${status}`} data-status={status}>{PD_ASSET_STATUS_LABEL[status]}</span><span className="date">{(asset.created_at || "").slice(0, 10)}</span></div> <div className="meta">
{/* 状态 pill 三态可点循环;三视图状态由「采用版本」决定,此处不可点 */}
<span
className={`pill ${status}`}
data-status={status}
role={isTri ? undefined : "button"}
tabIndex={isTri ? undefined : 0}
title={isTri ? "三视图状态由采用版本决定" : "点击切换状态"}
style={isTri ? { cursor: "default" } : undefined}
onClick={() => cycleAssetStatus(asset)}
onKeyDown={(event) => { if (!isTri && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); cycleAssetStatus(asset); } }}
>{PD_ASSET_STATUS_LABEL[status]}</span>
<span className="date">{(asset.created_at || "").slice(0, 10)}</span>
</div>
</div> </div>
); );
})} })}
+95 -18
View File
@@ -368,8 +368,34 @@ function projBucket(project: Project) { return project.status === "completed" ?
function projStatusLabel(project: Project) { function projStatusLabel(project: Project) {
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中"; return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[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); } 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:按项目名关键词映射封面图(无匹配 → 占位) // 复刻 mock-media coverFor:按项目名关键词映射封面图(无匹配 → 占位)
function projCover(name: string): string { function projCover(name: string): string {
const t = name.replace(/\s+/g, ""); const t = name.replace(/\s+/g, "");
@@ -403,10 +429,22 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null); const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
// 批量管理:编辑态多选集合 + 批量删除确认开关。退出编辑态自动清空选择。
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [bulkConfirm, setBulkConfirm] = useState(false);
useEffect(() => { useEffect(() => {
document.body.classList.toggle("edit-mode", editMode); document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode"); return () => document.body.classList.remove("edit-mode");
}, [editMode]); }, [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 [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
const [catFilter, setCatFilter] = useState(""); const [catFilter, setCatFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all"); const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
@@ -478,6 +516,19 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
setDeleteTarget(null); 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 ( return (
<section className="projects-page"> <section className="projects-page">
<div className="page-head"> <div className="page-head">
@@ -587,37 +638,40 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<tbody id="list-tbody"> <tbody id="list-tbody">
{pageItems.map((project) => { {pageItems.map((project) => {
const no = projStageNo(project); const no = projStageNo(project);
const shots = project.video_segment_count ?? project.video_segments?.length ?? 4;
const cover = projCover(project.name); const cover = projCover(project.name);
const isSel = selected.has(project.id);
return ( return (
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} onClick={() => openPipeline(project.id)}> <tr key={project.id} data-status={projBucket(project)} data-name={project.name} className={editMode && isSel ? "row-selected" : undefined} onClick={() => (editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
<td> <td>
<div className="proj-name-cell"> <div className="proj-name-cell">
{editMode && (
<span className={`row-check${isSel ? " on" : ""}`} aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
)}
<div className={`placeholder proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div> <div className={`placeholder proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div><div className="proj-name">{project.name}</div><div className="proj-sub">{shots} · 0-60s</div></div> <div><div className="proj-name">{project.name}</div><div className="proj-sub">{projShotMeta(project)}</div></div>
</div> </div>
</td> </td>
<td>{productTitle(project.product)}</td> <td>{productTitle(project.product)}</td>
<td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td> <td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
<td> <td>
<div className="hstack"> <div className="hstack">
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div> <div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={project.status === "failed" && i === no ? "fail" : i < no ? "done" : i === no ? "cur" : ""} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "12px" }}>{no}/5</span> <span className="muted-2 mono" style={{ fontSize: "12px" }}>{no}/5</span>
</div> </div>
</td> </td>
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td> <td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
<td className="muted-2">{projDate(project)}</td> <td className="muted-2">{projDate(project)}</td>
<td> <td>
<div className="row-action"> {/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
{editMode && ( {!editMode && (
<a href="#" className="row-del" onClick={(event) => { event.preventDefault(); event.stopPropagation(); setDeleteTarget(project); }} title="删除项目"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" width="14" height="14"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></a> <div className="row-action">
)} <a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a> <span className="row-more" onClick={(event) => event.stopPropagation()}>
<span className="row-more" onClick={(event) => event.stopPropagation()}> <svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg> <div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button></div>
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button></div> </span>
</span> </div>
</div> )}
</td> </td>
</tr> </tr>
); );
@@ -628,12 +682,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
) : ( ) : (
<div className="proj-grid">{pageItems.map((project) => { <div className="proj-grid">{pageItems.map((project) => {
const cover = projCover(project.name); const cover = projCover(project.name);
const no = projStageNo(project);
const isSel = selected.has(project.id);
return ( return (
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}> <article className={`proj-card${editMode && isSel ? " selected" : ""}`} key={project.id} onClick={() => (editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
<div className={`placeholder card-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div> <div className={`placeholder card-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="card-body"> <div className="card-body">
<div className="card-name">{project.name}</div> <div>
<div className="card-sub">{productTitle(project.product)}</div> <div className="card-name">{project.name}</div>
<div className="card-sub">{productTitle(project.product)} · {projShots(project)} </div>
</div>
<div className="hstack">
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={project.status === "failed" && i === no ? "fail" : i < no ? "done" : i === no ? "cur" : ""} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "10.5px" }}>{no}/5</span>
</div>
<div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div> <div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div>
</div> </div>
</article> </article>
@@ -642,7 +705,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
)} )}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} /> <Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />} {filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
{/* 批量管理栏 · 编辑态吸底浮动(显隐由 body.edit-mode 控)· 蓝本同 products-page */}
<div className="bulk-bar" role="toolbar" aria-label="批量管理">
<span className="ct"> <b>{selected.size}</b> </span>
<button className="clear-sel" type="button" disabled={selected.size === 0} onClick={() => setSelected(new Set())}></button>
<span className="sep" />
<button className="danger" type="button" disabled={selected.size === 0 || bulkDeleting} onClick={() => setBulkConfirm(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
{bulkDeleting ? "删除中…" : "删除所选"}
</button>
<button type="button" onClick={() => setEditMode(false)}></button>
</div>
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} /> <ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
<ConfirmModal open={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
</section> </section>
); );
} }
+76
View File
@@ -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));