修改部份ui
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user