import { useEffect, useMemo, useState } from "react"; import type { FormEvent } from "react"; import { ArrowLeft, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, Grid2X2, Info, List, PackagePlus, Play, Route, Search, Settings2, Trash2, VideoOff } from "lucide-react"; import type { Asset, Product, Project, ScriptTemplate } from "../types"; import { api } from "../api"; import type { Page } from "./route-config"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { CustomSelect } from "../components/custom-select"; import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer"; import { isLocalLife } from "../product-business"; import { Pager } from "../components/pager"; import { SkeletonRows } from "../components/loading"; import { useViewMode } from "../components/use-view-mode"; import { hasQuickCreateSuffix, isQuickCreateBusy } from "../quick-create-lock"; import "../project-wizard-page.css"; const PROJ_PAGE_SIZE = 8; // 4 列网格两行 // 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。 const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品 const WIZ_RAIL = [ { n: "01", title: "选择商品", desc: "确定本次内容生产的商品主体" }, { n: "02", title: "脚本创建", desc: "生成并确认镜头脚本" }, { n: "03", title: "资产选择", desc: "准备商品、角色与场景资产" }, { n: "04", title: "视频生成", desc: "按脚本的秒级分镜直接生成视频" }, ]; 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: ProductCreatePayload) => 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 [pickPage, setPickPage] = useState(1); // 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程) const [npOpen, setNpOpen] = useState(false); // Step 2 · 配置(基础配置只保留项目名;脚本风格/人物设定与时长都在流水线第 1 步统一落定) const [points, setPoints] = useState>({}); // Step 2 · 5.2 换商品重跑:选一个团队套路模板,新项目沿用它的镜序与节奏(文案仍按新商品重写)。 // 只传 template_id,套路参数由后端按库里的模板回填,前后端不留两份真相。 const [templates, setTemplates] = useState([]); const [templateId, setTemplateId] = useState(""); useEffect(() => { void api.listScriptTemplates() .then((page) => setTemplates(page.results || [])) .catch(() => setTemplates([])); }, []); const template = templates.find((t) => t.id === templateId); 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.id || 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] ); useEffect(() => { if (!catOpen) return; const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".nw-select")) setCatOpen(false); }; document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [catOpen]); // 筛选 + 排序后的商品 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 product1Done = !!productId; // 项目创建只要求选商品 + 输入项目名;时长在脚本阶段确认,避免阶段前置参数脱钩 const config2Done = 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: { selling_point_ids: selectedPoints, ...(templateId ? { template_id: templateId } : {}) } } }); } finally { setStarting(false); } } 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 || ""; }; const imageCount = (p: Product) => p.images?.length || (p.cover_preview_url ? 1 : 0); const pointCount = (p: Product) => p.selling_points?.length || 0; const createdLabel = (iso: string) => { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso.slice(0, 10); const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); if (d.toDateString() === now.toDateString()) return "今天创建"; const yest = new Date(now); yest.setDate(now.getDate() - 1); if (d.toDateString() === yest.toDateString()) return "昨天创建"; return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} 创建`; }; const selectedCover = product ? productCoverUrl(product) : ""; const selectedMeta = product ? `${isLocalLife(product) ? "本地生活" : (product.category || "未分类")} · ${imageCount(product)} 张参考图` : "尚未选择商品"; return (

新建项目

先选择本次项目使用的商品,下一步再配置内容方向与生成规格

选择商品· 新建项目

选择商品

从商品库选择一个 SKU。商品主图、卖点和参考资产将用于后续脚本、分镜与视觉生成。

{total} 个商品可选
{cats.map((cat) => ( ))}
{total === 0 ? (
{products.length === 0 ? "还没有商品" : "没有符合条件的商品"} {products.length === 0 ? "先创建一个商品,再回来开项目" : "请调整分类或搜索关键词后重试"} {hasFilter && } {products.length === 0 && }
) : pageList.map((item) => { const cover = productCoverUrl(item); const cat = isLocalLife(item) ? `本地生活${item.category ? " · " + item.category : ""}` : (item.category || "未分类"); return ( ); })}
{total > WIZ_PAGE_SIZE && ( )}

项目配置

基础配置只保留项目名与可选套路模板;脚本来源、风格和时长在下一步由脚本助手落定。

