Files
yingqing/core/frontend/src/routes/projects.tsx
T
zycandClaude Opus 4.8 f7a403ead3 fix(core): 测试清单 行16-33 — 商品详情/视频项目/新建向导/图片生成 批量修复
商品库+详情(行16-23):AI素材空状态缺省页;三视图16:9+版本历史切换+采用按钮;
详情「生成视频」预勾选商品、快捷生成图带入商品;管理商品编辑态保留底部统计;
删除商品 icon+DELETE ITEM 文案、乐观删除防重复点击;卡片图铺满占位区。

视频项目+新建向导(行24-28):筛选脚本来源按真实数据渲染;新建项目内「创建新商品」
右侧抽屉+创建后自动选中;开始栏 sticky 常驻;默认选中商品即显示卖点、未填完禁用开始;
去掉脚本风格/人物设定区块。

图片生成工作台(行29-33):模特单选;生成中改 spinner 动态图标+按钮间距;多任务并行;
重跑追加批次而非覆盖+更多按钮 hover 气泡菜单+「加入资产库/取消」切换与提示弹窗;
多批次本地持久化恢复。

收尾:App.tsx 接 initialProductId/preselectProductId/onCreateProduct/onAdoptTriView;
全局 toast 自动消失(成功3s/错误5s)修复行17③/22「右上角提示一直挂着」。

tsc -b && vite build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:10:26 +08:00

