810 lines
39 KiB
TypeScript
810 lines
39 KiB
TypeScript
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<string, unknown> }) => Promise<unknown> | void;
|
|
// 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
|
|
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
|
|
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
|
|
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | 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<Record<string, boolean>>({});
|
|
|
|
// Step 2 · 5.2 换商品重跑:选一个团队套路模板,新项目沿用它的镜序与节奏(文案仍按新商品重写)。
|
|
// 只传 template_id,套路参数由后端按库里的模板回填,前后端不留两份真相。
|
|
const [templates, setTemplates] = useState<ScriptTemplate[]>([]);
|
|
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<string, boolean> = {};
|
|
(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 (
|
|
<section className="project-wizard-page">
|
|
<header className="nw-head">
|
|
<div className="nw-title">
|
|
<button className="nw-back" type="button" onClick={onBack} aria-label="返回">
|
|
<ArrowLeft />
|
|
</button>
|
|
<div>
|
|
<h1>新建项目</h1>
|
|
<p>先选择本次项目使用的商品,下一步再配置内容方向与生成规格</p>
|
|
</div>
|
|
</div>
|
|
<div className="nw-status"><strong>选择商品</strong><span>· 新建项目</span></div>
|
|
</header>
|
|
|
|
<form className="nw-shell" onSubmit={submit}>
|
|
<aside className="nw-rail" aria-label="项目进度">
|
|
<header className="nw-rail-head">
|
|
<span className="nw-rail-icon"><Route /></span>
|
|
<div>
|
|
<h2>项目进度</h2>
|
|
<p>按步骤完成项目创建流程</p>
|
|
</div>
|
|
</header>
|
|
<div className="nw-rail-summary">
|
|
<span>已解锁 1 / 5 · 当前第 1 步</span>
|
|
<div className="nw-rail-track"><span style={{ width: "20%" }} /></div>
|
|
</div>
|
|
<div className="nw-rail-steps">
|
|
{WIZ_RAIL.map((step, index) => (
|
|
<div className={`nw-step${index === 0 ? " current" : ""}`} key={step.n} hidden={index > 0}>
|
|
<span className="nw-step-mark">{index === 0 ? <Check /> : step.n}</span>
|
|
<span className="nw-step-copy">
|
|
<strong>{step.title}</strong>
|
|
<span>{step.desc}</span>
|
|
</span>
|
|
<ChevronRight className="nw-step-chev" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="nw-picked">
|
|
<span>本项目商品</span>
|
|
<div className="nw-picked-card">
|
|
{selectedCover ? <img src={selectedCover} alt="" /> : <span className="nw-picked-ph" />}
|
|
<div>
|
|
<strong>{product?.title || "未选择"}</strong>
|
|
<small>{selectedMeta}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="nw-surface" aria-labelledby="nwSelectTitle">
|
|
<div className="nw-select-head">
|
|
<div>
|
|
<h2 id="nwSelectTitle">选择商品</h2>
|
|
<p>从商品库选择一个 SKU。商品主图、卖点和参考资产将用于后续脚本、分镜与视觉生成。</p>
|
|
</div>
|
|
<span className="nw-count">{total} 个商品可选</span>
|
|
</div>
|
|
|
|
<div className="nw-toolbar">
|
|
<label className="nw-search">
|
|
<Search size={16} />
|
|
<input placeholder="搜索商品名称、分类或标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} onKeyDown={(event) => { if (event.key === "Enter") event.preventDefault(); }} />
|
|
</label>
|
|
<div className={`nw-select${catOpen ? " open" : ""}`}>
|
|
<button className="nw-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={catOpen} onClick={() => setCatOpen((open) => !open)}>
|
|
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
|
|
<ChevronDown size={15} />
|
|
</button>
|
|
<div className="nw-select-menu" role="listbox">
|
|
{cats.map((cat) => (
|
|
<button className={`nw-select-option${pickCat === cat ? " selected" : ""}`} type="button" key={cat} onClick={() => { setPickCat(cat); setPickPage(1); setCatOpen(false); }}>
|
|
{cat === "全部" ? "全部分类" : cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="nw-grid">
|
|
<button className="nw-card nw-create" type="button" onClick={openCreateProduct}>
|
|
<span className="nw-create-icon"><PackagePlus /></span>
|
|
<strong>创建新商品</strong>
|
|
<span>商品库中没有合适商品时,在这里补充一个新 SKU</span>
|
|
</button>
|
|
{total === 0 ? (
|
|
<div className="nw-empty" role="status">
|
|
<strong>{products.length === 0 ? "还没有商品" : "没有符合条件的商品"}</strong>
|
|
<span>{products.length === 0 ? "先创建一个商品,再回来开项目" : "请调整分类或搜索关键词后重试"}</span>
|
|
{hasFilter && <button className="nw-empty-reset" type="button" onClick={clearPickFilters}>清空筛选</button>}
|
|
{products.length === 0 && <button className="nw-empty-reset" type="button" onClick={openCreateProduct}>去创建商品</button>}
|
|
</div>
|
|
) : pageList.map((item) => {
|
|
const cover = productCoverUrl(item);
|
|
const cat = isLocalLife(item) ? `本地生活${item.category ? " · " + item.category : ""}` : (item.category || "未分类");
|
|
return (
|
|
<button className={`nw-card${productId === item.id ? " active" : ""}`} type="button" key={item.id} onClick={() => selectProduct(item.id)}>
|
|
<span className="nw-check"><Check /></span>
|
|
<span className="nw-cover">{cover ? <img src={cover} alt="" loading="lazy" /> : null}</span>
|
|
<span className="nw-meta">
|
|
<h3>{item.title}</h3>
|
|
<p>{cat} · {pointCount(item)} 个核心卖点</p>
|
|
<span className="nw-tags">
|
|
<span>{imageCount(item)} 张参考图</span>
|
|
<span>{createdLabel(item.created_at)}</span>
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{total > WIZ_PAGE_SIZE && (
|
|
<Pager page={cur} total={total} pageSize={WIZ_PAGE_SIZE} onChange={setPickPage} />
|
|
)}
|
|
|
|
<div className="nw-config">
|
|
<div className="nw-config-h">
|
|
<h2>项目配置</h2>
|
|
<p>基础配置只保留项目名与可选套路模板;脚本来源、风格和时长在下一步由脚本助手落定。</p>
|
|
</div>
|
|
<label className="nw-field">
|
|
<span>项目名称<small>必填</small></span>
|
|
<input value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
|
</label>
|
|
{templates.length > 0 && (
|
|
<label className="nw-field">
|
|
<span>套路模板<small>可选</small></span>
|
|
<CustomSelect
|
|
fill
|
|
value={templateId}
|
|
onChange={setTemplateId}
|
|
placeholder="不套用 · 让 AI 按这个商品从头设计"
|
|
options={[
|
|
{ value: "", label: "不套用 · 让 AI 按这个商品从头设计" },
|
|
...templates.map((item) => ({
|
|
value: item.id,
|
|
label: `${item.name} · ${item.total_duration}s${item.usage_count ? ` · 用过 ${item.usage_count} 次` : ""}`,
|
|
})),
|
|
]}
|
|
/>
|
|
{template && (
|
|
<div className="nw-tpl">
|
|
<strong>同套路重出</strong>
|
|
<p>沿用「{template.name}」的镜数、每镜作用和节奏。文案、画面与模特都会按 {product?.title || "所选商品"} 重新生成。</p>
|
|
{template.outline_text ? <pre>{template.outline_text}</pre> : null}
|
|
</div>
|
|
)}
|
|
</label>
|
|
)}
|
|
{Object.keys(points).length > 0 && (
|
|
<div className="nw-field">
|
|
<span>关键卖点<small>可勾选要重点突出的</small></span>
|
|
<div className="nw-pills">
|
|
{Object.entries(points).map(([key, on]) => {
|
|
const label = product?.selling_points?.find((sp) => (sp.id || sp.title) === key)?.title || key;
|
|
return (
|
|
<button className={`nw-pill${on ? " on" : ""}`} type="button" key={key} aria-pressed={on} onClick={() => setPoints((prev) => ({ ...prev, [key]: !prev[key] }))}>
|
|
{on && <Check size={13} />}
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="nw-actions">
|
|
<span className="nw-tip">
|
|
<Info />
|
|
<span>找不到需要的商品?可先创建新商品,完善后会自动返回本流程。</span>
|
|
</span>
|
|
<button className="nw-next" type="submit" disabled={!canStart || starting}>
|
|
<span>{starting ? "创建中…" : "确认商品并创建项目"}</span>
|
|
<ArrowRight />
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</form>
|
|
|
|
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
|
|
{onCreateProduct && (
|
|
<ProductCreateDrawer
|
|
open={npOpen}
|
|
close={() => setNpOpen(false)}
|
|
onCreate={onCreateProduct}
|
|
onUploadImage={onUploadImage}
|
|
// 新建成功 → 自动选中该商品进入第 1 步选中态
|
|
onCreated={(created) => setProductId(created.id)}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// 对齐 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<string, number> = { 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<string, string> = {
|
|
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<unknown> | void;
|
|
openPipeline: (projectId: string) => void;
|
|
onDelete: (projectId: string) => Promise<unknown> | void;
|
|
// 工作台统计块点击带入的初始 tab(all/wip/done/fail);变化时覆盖当前 tab。
|
|
initialTab?: string;
|
|
}) {
|
|
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "grid");
|
|
const [tab, setTab] = useState<ProjTab>("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<Project | null>(null);
|
|
// 批量管理:编辑态多选集合 + 批量删除确认开关。退出编辑态自动清空选择。
|
|
const [selected, setSelected] = useState<Set<string>>(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<Project | null>(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 (
|
|
<section className={`projects-page${editMode ? " manage-mode" : ""}`}>
|
|
<div className="projects-inner">
|
|
<header className="vc-head">
|
|
<h1>视频创作</h1>
|
|
</header>
|
|
|
|
<section className="vc-sec">
|
|
<div className="vc-sec-h">
|
|
<h2>商品视频生产</h2>
|
|
</div>
|
|
<div className="vc-tools two">
|
|
{VIDEO_MAKE.map((card) => (
|
|
<button className={`vc-tool${card.primary ? " primary" : ""}`} type="button" key={card.title} onClick={() => navigate(card.page)}>
|
|
<div className="vc-tool-cover"><img src={card.image} alt="" /></div>
|
|
<div className="vc-tool-body">
|
|
<div className="vc-tool-title">
|
|
<h3>{card.title}</h3>
|
|
<ArrowUpRight size={21} />
|
|
</div>
|
|
<p>{card.desc}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="vc-sec">
|
|
<div className="vc-sec-h">
|
|
<h2>AI 视频工具</h2>
|
|
</div>
|
|
<div className="vc-tools three">
|
|
{VIDEO_TOOLS.map((card) => (
|
|
<button className="vc-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
|
|
<div className="vc-tool-cover"><img src={card.image} alt="" style={card.style} /></div>
|
|
<div className="vc-tool-body">
|
|
<div className="vc-tool-title">
|
|
<h3>{card.title}</h3>
|
|
<ArrowUpRight size={21} />
|
|
</div>
|
|
<p>{card.desc}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<div className="vc-sec-h vc-list-h">
|
|
<div>
|
|
<h2>视频项目</h2>
|
|
</div>
|
|
<button className={`vc-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
|
<Settings2 />
|
|
<span>{editMode ? "完成" : "管理项目"}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="vc-toolbar">
|
|
<div className="vc-seg" role="tablist" aria-label="项目状态">
|
|
{PROJ_TABS.map((t) => (
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === t.filter}
|
|
className={`vc-seg-btn${tab === t.filter ? " active" : ""}`}
|
|
key={t.filter}
|
|
onClick={() => setTab(t.filter)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="vc-toolbar-right">
|
|
<label className="vc-search">
|
|
<Search size={16} />
|
|
<input placeholder="搜索视频项目" value={query} onChange={(event) => setQuery(event.target.value)} />
|
|
</label>
|
|
<div className={`vc-select${openChip === "time" ? " open" : ""}`} data-key="time">
|
|
<button className="vc-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "time"} onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
|
|
<span>{timeLabel}</span>
|
|
<ChevronDown size={15} />
|
|
</button>
|
|
<div className="vc-select-menu" role="listbox">
|
|
{TIME_OPTS.map((opt) => (
|
|
<button className={`vc-select-option${timeFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>{opt.label}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className={`vc-select${openChip === "product" ? " open" : ""}`} data-key="product">
|
|
<button className="vc-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "product"} onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
|
<span>{catFilter || "商品分类"}</span>
|
|
<ChevronDown size={15} />
|
|
</button>
|
|
<div className="vc-select-menu" role="listbox">
|
|
<button className={`vc-select-option${!catFilter ? " selected" : ""}`} type="button" onClick={() => { setCatFilter(""); setOpenChip(""); }}>全部分类</button>
|
|
{projectCategories.map((cat) => (
|
|
<button className={`vc-select-option${catFilter === cat ? " selected" : ""}`} type="button" key={cat} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>{cat}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="vc-view">
|
|
<button type="button" className={`vc-view-btn${view === "grid" ? " active" : ""}`} aria-label="网格显示" onClick={() => setView("grid")}>
|
|
<Grid2X2 size={16} />
|
|
</button>
|
|
<button type="button" className={`vc-view-btn${view === "list" ? " active" : ""}`} aria-label="列表显示" onClick={() => setView("list")}>
|
|
<List size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loading && projects.length === 0 ? (
|
|
<SkeletonRows count={6} />
|
|
) : filtered.length === 0 ? (
|
|
<div className="vc-empty" role="status">
|
|
<VideoOff />
|
|
<strong>{projects.length === 0 ? "还没有视频项目" : "没有符合条件的视频项目"}</strong>
|
|
<span>{projects.length === 0 ? "从上方入口开始生成" : "请调整状态、时间、分类或搜索关键词后重试"}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className={`vc-grid${view === "list" ? " list" : ""}`}>
|
|
{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 (
|
|
<article
|
|
className={`vc-card project-card${isSel ? " selected" : ""}`}
|
|
key={project.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={openProject}
|
|
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
|
>
|
|
<span className="vc-check" aria-hidden="true"><Check /></span>
|
|
<button
|
|
className="card-del-btn"
|
|
type="button"
|
|
title="删除项目"
|
|
onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" 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 className="vc-thumb">
|
|
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
|
|
{!editMode && project.final_video_url ? (
|
|
<button
|
|
className="vc-play"
|
|
type="button"
|
|
aria-label={`播放「${project.name}」成片`}
|
|
onClick={(event) => { event.stopPropagation(); setPlaying(project); }}
|
|
>
|
|
<Play />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<div className="vc-meta">
|
|
<h3>{project.name}</h3>
|
|
<p>{projCardSub(project, productTitle(project.product))}</p>
|
|
<div className="vc-line">
|
|
<span className={`vc-tag ${bucket}`}>{statusLabel}</span>
|
|
<span className="vc-tag type">{projectModeLabel(project)}</span>
|
|
</div>
|
|
{bucket === "wip" && (
|
|
<div className="vc-progress" aria-hidden="true"><span style={{ width: `${Math.round((no / PROJ_STAGE_TOTAL) * 100)}%` }} /></div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`vc-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
|
|
<div className="vc-sel-copy">
|
|
<strong>已选择 {selected.size} 个项目</strong>
|
|
<span>删除后可在垃圾桶中恢复</span>
|
|
</div>
|
|
<div className="vc-sel-actions">
|
|
<button className="vc-sel-cancel" type="button" onClick={exitEdit}>取消</button>
|
|
<button className="vc-sel-confirm" type="button" disabled={selected.size === 0 || bulkDeleting} onClick={() => selected.size && setBulkConfirm(true)}>
|
|
{bulkDeleting ? "删除中…" : "确认删除"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<MediaLightbox open={Boolean(playing)} src={playing?.final_video_url || ""} kind="video" name={`${playing?.name || ""} · 成片`} close={() => setPlaying(null)} />
|
|
|
|
<ConfirmModal
|
|
open={Boolean(deleteTarget)}
|
|
title="删除项目"
|
|
icon={<Trash2 size={16} />}
|
|
detail={`确定删除「${deleteTarget?.name || ""}」?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
|
confirmText="删除"
|
|
onCancel={() => setDeleteTarget(null)}
|
|
onConfirm={confirmDelete}
|
|
/>
|
|
<ConfirmModal
|
|
open={bulkConfirm}
|
|
title="删除项目"
|
|
icon={<Trash2 size={16} />}
|
|
detail={`确定删除选中的 ${selected.size} 个项目?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
|
confirmText="删除"
|
|
onCancel={() => setBulkConfirm(false)}
|
|
onConfirm={confirmBulkDelete}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|