修改部份ui

This commit is contained in:
Azmat@qq.com
2026-08-18 18:32:19 +08:00
parent dfe94a923e
commit 18b3927817
19 changed files with 2151 additions and 681 deletions
+255 -199
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowUpRight, Grid2X2, List, Search } from "lucide-react";
import type { Asset, Product, Project, ScriptTemplate } from "../types";
import { api } from "../api";
import type { Page } from "./route-config";
@@ -11,7 +12,7 @@ import { SkeletonRows } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
const PROJ_PAGE_SIZE = 8; // 4 列网格两行
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
@@ -380,7 +381,12 @@ function projProgClass(project: Project, i: number, no: number): string {
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
return i < no ? "done" : i === no ? "cur" : "";
}
function projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
function projBucket(project: Project) {
if (project.status === "completed") return "done";
if (project.status === "failed") return "fail";
if (project.status === "draft") return "draft";
return "wip";
}
function projStatusLabel(project: Project) {
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
}
@@ -416,9 +422,53 @@ function projShotMeta(project: Project): string {
// (原先按项目名关键词映射静态 mock 假图,与真实数据脱节,已废弃。)
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" }
const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" },
{ filter: "draft", label: "草稿" },
{ filter: "wip", label: "生成中" },
{ filter: "done", label: "已完成" },
{ filter: "fail", label: "失败" },
];
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频。", page: "projectWizard", image: "/exact/assets/mock/person-linxi.png" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成。", page: "projectWizard", image: "/exact/assets/mock/scene-cafe.png" },
];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词。", page: "freeCreate", image: "/exact/assets/mock/scene-night-street.png" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品。", page: "freeCreate", image: "/exact/assets/mock/cover-coffee.png" },
{ title: "自由生成", desc: "用提示词与参考视频自由生成镜头。", page: "freeCreate", image: "/exact/assets/mock/scene-bedroom.png" },
];
function projCardSub(project: Project): string {
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
return projStageLabel(project);
}
function projCardCta(project: Project): string {
if (project.status === "failed") return "重新拍摄";
if (project.status === "completed") return "查看";
return "继续";
}
// 项目卡「当前阶段」文案(对齐 YZ 稿:资产创建 / 视频生成)
function projStageLabel(project: Project): string {
if (project.status === "failed") return "失败";
const map: Record<string, string> = {
script: "脚本",
base_assets: "资产创建",
storyboard: "故事板",
video: "视频生成",
export: "视频生成",
};
return map[project.current_stage] || "视频生成";
}
function projCardMeta(project: Project, productTitle: string): string {
const shots = projShots(project);
return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
}
function projRowCta(project: Project): string {
return project.status === "completed" ? "查看" : "继续";
}
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete, initialTab }: {
products: Product[];
@@ -431,12 +481,12 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
// 工作台统计块点击带入的初始 tab(all/wip/done/fail);变化时覆盖当前 tab。
initialTab?: string;
}) {
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "grid");
const [tab, setTab] = useState<ProjTab>("all");
// initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。
useEffect(() => {
if (initialTab && ["all", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as "all" | "wip" | "done" | "fail");
if (initialTab && ["all", "draft", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as ProjTab);
}
}, [initialTab]);
const [query, setQuery] = useState("");
@@ -460,28 +510,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
}
// 成片播放:合成过的项目点播放键直接弹窗播成片,不再跳去视频生成页
const [playing, setPlaying] = useState<Project | null>(null);
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
const [openChip, setOpenChip] = useState<"" | "product" | "time">("");
const [catFilter, setCatFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
const [timeFilter, setTimeFilter] = useState<"all" | "7" | "30" | "90">("all");
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const productCat = (id: string) => products.find((product) => product.id === id)?.category || "";
// 筛选选项(全部来自真实数据)
const projectCategories = Array.from(new Set(projects.map((p) => productCat(p.product)).filter(Boolean))) as string[];
const SRC_OPTS: Array<{ value: "all" | "has" | "none"; label: string }> = [
{ value: "all", label: "全部来源" },
{ value: "has", label: "AI 已生成脚本" },
{ value: "none", label: "暂无脚本" }
];
const TIME_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [
{ value: "all", label: "全部时间" },
{ value: "7", label: "近 7 天" },
{ value: "30", label: "近 30 天" },
{ value: "90", label: "近 90 天" }
];
const srcLabel = SRC_OPTS.find((o) => o.value === sourceFilter)?.label || "脚本来源";
const timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "创建时间";
const timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间";
useEffect(() => {
if (!openChip) return;
@@ -492,21 +535,10 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
return () => document.removeEventListener("click", close);
}, [openChip]);
const counts = {
all: projects.length,
wip: projects.filter((p) => projBucket(p) === "wip").length,
done: projects.filter((p) => projBucket(p) === "done").length,
fail: projects.filter((p) => projBucket(p) === "fail").length
};
const filtered = projects.filter((project) => {
if (tab !== "all" && projBucket(project) !== tab) return false;
if (!`${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase())) return false;
if (catFilter && productCat(project.product) !== catFilter) return false;
if (sourceFilter !== "all") {
const hasScript = (project.script_version_count ?? project.script_versions?.length ?? 0) > 0;
if (sourceFilter === "has" && !hasScript) return false;
if (sourceFilter === "none" && hasScript) return false;
}
if (timeFilter !== "all" && project.created_at) {
const days = (Date.now() - new Date(project.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
@@ -520,7 +552,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
});
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, catFilter, sourceFilter, timeFilter]);
useEffect(() => { setPage(1); }, [tab, query, catFilter, timeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROJ_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROJ_PAGE_SIZE, curPage * PROJ_PAGE_SIZE);
@@ -546,192 +578,216 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
return (
<section className="projects-page">
<div className="page-head">
<div>
<h1>视频项目</h1>
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="proj-manage-btn" onClick={() => setEditMode((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="proj-manage-label">{editMode ? "完成" : "管理项目"}</span>
</button>
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m12.3 3.5 3 4" /><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" /><path d="m6.2 5.3 3.1 3.9" /><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>
新建项目
</button>
</div>
</div>
<header className="vc-head">
<h1>视频创作</h1>
<p>从商品出发,一站完成电商短视频生产</p>
</header>
<div className="tabs" id="status-tabs">
{PROJ_TABS.map((t) => (
<div className={`tab${tab === t.filter ? " active" : ""}`} key={t.filter} data-filter={t.filter} onClick={() => setTab(t.filter)}>{t.label} <span className="count">{counts[t.filter]}</span></div>
))}
</div>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} />
<section className="vc-sec">
<div className="vc-sec-h">
<h2>商品视频生产</h2>
<p>上传商品即可生成带货短视频</p>
</div>
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<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>
<div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
<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 className="vc-row cols-2">
{VIDEO_MAKE.map((card) => (
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-visual"><img src={card.image} alt="" /></div>
<div className="vc-copy">
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
</div>
</button>
))}
</div>
</section>
<section className="vc-sec">
<div className="vc-sec-h">
<h2>AI 视频工具</h2>
<p>复刻参考片、替换商品或自由生成</p>
</div>
<div className="vc-row cols-3">
{VIDEO_TOOLS.map((card) => (
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-visual"><img src={card.image} alt="" /></div>
<div className="vc-copy">
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
</div>
</button>
))}
</div>
</section>
<section className="vc-projects">
<div className="vc-sec-h">
<h2>视频项目</h2>
<p>查看视频任务状态与生成结果</p>
</div>
<div className="vc-toolbar">
<div className="vc-pills" role="tablist" aria-label="项目状态">
{PROJ_TABS.map((t) => (
<button
type="button"
role="tab"
aria-selected={tab === t.filter}
className={`vc-pill${tab === t.filter ? " active" : ""}`}
key={t.filter}
onClick={() => setTab(t.filter)}
>
{t.label}
</button>
))}
</div>
<div className="vc-toolbar-right">
<div className="vc-search">
<Search size={14} />
<input placeholder="搜索项目名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{projectCategories.length > 0 && <div className="mi-sep" />}
{projectCategories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
<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}
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{timeLabel}</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">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<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={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<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>
<div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
<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>
{projectCategories.length > 0 && <div className="mi-sep" />}
{projectCategories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
<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}
</div>
))}
</div>
</div>
<div className="vc-view">
<button type="button" className={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
<Grid2X2 size={14} />
</button>
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
<List size={14} />
</button>
</div>
</div>
</div>
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source">
<button className={`chip${sourceFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span className="chip-label">{sourceFilter === "all" ? "脚本来源" : srcLabel}</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">
{SRC_OPTS.map((opt) => (
<div className={`mi${sourceFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setSourceFilter(opt.value); setOpenChip(""); }}>
<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={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{timeFilter === "all" ? "创建时间" : timeLabel}</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">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<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>
{(query || catFilter || sourceFilter !== "all" || timeFilter !== "all" || tab !== "all") && (
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(""); setSourceFilter("all"); setTimeFilter("all"); setTab("all"); setOpenChip(""); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg> 清空筛选
</button>
)}
<span className="spacer"></span>
<div className="view-toggle">
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="2" width="5" height="5" /><rect x="9" y="2" width="5" height="5" /><rect x="2" y="9" width="5" height="5" /><rect x="9" y="9" width="5" height="5" /></svg>
网格
</button>
<button className={view === "list" ? "active" : ""} type="button" data-view="list" onClick={() => setView("list")}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 4h12M2 8h12M2 12h12" /></svg>
列表
</button>
</div>
</div>
<div className="result-meta" id="result-meta">// {loading && projects.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</>}</div>
{loading && projects.length === 0 ? (
<SkeletonRows count={6} />
) : view === "list" ? (
<div id="list-view">
<table className="t">
<thead>
<tr>
<th style={{ width: "32%" }}>项目</th>
<th>商品</th>
<th>脚本来源</th>
<th style={{ width: "200px" }}>进度</th>
<th>状态</th>
<th style={{ width: "120px" }}>更新于</th>
<th style={{ width: "60px" }}></th>
</tr>
</thead>
<tbody id="list-tbody">
{pageItems.map((project) => {
const no = projStageNo(project);
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
return (
<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 ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></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">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "11px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
</div>
</td>
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
<td className="muted-2">{projDate(project)}</td>
<td>
{/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
{!editMode && (
<div className="row-action">
{/* 合成过成片 → 直接弹窗播成片;没合成过 → 照旧进流水线继续做 */}
<a
href="#"
title={project.final_video_url ? "播放成片" : "继续"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
}}
><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>
);
})}
</tbody>
</table>
) : view === "grid" ? (
<div className="vc-grid">
{pageItems.map((project) => {
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const bucket = projBucket(project);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
className={`vc-proj${editMode && isSel ? " selected" : ""}`}
key={project.id}
role="button"
tabIndex={0}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
>
<div className={`vc-proj-visual${cover ? " has-img" : ""}`}>
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
</div>
<div className="vc-proj-body">
<div className="vc-proj-name">{project.name}</div>
<div className="vc-proj-sub">{projCardSub(project)}</div>
<div className="vc-proj-foot">
<span className={`vc-tag ${bucket}`}>{bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中"}</span>
<button
className="vc-proj-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{projCardCta(project)}
</button>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="proj-grid">{pageItems.map((project) => {
const cover = project.cover_preview_url || "";
const no = projStageNo(project);
const isSel = selected.has(project.id);
return (
<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 ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="card-body">
<div>
<div className="card-name">{project.name}</div>
<div className="card-sub">{productTitle(project.product)} · {projShots(project)} 镜</div>
<div className="proj-card-list" id="list-view">
{pageItems.map((project) => {
const no = projStageNo(project);
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
key={project.id}
className={`proj-card-row${editMode && isSel ? " selected" : ""}`}
data-status={projBucket(project)}
data-name={project.name}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
{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-card-row-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="proj-card-row-main">
<div className="proj-card-row-title">{project.name}</div>
<div className="proj-card-row-sub">{projCardMeta(project, productTitle(project.product))}</div>
</div>
<div className="hstack">
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "10.5px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
<div className="proj-card-row-stage">
<div className="proj-card-row-stage-lbl">当前阶段</div>
<div className="proj-card-row-stage-name">{projStageLabel(project)}</div>
<div className="prog proj-prog-yz">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
</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>
);
})}</div>
{!editMode && (
<button
className="proj-card-row-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{projRowCta(project)}
</button>
)}
{!editMode && (
<span className="row-more proj-card-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>
)}
</article>
);
})}
</div>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
{filtered.length === 0 && !loading && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
</section>
{/* 批量管理栏 · 吸底浮动(显隐绑「有无选中」)· 蓝本同 products-page */}
<div className={`bulk-bar${selected.size > 0 ? " show" : ""}`} 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>