711 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { ConfirmModal, Drawer, EmptyPanel } from "../components/overlays";
import { Pager } from "../components/pager";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
const WIZ_DURATIONS = [
{ id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "快速种草", note: "适合快速种草" },
{ id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "精准短片", note: "适合短平快投放" },
{ id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点展开", note: "适合卖点展开" },
{ id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化", note: "适合故事化表达" }
];
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
// 新建商品抽屉品类选项(与 products.tsx 一致;本组件自洽,不依赖 products.tsx)
const WIZ_PC_CATS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
type WizProductPayload = {
title: string;
category: string;
target_audience?: string;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
};
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
products: Product[];
projects?: Project[];
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
preselectProductId?: string;
onBack: () => void;
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
// 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
}) {
// 预选 id 命中商品库时用它,否则默认第一个商品
const initialProductId =
(preselectProductId && products.some((p) => p.id === preselectProductId) && preselectProductId) ||
products[0]?.id || "";
const [productId, setProductId] = useState(initialProductId);
const product = products.find((item) => item.id === productId) || products[0];
// 默认项目名跟随所选商品+脚本风格,版本号按已有同名项目自动递增(避免连建多个同名 v1);
// 用户手动改过名字后不再覆盖。
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
// Step 1 · 商品选择器本地交互态
const [pickSearch, setPickSearch] = useState("");
const [pickCat, setPickCat] = useState("全部");
const [catOpen, setCatOpen] = useState(false);
const [pickView, setPickView] = useState<"grid" | "list">("grid");
const [pickPage, setPickPage] = useState(1);
// 新建商品 · 右侧抽屉本地态(自洽实现,不依赖 products.tsx)
const [npOpen, setNpOpen] = useState(false);
const [npTitle, setNpTitle] = useState("");
const [npCat, setNpCat] = useState(WIZ_PC_CATS[0]);
const [npAudience, setNpAudience] = useState("");
const [npPoints, setNpPoints] = useState<string[]>([]);
const [npDraft, setNpDraft] = useState("");
const [npTitleError, setNpTitleError] = useState(false);
const [npSaving, setNpSaving] = useState(false);
// Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手)
const [duration, setDuration] = useState<string | null>("0-15");
const [points, setPoints] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!productId && products[0]) setProductId(products[0].id);
}, [productId, products]);
// 卖点胶囊跟随当前选中商品初始化(默认全不勾选)。初始挂载时(含预选商品)即填充,
// 修复「默认选中商品却不显示卖点,要切走再切回才出现」的初始化时机 bug。
useEffect(() => {
const p = products.find((item) => item.id === productId);
const seed: Record<string, boolean> = {};
(p?.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
setPoints(seed);
}, [productId, products]);
useEffect(() => {
if (nameTouched) return;
const base = `${(product?.title || "商品").split(" ")[0]} · 短视频`;
let version = 1;
while (projects.some((p) => p.name === `${base} · v${version}`)) version += 1;
setName(`${base} · v${version}`);
}, [nameTouched, product, projects]);
// 分类清单
const cats = useMemo(
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
[products]
);
// 筛选 + 排序后的商品
const filtered = useMemo(() => {
const q = pickSearch.trim().toLowerCase();
return products.filter((p) => {
const cat = p.category || "未分类";
if (pickCat !== "全部" && cat !== pickCat) return false;
if (q) {
const blob = `${p.title} ${cat} ${(p.selling_points || []).map((s) => s.title).join(" ")}`.toLowerCase();
if (!blob.includes(q)) return false;
}
return true;
});
}, [products, pickSearch, pickCat]);
const hasFilter = !!pickSearch || pickCat !== "全部";
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / WIZ_PAGE_SIZE));
const cur = Math.min(pickPage, totalPages);
const pageList = filtered.slice((cur - 1) * WIZ_PAGE_SIZE, cur * WIZ_PAGE_SIZE);
function selectProduct(id: string) {
// 卖点种子由 productId useEffect 统一初始化,这里只切换选中商品
setProductId(id);
}
function clearPickFilters() {
setPickSearch("");
setPickCat("全部");
setPickPage(1);
}
// 点击「创建新商品」:有 onCreateProduct 接线则开右侧抽屉,否则回落到旧行为(返回去商品库)
function openCreateProduct() {
if (!onCreateProduct) { onBack(); return; }
setNpTitle("");
setNpCat(WIZ_PC_CATS[0]);
setNpAudience("");
setNpPoints([]);
setNpDraft("");
setNpTitleError(false);
setNpOpen(true);
}
function addNpPoint() {
const v = npDraft.trim();
if (!v || npPoints.includes(v)) { setNpDraft(""); return; }
setNpPoints((prev) => [...prev, v]);
setNpDraft("");
}
async function submitNewProduct() {
if (!onCreateProduct) return;
const name = npTitle.trim();
if (!name) { setNpTitleError(true); return; } // 空名:必填校验,不静默无反应
setNpSaving(true);
try {
const created = await onCreateProduct({
title: name,
category: npCat,
target_audience: npAudience.trim() || undefined,
selling_points: npPoints.map((item, index) => ({ title: item, detail: item, sort_order: index }))
});
setNpOpen(false);
// 新建成功且拿到 id → 自动选中该商品进入第 1 步选中态
if (created && typeof created === "object" && "id" in created) {
setProductId((created as Product).id);
}
} finally {
setNpSaving(false);
}
}
const durObj = WIZ_DURATIONS.find((d) => d.id === duration);
const product1Done = !!productId;
// 信息没填完(项目名 < 2 字 / 未选时长)时「开始」禁用
const config2Done = !!durObj && name.trim().length >= 2;
const canStart = product1Done && config2Done;
// 提交中状态:创建项目后 App 端全量刷新需数秒,按钮要给「创建中…」反馈,避免用户以为没点上
const [starting, setStarting] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
if (!canStart || !product || starting) return;
// 向导选项(成片时长档 / 选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id);
setStarting(true);
try {
await onCreate({
name: name.trim() || `${product.title} · 短视频`,
product: product.id,
metadata: {
wizard: {
duration,
selling_point_ids: selectedPoints
}
}
});
} finally {
setStarting(false);
}
}
const productCover = (p: Product): CSSProperties | undefined => {
const file = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || p.images?.[0]?.asset;
return file ? ({ ["--mock-media-url"]: `url(${file})` } as CSSProperties) : undefined;
};
return (
<section className="project-wizard-page">
<div className="page-head">
<div>
<h1>新建项目</h1>
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
</div>
<div className="actions">
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
</div>
</div>
<form className="wizard" onSubmit={submit}>
{/* ── 左侧步骤轨 ── */}
<nav className="steps" aria-label="新建项目步骤">
<div className={`step ${product1Done ? "done" : "active"}`}>
<div className="num">{product1Done ? (
<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>
) : "1"}</div>
<div>
<div className="label">选择商品</div>
<div className="desc">{product?.title || "未选择"}</div>
</div>
</div>
<div className={`step ${config2Done ? "done" : product1Done ? "active" : ""}`}>
<div className="num">{config2Done ? (
<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>
) : "2"}</div>
<div>
<div className="label">项目配置</div>
<div className="desc">{durObj ? `${durObj.label} · ${name.trim() || "未命名"}` : "项目名 · 成片时长"}</div>
</div>
</div>
</nav>
{/* ── 主体 ── */}
<div className="wiz-body">
{/* Step 1 · 商品选择 */}
<section className="step-pane-wrap">
<div className="wiz-pane">
<div className="wiz-step-h">
<h2>第 1 步 · 选择商品</h2>
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
</div>
<div className="pp-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 type="text" placeholder="搜索商品名称、标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} />
</div>
<div className={`pp-chip-wrap${catOpen ? " open" : ""}`}>
<button className={`pp-chip${pickCat !== "全部" ? " active" : ""}`} type="button" onClick={() => setCatOpen((open) => !open)}>
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="pp-menu">
{cats.map((c) => (
<div className={`mi${pickCat === c ? " selected" : ""}`} key={c} onClick={() => { setPickCat(c); setPickPage(1); setCatOpen(false); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6"><polyline points="3 8 7 12 13 4" /></svg>
<span>{c}</span>
</div>
))}
</div>
</div>
{hasFilter && (
<button className="pp-clear" type="button" onClick={clearPickFilters}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
清空筛选
</button>
)}
</div>
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
<div className="pp-create-card" onClick={openCreateProduct}>
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
<div className="pc-t">创建新商品</div>
<div className="pc-d">// 在此添加一个新商品</div>
</div>
{total === 0 ? (
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
) : (
pageList.map((p) => (
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
<div className={`placeholder product-thumb${productCover(p) ? " has-mock-media" : ""}`} style={productCover(p)}><span className="ph-frame">{p.title} · 1200×800</span></div>
<div className="product-body">
<div className="product-name">{p.title}</div>
<div className="product-cat">{p.category || "未分类"}</div>
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
</div>
</div>
))
)}
</div>
{total > WIZ_PAGE_SIZE && (
<div className="pp-pager">
<span className="total">共 {total} 条</span>
<div className="pages">
<button type="button" disabled={cur === 1} onClick={() => setPickPage(cur - 1)}>‹</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button type="button" key={n} className={n === cur ? "active" : ""} onClick={() => setPickPage(n)}>{n}</button>
))}
<button type="button" disabled={cur === totalPages} onClick={() => setPickPage(cur + 1)}>›</button>
</div>
<span className="page-size">每页 {WIZ_PAGE_SIZE} 条</span>
</div>
)}
<div className="pp-bottom-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
<span>找不到想要的商品?可<a onClick={openCreateProduct}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
</div>
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={openCreateProduct} />}
</div>
</section>
{/* Step 2 · 项目配置 */}
<section className="step-pane-wrap">
<div className="wiz-pane">
<div className="wiz-step-h">
<h2>第 2 步 · 项目配置</h2>
<p>基础配置只保留项目名和成片时长。脚本来源、风格和人物设定已移到流水线第 1 步由脚本助手引导。</p>
</div>
<div className="field">
<label className="field-label">项目名称<span className="req">*</span></label>
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</div>
<div className="field">
<label className="field-label">视频时长<span className="req">*</span></label>
<div className="opt-row cols-4">
{WIZ_DURATIONS.map((d) => (
<div className={`opt-card${duration === d.id ? " selected" : ""}`} key={d.id} onClick={() => setDuration(d.id)}>
<span className="badge">[ {d.tag} ]</span>
<h4>{d.label}</h4>
<div className="sub">{d.shots[0]}-{d.shots[1]} 镜 · 9:16</div>
<div className="note">{d.note}</div>
</div>
))}
</div>
</div>
{Object.keys(points).length > 0 && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
<div className="theme-pill-row">
{Object.entries(points).map(([k, v]) => (
<button className={`theme-pill${v ? " active" : ""}`} type="button" key={k} aria-pressed={v} onClick={() => setPoints((prev) => ({ ...prev, [k]: !prev[k] }))}>
{v && <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>{k}</span>
</button>
))}
</div>
</div>
)}
</div>
</section>
{/* ── 底部「开始」CTA ── */}
<div className="wiz-start-bar">
<button className={`btn-start${canStart && !starting ? "" : " disabled"}`} type="submit" disabled={!canStart || starting}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
<span>{starting ? "创建中…" : "开始"}</span>
</button>
</div>
</div>
</form>
{/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */}
<Drawer title="新建商品" open={npOpen} close={() => setNpOpen(false)}>
<div className="field">
<label className="field-label">商品名称<span className="req">*</span></label>
<input
className={`input${npTitleError ? " error" : ""}`}
value={npTitle}
placeholder="例:南卡 Lite Pro 蓝牙耳机"
onChange={(event) => { setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }}
/>
{npTitleError && <div className="field-hint err">// 商品名称不能为空</div>}
</div>
<div className="field">
<label className="field-label">商品品类</label>
<select className="select" value={npCat} onChange={(event) => setNpCat(event.target.value)}>
{WIZ_PC_CATS.map((c) => <option value={c} key={c}>{c}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">目标人群</label>
<input className="input" value={npAudience} placeholder="例:25-30 岁都市白领" onChange={(event) => setNpAudience(event.target.value)} />
</div>
<div className="field">
<label className="field-label">关键卖点</label>
<div className="np-point-add">
<input
className="input"
value={npDraft}
placeholder="输入一个卖点后回车添加"
onChange={(event) => setNpDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addNpPoint(); } }}
/>
<button className="btn btn-sm" type="button" onClick={addNpPoint}>添加</button>
</div>
{npPoints.length > 0 && (
<div className="theme-pill-row" style={{ marginTop: 10 }}>
{npPoints.map((p) => (
<button className="theme-pill active" type="button" key={p} onClick={() => setNpPoints((prev) => prev.filter((x) => x !== p))}>
<span>{p}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
))}
</div>
)}
</div>
<div className="np-drawer-foot">
<button className="btn" type="button" onClick={() => setNpOpen(false)}>取消</button>
<button className="btn btn-primary" type="button" disabled={npSaving} onClick={submitNewProduct}>{npSaving ? "创建中…" : "创建商品"}</button>
</div>
</Drawer>
</section>
);
}
// 对齐 api-bridge:阶段编号 / 状态分桶 / 友好标签 / pill 类
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
function projStageNo(project: Project) { return project.status === "completed" ? 5 : (PROJ_STAGE_NO[project.current_stage] || 1); }
function projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
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"; }
function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); }
// 复刻 mock-media coverFor:按项目名关键词映射封面图(无匹配 → 占位)
function projCover(name: string): string {
const t = name.replace(/\s+/g, "");
if (/蓝牙|耳机|南卡/.test(t)) return "cover-earbuds.png";
if (/速食|牛肉面|泡面|面条/.test(t)) return "cover-noodle.png";
if (/防晒/.test(t)) return "cover-sunscreen.png";
if (/咖啡|冻干/.test(t)) return "cover-coffee.png";
if (/空气炸锅|小熊/.test(t)) return "cover-air-fryer.png";
if (/瑜伽裤|露露/.test(t)) return "cover-yoga.png";
if (/v1|final|已完成|成片|敷面膜|化妆台/.test(t)) return "cover-mask-final.png";
if (/面膜|补水|玻尿酸|透真/.test(t)) return "cover-mask-v3.png";
return "";
}
const projMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } 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: "失败" }
];
export function ProjectsPage({ products, projects, navigate, openPipeline, onDelete }: {
products: Product[];
projects: Project[];
navigate: (page: Page) => void;
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
openPipeline: (projectId: string) => void;
onDelete: (projectId: string) => Promise<unknown> | void;
}) {
const [view, setView] = useState<"list" | "grid">("list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
const [query, setQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "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 || "创建时间";
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
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_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;
}
return true;
});
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, catFilter, sourceFilter, 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);
async function confirmDelete() {
if (!deleteTarget) return;
await onDelete(deleteTarget.id);
setDeleteTarget(null);
}
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>
<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)} />
</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={`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">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</div>
{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 shots = project.video_segments.length || 4;
const cover = projCover(project.name);
return (
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} onClick={() => openPipeline(project.id)}>
<td>
<div className="proj-name-cell">
<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>
</td>
<td>{productTitle(project.product)}</td>
<td><span className="muted">{(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>
<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>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="proj-grid">{pageItems.map((project) => {
const cover = projCover(project.name);
return (
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}>
<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 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>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
</section>
);
}