import { useEffect, useMemo, useState } from "react"; import type { CSSProperties, FormEvent } from "react"; import type { Asset, Product, Project } from "../types"; import type { Page } from "./route-config"; import { ConfirmModal, EmptyPanel } from "../components/overlays"; import { ProductCreateDrawer } from "../components/product-create-drawer"; import { Pager } from "../components/pager"; import { SkeletonRows } from "../components/loading"; import { useViewMode } from "../components/use-view-mode"; 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 商品 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, onUploadImage }: { products: Product[]; projects?: Project[]; // 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。 preselectProductId?: string; onBack: () => void; onCreate: (payload: { name: string; product: string; metadata?: Record }) => Promise | void; // 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。 onCreateProduct?: (payload: WizProductPayload) => Promise | void; // 新建商品的主图随商品一起持久化(与商品库新建商品同一流程) onUploadImage?: (productId: string, formData: FormData) => Promise | 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); // 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程) const [npOpen, setNpOpen] = useState(false); // Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手) const [duration, setDuration] = useState("0-15"); const [points, setPoints] = useState>({}); useEffect(() => { if (!productId && products[0]) setProductId(products[0].id); }, [productId, products]); // 卖点胶囊跟随当前选中商品初始化(默认全不勾选)。初始挂载时(含预选商品)即填充, // 修复「默认选中商品却不显示卖点,要切走再切回才出现」的初始化时机 bug。 useEffect(() => { const p = products.find((item) => item.id === productId); const seed: Record = {}; (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; } setNpOpen(true); } 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); } } // 商品封面图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets const productCoverUrl = (p: Product): string => { const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order); const primary = p.images?.find((img) => img.is_primary) || sorted[0]; return p.cover_preview_url || primary?.preview_url || ""; }; return (

新建项目

// 商品 → 配置 · 2 步开始生成
{/* ── 左侧步骤轨 ── */} {/* ── 主体 ── */}
{/* Step 1 · 商品选择 */}

第 1 步 · 选择商品

从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。

{ setPickSearch(event.target.value); setPickPage(1); }} />
{cats.map((c) => (
{ setPickCat(c); setPickPage(1); setCatOpen(false); }}> {c}
))}
{hasFilter && ( )}
// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}
创建新商品
// 在此添加一个新商品
{total === 0 ? (
// NO MATCH
没有符合筛选条件的商品 [ 清空筛选 ]
) : ( pageList.map((p) => { const coverUrl = productCoverUrl(p); return (
selectProduct(p.id)}> {coverUrl ? (
{p.title}
) : (
{p.title} · 1200×800
)}
{p.title}
{p.category || "未分类"}
{(p.created_at || "").slice(0, 10)} 创建
); }) )}
{total > WIZ_PAGE_SIZE && (
共 {total} 条
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( ))}
每页 {WIZ_PAGE_SIZE} 条
)}
找不到想要的商品?可创建新商品,或前往 商品库 · 管理商品
{products.length === 0 && }
{/* Step 2 · 项目配置 */}

第 2 步 · 项目配置

基础配置只保留项目名和成片时长。脚本来源、风格和人物设定已移到流水线第 1 步由脚本助手引导。

