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 }) => Promise | void; // 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。 onCreateProduct?: (payload: WizProductPayload) => 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); // 新建商品 · 右侧抽屉本地态(自洽实现,不依赖 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([]); const [npDraft, setNpDraft] = useState(""); const [npTitleError, setNpTitleError] = useState(false); const [npSaving, setNpSaving] = 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; } 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 (

新建项目

// 商品 → 配置 · 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) => (
selectProduct(p.id)}>
{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 ── */}
{/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */} setNpOpen(false)}>
{ setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }} /> {npTitleError &&
// 商品名称不能为空
}
setNpAudience(event.target.value)} />
setNpDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addNpPoint(); } }} />
{npPoints.length > 0 && (
{npPoints.map((p) => ( ))}
)}
); } // 对齐 api-bridge:阶段编号 / 状态分桶 / 友好标签 / pill 类 const PROJ_STAGE_NO: Record = { 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)[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 | void; openPipeline: (projectId: string) => void; onDelete: (projectId: string) => Promise | 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(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 (

视频项目

// {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") && ( )}
// 显示 {pageItems.length} / {filtered.length} 个项目
{view === "list" ? (
{pageItems.map((project) => { const no = projStageNo(project); const shots = project.video_segments.length || 4; const cover = projCover(project.name); return ( openPipeline(project.id)}> ); })}
项目 商品 脚本来源 进度 状态 更新于
9:16
{project.name}
{shots} 镜 · 0-60s
{productTitle(project.product)} {(project.script_versions?.length || 0) > 0 ? "AI 已生成" : "暂无脚本"}
{[1, 2, 3, 4, 5].map((i) => )}
{no}/5
{projStatusLabel(project)} {projDate(project)}
) : (
{pageItems.map((project) => { const cover = projCover(project.name); return (
openPipeline(project.id)}>
9:16
{project.name}
{productTitle(project.product)}
{projStatusLabel(project)}{projDate(project)}
); })}
)} {filtered.length === 0 && navigate("projectWizard")} />} setDeleteTarget(null)} onConfirm={confirmDelete} />
); }