{templates.length > 0 && ( )} {Object.keys(points).length > 0 && (
关键卖点可勾选要重点突出的
{Object.entries(points).map(([key, on]) => { const label = product?.selling_points?.find((sp) => (sp.id || sp.title) === key)?.title || key; return ( ); })}
)}
找不到需要的商品?可先创建新商品,完善后会自动返回本流程。
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */} {onCreateProduct && ( setNpOpen(false)} onCreate={onCreateProduct} onUploadImage={onUploadImage} // 新建成功 → 自动选中该商品进入第 1 步选中态 onCreated={(created) => setProductId(created.id)} /> )}
); } // 对齐 api-bridge / pipeline:阶段编号 / 状态分桶 / 友好标签 / pill 类 // 故事板已下线;V1 又雪藏「拼接导出」→ 流程现为 3 步,export 归并到第 3(视频), // 已完成项目落到第 3 阶段。(与 pipeline.tsx PIPELINE_RAIL / projectStage 对齐) // storyboard 保留映射,只为老项目的历史 current_stage 兜底。 const PROJ_STAGE_TOTAL = 3; const PROJ_STAGE_NO: Record = { script: 1, base_assets: 2, storyboard: 2, video: 3, export: 3 }; function projStageNo(project: Project) { return project.status === "completed" ? PROJ_STAGE_TOTAL : (PROJ_STAGE_NO[project.current_stage] || 1); } function projBucket(project: Project) { if (project.status === "completed") return "done"; if (project.status === "failed") return "fail"; if (project.status === "draft") return "draft"; return "wip"; } // 当前脚本版本:优先已采用,否则取第一版(轻量列表序列化可能不含 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 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; primary?: boolean }> = [ { title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/prototype/video-oneclick-film-v3.png", primary: true }, { title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/prototype/photo-1485846234645-a62644f84728.jpg", primary: true }, ]; const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string; style?: React.CSSProperties }> = [ { title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/prototype/video-free-film-v3.png" }, { title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/prototype/video-prompt-extract-film-v3.png" }, { title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/prototype/video-remix-film-v3.png", style: { objectPosition: "center 58%" } }, ]; function isQuickCreateProject(project: Project) { if (project.quick_create) return true; if (project.metadata?.quick_create) return true; return hasQuickCreateSuffix(project.name); } function projectModeLabel(project: Project) { if (isQuickCreateBusy(project)) return "一键成片生成中"; return isQuickCreateProject(project) ? "一键成片" : "专业创作"; } function projCardSub(project: Project, productTitle: string): string { if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄"; const mode = projectModeLabel(project); const shots = projShotMeta(project); const stage = projStageLabel(project); if (project.status === "completed") return `${mode} · ${productTitle} · ${shots}`; const no = projStageNo(project); return `${mode} · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`; } function projStageLabel(project: Project): string { if (project.status === "failed") return "失败"; const map: Record = { script: "脚本", base_assets: "资产创建", storyboard: "资产创建", video: "视频生成", export: "视频生成", }; return map[project.current_stage] || "视频生成"; } 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", "grid"); const [tab, setTab] = useState("all"); // initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。 useEffect(() => { if (initialTab && ["all", "draft", "wip", "done", "fail"].includes(initialTab)) { setTab(initialTab as ProjTab); } }, [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(() => { if (!editMode) setSelected(new Set()); }, [editMode]); const exitEdit = () => { setEditMode(false); setSelected(new Set()); }; 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 [playing, setPlaying] = useState(null); const [openChip, setOpenChip] = useState<"" | "product" | "time">(""); const [catFilter, setCatFilter] = useState(""); 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 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 timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间"; useEffect(() => { if (!openChip) return; const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".vc-select")) setOpenChip(""); }; document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [openChip]); 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 (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, 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 (

视频创作

商品视频生产

{VIDEO_MAKE.map((card) => ( ))}

AI 视频工具

{VIDEO_TOOLS.map((card) => ( ))}

视频项目

{PROJ_TABS.map((t) => ( ))}
{TIME_OPTS.map((opt) => ( ))}
{projectCategories.map((cat) => ( ))}
{loading && projects.length === 0 ? ( ) : filtered.length === 0 ? (
{projects.length === 0 ? "还没有视频项目" : "没有符合条件的视频项目"} {projects.length === 0 ? "从上方入口开始生成" : "请调整状态、时间、分类或搜索关键词后重试"}
) : ( <>
{pageItems.map((project) => { const cover = project.cover_preview_url || ""; const isSel = selected.has(project.id); const bucket = projBucket(project); const no = projStageNo(project); const openProject = () => { if (editMode) toggleSelect(project.id); else openPipeline(project.id); }; const statusLabel = bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中"; return (
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }} >
{cover ? : null} {!editMode && project.final_video_url ? ( ) : null}

{project.name}

{projCardSub(project, productTitle(project.product))}

{statusLabel} {projectModeLabel(project)}
{bucket === "wip" && ( )}
); })}
)}
已选择 {selected.size} 个项目 删除后可在垃圾桶中恢复
setPlaying(null)} /> } detail={`确定删除「${deleteTarget?.name || ""}」?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} /> } detail={`确定删除选中的 ${selected.size} 个项目?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
); }