{ setName(event.target.value); setNameTouched(true); }} />
{WIZ_DURATIONS.map((d) => (
setDuration(d.id)}> [ {d.tag} ]

{d.label}

{d.shots[0]}-{d.shots[1]} 镜 · 9:16
{d.note}
))}
{Object.keys(points).length > 0 && (
{Object.entries(points).map(([k, v]) => ( ))}
)}
{/* ── 底部「开始」CTA ── */}
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */} {onCreateProduct && ( setNpOpen(false)} onCreate={onCreateProduct} onUploadImage={onUploadImage} // 新建成功 → 自动选中该商品进入第 1 步选中态 onCreated={(created) => setProductId(created.id)} /> )}
); } // 对齐 api-bridge / pipeline:阶段编号 / 状态分桶 / 友好标签 / pill 类 // V1 雪藏「拼接导出」第 5 阶段(代码保留,V2 恢复):流程现为 4 步,export 归并到第 4(视频), // 已完成项目落到第 4 阶段而非被藏的第 5。(与 pipeline.tsx STAGE_STEPS / projectStage 对齐) const PROJ_STAGE_TOTAL = 4; const PROJ_STAGE_NO: Record = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 }; function projStageNo(project: Project) { return project.status === "completed" ? PROJ_STAGE_TOTAL : (PROJ_STAGE_NO[project.current_stage] || 1); } // 进度格 class: // · 已完成 → 含末格在内全部 done(绿),末格不再用闪烁的 cur // · 失败 → 停在第 no 格标红(fail),之前的 done // · 进行中 → 已过的格 done,当前格 cur(一闪一闪),未到的格留空 function projProgClass(project: Project, i: number, no: number): string { if (project.status === "completed") return i <= no ? "done" : ""; 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 projStatusLabel(project: Project) { return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record)[project.status] || "进行中"; } // 失败态用现成 .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} 镜`; } // 项目卡封面 = 该项目商品的主图(后端 cover_preview_url,取商品 cover_asset)。无图 → 占位。 // (原先按项目名关键词映射静态 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: "失败" } ]; export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete, initialTab }: { products: Product[]; projects: Project[]; loading?: boolean; navigate: (page: Page) => void; onCreate: (payload: { name: string; product: string }) => Promise | void; openPipeline: (projectId: string) => void; onDelete: (projectId: string) => Promise | void; // 工作台统计块点击带入的初始 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"); // initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。 useEffect(() => { if (initialTab && ["all", "wip", "done", "fail"].includes(initialTab)) { setTab(initialTab as "all" | "wip" | "done" | "fail"); } }, [initialTab]); const [query, setQuery] = useState(""); const [editMode, setEditMode] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); // 批量管理:编辑态多选集合 + 批量删除确认开关。退出编辑态自动清空选择。 const [selected, setSelected] = useState>(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"); 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_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; } return true; }).sort((a, b) => { // 倒序排列:新创建的项目排最前;created_at 缺失时回退 updated_at,再回退 id 字典序 const ta = a.created_at || a.updated_at || a.id; const tb = b.created_at || b.updated_at || b.id; return ta < tb ? 1 : ta > tb ? -1 : 0; }); // 分页:每页 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); } // 批量删除选中项目:逐个调 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 (

视频项目

// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败
{PROJ_TABS.map((t) => (
setTab(t.filter)}>{t.label} {counts[t.filter]}
))}
setQuery(event.target.value)} />
{ setCatFilter(""); setOpenChip(""); }}> 全部品类
{projectCategories.length > 0 &&
} {projectCategories.map((cat) => (
{ setCatFilter(cat); setOpenChip(""); }}> {cat}
))}
{SRC_OPTS.map((opt) => (
{ setSourceFilter(opt.value); setOpenChip(""); }}> {opt.label}
))}
{TIME_OPTS.map((opt) => (
{ setTimeFilter(opt.value); setOpenChip(""); }}> {opt.label}
))}
{(query || catFilter || sourceFilter !== "all" || timeFilter !== "all" || tab !== "all") && ( )}
// {loading && projects.length === 0 ? "加载中…" : <>显示 {pageItems.length} / {filtered.length} 个项目}
{loading && projects.length === 0 ? ( ) : view === "list" ? (
{pageItems.map((project) => { const no = projStageNo(project); const cover = project.cover_preview_url || ""; const isSel = selected.has(project.id); return ( (editMode ? toggleSelect(project.id) : openPipeline(project.id))}> ); })}
项目 商品 脚本来源 进度 状态 更新于
{editMode && ( )}
9:16
{project.name}
{projShotMeta(project)}
{productTitle(project.product)} {(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}
{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => )}
{no}/{PROJ_STAGE_TOTAL}
{projStatusLabel(project)} {projDate(project)} {/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */} {!editMode && (
{ event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"> event.stopPropagation()}>
)}
) : (
{pageItems.map((project) => { const cover = project.cover_preview_url || ""; const no = projStageNo(project); const isSel = selected.has(project.id); return (
(editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
9:16
{project.name}
{productTitle(project.product)} · {projShots(project)} 镜
{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => )}
{no}/{PROJ_STAGE_TOTAL}
{projStatusLabel(project)}{projDate(project)}
); })}
)} {filtered.length === 0 && navigate("projectWizard")} />} {/* 批量管理栏 · 吸底浮动(显隐绑「有无选中」)· 蓝本同 products-page */}
0 ? " show" : ""}`} role="toolbar" aria-label="批量管理"> 已选 {selected.size}
setDeleteTarget(null)} onConfirm={confirmDelete} /> setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
); }