商品库+详情(行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>
711 lines
39 KiB
TypeScript
711 lines
39 KiB
TypeScript
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>
|
||
);
|
||
}
|