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:
co-authored by
Claude Opus 4.8
parent
38f56cb037
commit
da8c6a70a7
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<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 className="actions">
|
||||
<button className="btn" type="button" onClick={() => navigate("products")}>
|
||||
@@ -49,14 +68,14 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
|
||||
</button>
|
||||
</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="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="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 全生", 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 }) {
|
||||
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>;
|
||||
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>{delta ? <div className="delta up">{delta}</div> : null}</div>;
|
||||
}
|
||||
|
||||
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
|
||||
|
||||
@@ -93,11 +93,24 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
|
||||
const [createdName, setCreatedName] = useState<string | null>(null);
|
||||
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");
|
||||
// 视图切换:网格 / 列表(.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<string, number>);
|
||||
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
|
||||
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
|
||||
<button className={`chip${catFilter ? " 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>
|
||||
<button className={`chip${catFilter.size ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
|
||||
<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>
|
||||
<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>全部分类
|
||||
</div>
|
||||
{categories.length > 0 && <div className="mi-sep" />}
|
||||
{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}
|
||||
<span className="cat-count">{catCounts[cat] || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -192,8 +210,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(query || catFilter || dateFilter !== "all") && (
|
||||
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(""); setDateFilter("all"); setOpenChip(""); }}>
|
||||
{(query || catFilter.size > 0 || dateFilter !== "all") && (
|
||||
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setOpenChip(""); }}>
|
||||
<X size={13} /> 清空筛选
|
||||
</button>
|
||||
)}
|
||||
@@ -201,13 +219,21 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
|
||||
<div className="result-meta" id="result-meta">
|
||||
<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>
|
||||
|
||||
{loading && products.length === 0 ? (
|
||||
<SkeletonGrid count={8} />
|
||||
) : (
|
||||
<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) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
@@ -475,7 +501,23 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
|
||||
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
|
||||
// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡
|
||||
// 已接入真实数据(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", "个护清洁", "运动户外", "母婴亲子"];
|
||||
|
||||
@@ -531,12 +573,18 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
const [triAdoptedId, setTriAdoptedId] = useState<string>("");
|
||||
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<Record<string, "pass" | "fail" | "archive">>({});
|
||||
// 采用三视图后,本地 prepend 的新素材卡(供「采用版本联动 + prepend 新卡」即时呈现,不等服务端回灌)
|
||||
const [adoptedAssets, setAdoptedAssets] = useState<Asset[]>([]);
|
||||
// 图片预览灯箱
|
||||
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<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
|
||||
@@ -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
|
||||
))}
|
||||
</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="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">
|
||||
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "sort" ? "" : "sort"))}>
|
||||
{assetSortDesc ? "最新生成" : "最早生成"}
|
||||
@@ -950,20 +1060,27 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
|
||||
{assetsLoading && assetCount === 0 ? (
|
||||
<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 ? (
|
||||
<div className="empty-state show pd-empty-assets">
|
||||
<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>
|
||||
</div>
|
||||
<h3>{typeFilter ? "该类型暂无 AI 素材" : "还没有 AI 素材"}</h3>
|
||||
<h3>还没有 AI 素材</h3>
|
||||
<p>// 用右侧「快速操作」生成模特上身图 / 平台套图 / 商品三视图</p>
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}>去生成素材</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-grid">
|
||||
<div className={`asset-grid${assetView === "list" ? " list-view" : ""}`}>
|
||||
{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 (
|
||||
<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}>
|
||||
@@ -971,7 +1088,20 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
<span className="type-pill">{pdAssetTypeLabel(asset)}</span>
|
||||
{url ? null : <span className="ph-frame">3:4</span>}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<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); }
|
||||
// 当前脚本版本:优先已采用,否则取第一版(轻量列表序列化可能不含 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<Project | null>(null);
|
||||
// 批量管理:编辑态多选集合 + 批量删除确认开关。退出编辑态自动清空选择。
|
||||
const [selected, setSelected] = useState<Set<string>>(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 (
|
||||
<section className="projects-page">
|
||||
<div className="page-head">
|
||||
@@ -587,37 +638,40 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
<tbody id="list-tbody">
|
||||
{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 (
|
||||
<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>
|
||||
<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><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>
|
||||
</td>
|
||||
<td>{productTitle(project.product)}</td>
|
||||
<td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
|
||||
<td>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
|
||||
<td className="muted-2">{projDate(project)}</td>
|
||||
<td>
|
||||
<div className="row-action">
|
||||
{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>
|
||||
)}
|
||||
<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()}>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
{/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
|
||||
{!editMode && (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -628,12 +682,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
) : (
|
||||
<div className="proj-grid">{pageItems.map((project) => {
|
||||
const cover = projCover(project.name);
|
||||
const no = projStageNo(project);
|
||||
const isSel = selected.has(project.id);
|
||||
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="card-body">
|
||||
<div className="card-name">{project.name}</div>
|
||||
<div className="card-sub">{productTitle(project.product)}</div>
|
||||
<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>
|
||||
</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} />
|
||||
{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={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user