大量修改UI

This commit is contained in:
Azmat@qq.com
2026-08-19 17:44:56 +08:00
parent 18b3927817
commit a343f47f0c
76 changed files with 11655 additions and 6104 deletions
+375 -394
View File
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowUpRight, Grid2X2, List, Search } from "lucide-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, EmptyPanel, MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
import { isLocalLife } from "../product-business";
import { Pager } from "../components/pager";
@@ -16,6 +16,13 @@ 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: "生成并确认视频分镜画面" },
{ n: "05", title: "视频生成", desc: "按故事板生成视频片段" },
];
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: {
products: Product[];
@@ -44,7 +51,6 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
const [pickSearch, setPickSearch] = useState("");
const [pickCat, setPickCat] = useState("全部");
const [catOpen, setCatOpen] = useState(false);
const [pickView, setPickView] = useState<"grid" | "list">("grid");
const [pickPage, setPickPage] = useState(1);
// 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程)
@@ -90,6 +96,14 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
() => ["全部", ...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(() => {
@@ -157,198 +171,201 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
}
}
// 商品封面图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
const productCoverUrl = (p: Product): string => {
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
return p.cover_preview_url || primary?.preview_url || "";
};
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">
<div className="page-head">
<div>
<h1>新建项目</h1>
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
<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="actions">
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
</div>
</div>
<div className="nw-status"><strong>选择商品</strong><span>· 新建项目</span></div>
</header>
<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>
<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>
<div className="label">选择商品</div>
<div className="desc">{product?.title || "未选择"}</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>
<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>
</aside>
<section className="nw-surface" aria-labelledby="nwSelectTitle">
<div className="nw-select-head">
<div>
<div className="label">项目配置</div>
<div className="desc">{name.trim() || "未命名"}</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>
</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 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>
<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>
{total > WIZ_PAGE_SIZE && (
<Pager page={cur} total={total} pageSize={WIZ_PAGE_SIZE} onChange={setPickPage} />
)}
<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) => {
const coverUrl = productCoverUrl(p);
return (
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
{coverUrl ? (
<div className="product-thumb has-real-media"><img src={coverUrl} alt={p.title} loading="lazy" /></div>
) : (
<div className="placeholder product-thumb"><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">{isLocalLife(p) ? `本地生活${p.category ? " · " + p.category : ""}` : (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 className="nw-config">
<div className="nw-config-h">
<h2>项目配置</h2>
<p>基础配置只保留项目名与可选套路模板;脚本来源、风格和时长在下一步由脚本助手落定。</p>
</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>
{/* 5.2 套路模板 · 没存过模板就整块不出现,不给空下拉占位 */}
{templates.length > 0 && (
<div className="field">
<label className="field-label">套路模板(可选)</label>
<select className="input" value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value="">不套用 · 让 AI 按这个商品从头设计</option>
{/* 默认模板名已含镜数,选项里只补时长与使用次数,避免「4 镜 · 4 镜」 */}
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name} · {t.total_duration}s{t.usage_count ? ` · 用过 ${t.usage_count} 次` : ""}</option>
))}
</select>
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」 */}
{template && (
<div className="tpl-expect">
<div className="te-head"><span className="mono">// 同套路重出</span>沿用「{template.name}」的镜数、每镜作用和节奏</div>
<div className="te-outline">{template.outline_text}</div>
<div className="te-note">文案、画面、模特与故事板都会按 {product?.title || "所选商品"} 重新生成 —— 不是把上一条片子换个商品名。生成后仍可在脚本页逐镜改。</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>
))}
<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>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value="">不套用 · 让 AI 按这个商品从头设计</option>
{templates.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.total_duration}s{item.usage_count ? ` · 用过 ${item.usage_count} 次` : ""}</option>
))}
</select>
{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]) => (
<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>{key}</span>
</button>
))}
</div>
)}
</div>
</section>
</div>
)}
</div>
{/* ── 底部「开始」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>
<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>
</div>
</section>
</form>
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
@@ -372,27 +389,12 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
const PROJ_STAGE_TOTAL = 4;
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
function projStageNo(project: Project) { return project.status === "completed" ? PROJ_STAGE_TOTAL : (PROJ_STAGE_NO[project.current_stage] || 1); }
// 进度格 class:
// · 已完成 → 含末格在内全部 done(绿),末格不再用闪烁的 cur
// · 失败 → 停在第 no 格标红(fail),之前的 done
// · 进行中 → 已过的格 done,当前格 cur(一闪一闪),未到的格留空
function projProgClass(project: Project, i: number, no: number): string {
if (project.status === "completed") return i <= no ? "done" : "";
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
return i < no ? "done" : i === no ? "cur" : "";
}
function projBucket(project: Project) {
if (project.status === "completed") return "done";
if (project.status === "failed") return "fail";
if (project.status === "draft") return "draft";
return "wip";
}
function projStatusLabel(project: Project) {
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
}
// 失败态用现成 .pill.err(crimson),不存在 .pill.fail → 退化无色
function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); }
// 当前脚本版本:优先已采用,否则取第一版(轻量列表序列化可能不含 segments,故全程可选链)
function projScript(project: Project) {
const list = project.script_versions || [];
@@ -420,8 +422,6 @@ function projShotMeta(project: Project): string {
}
// 项目卡封面 = 该项目商品的主图(后端 cover_preview_url,取商品 cover_asset)。无图 → 占位。
// (原先按项目名关键词映射静态 mock 假图,与真实数据脱节,已废弃。)
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" },
{ filter: "draft", label: "草稿" },
@@ -431,26 +431,24 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
];
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频。", page: "projectWizard", image: "/exact/assets/mock/person-linxi.png" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成。", page: "projectWizard", image: "/exact/assets/mock/scene-cafe.png" },
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
{ title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "projectWizard", image: "/assets/yz/video-quick.jpg", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词。", page: "freeCreate", image: "/exact/assets/mock/scene-night-street.png" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品。", page: "freeCreate", image: "/exact/assets/mock/cover-coffee.png" },
{ title: "自由生成", desc: "用提示词与参考视频自由生成镜头。", page: "freeCreate", image: "/exact/assets/mock/scene-bedroom.png" },
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
{ title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "freeCreate", image: "/assets/yz/video-remix.jpg" },
{ title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
];
function projCardSub(project: Project): string {
function projCardSub(project: Project, productTitle: string): string {
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
return projStageLabel(project);
const shots = projShotMeta(project);
const stage = projStageLabel(project);
if (project.status === "completed") return `专业创作 · ${productTitle} · ${shots}`;
const no = projStageNo(project);
return `专业创作 · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`;
}
function projCardCta(project: Project): string {
if (project.status === "failed") return "重新拍摄";
if (project.status === "completed") return "查看";
return "继续";
}
// 项目卡「当前阶段」文案(对齐 YZ 稿:资产创建 / 视频生成)
function projStageLabel(project: Project): string {
if (project.status === "failed") return "失败";
const map: Record<string, string> = {
@@ -462,13 +460,6 @@ function projStageLabel(project: Project): string {
};
return map[project.current_stage] || "视频生成";
}
function projCardMeta(project: Project, productTitle: string): string {
const shots = projShots(project);
return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
}
function projRowCta(project: Project): string {
return project.status === "completed" ? "查看" : "继续";
}
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete, initialTab }: {
products: Product[];
@@ -496,11 +487,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [bulkConfirm, setBulkConfirm] = useState(false);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
useEffect(() => { if (!editMode) setSelected(new Set()); }, [editMode]);
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
@@ -529,7 +517,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
if (!(event.target as HTMLElement).closest(".vc-select")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
@@ -577,25 +565,35 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
}
return (
<section className="projects-page">
<section className={`projects-page${editMode ? " manage-mode" : ""}`}>
<header className="vc-head">
<h1>视频创作</h1>
<p>从商品出发,一站完成电商短视频生产</p>
<div>
<h1>视频创作</h1>
<p>从商品或参考视频出发,选择适合当前任务的生产方式</p>
</div>
<div className="vc-actions">
<button className={`vc-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<Settings2 />
<span>{editMode ? "完成" : "管理项目"}</span>
</button>
</div>
</header>
<section className="vc-sec">
<div className="vc-sec-h">
<h2>商品视频生产</h2>
<p>上传商品即可生成带货短视频</p>
<p>以商品卖点为核心,快速生成完整带货视频</p>
</div>
<div className="vc-row cols-2">
<div className="vc-tools two">
{VIDEO_MAKE.map((card) => (
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-visual"><img src={card.image} alt="" /></div>
<div className="vc-copy">
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
<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>
))}
@@ -605,204 +603,187 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<section className="vc-sec">
<div className="vc-sec-h">
<h2>AI 视频工具</h2>
<p>复刻参考片、替换商品或自由生成</p>
<p>自由生成,或复用参考视频中已经验证过的内容表达</p>
</div>
<div className="vc-row cols-3">
<div className="vc-tools three">
{VIDEO_TOOLS.map((card) => (
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-visual"><img src={card.image} alt="" /></div>
<div className="vc-copy">
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
<button className="vc-tool" 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-projects">
<div className="vc-sec-h">
<h2>视频项目</h2>
<p>查看视频任务状态与生成结果</p>
</div>
<div className="vc-sec-h vc-list-h">
<h2>视频项目</h2>
<p>统一查看各类视频任务的状态与生成结果</p>
</div>
<div className="vc-toolbar">
<div className="vc-pills" role="tablist" aria-label="项目状态">
{PROJ_TABS.map((t) => (
<button
type="button"
role="tab"
aria-selected={tab === t.filter}
className={`vc-pill${tab === t.filter ? " active" : ""}`}
key={t.filter}
onClick={() => setTab(t.filter)}
>
{t.label}
</button>
))}
<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-toolbar-right">
<div className="vc-search">
<Search size={14} />
<input placeholder="搜索项目名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</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">{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>
<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="vc-view">
<button type="button" className={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
<Grid2X2 size={14} />
</button>
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
<List size={14} />
</button>
<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} />
) : view === "grid" ? (
<div className="vc-grid">
{pageItems.map((project) => {
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const bucket = projBucket(project);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
className={`vc-proj${editMode && isSel ? " selected" : ""}`}
key={project.id}
role="button"
tabIndex={0}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
>
<div className={`vc-proj-visual${cover ? " has-img" : ""}`}>
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
</div>
<div className="vc-proj-body">
<div className="vc-proj-name">{project.name}</div>
<div className="vc-proj-sub">{projCardSub(project)}</div>
<div className="vc-proj-foot">
<span className={`vc-tag ${bucket}`}>{bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中"}</span>
<button
className="vc-proj-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{projCardCta(project)}
</button>
</div>
</div>
</article>
);
})}
) : filtered.length === 0 ? (
<div className="vc-empty" role="status">
<VideoOff />
<strong>{projects.length === 0 ? "还没有视频项目" : "没有符合条件的视频项目"}</strong>
<span>{projects.length === 0 ? "从上方入口开始生成" : "请调整状态、时间、分类或搜索关键词后重试"}</span>
</div>
) : (
<div className="proj-card-list" id="list-view">
{pageItems.map((project) => {
const no = projStageNo(project);
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
key={project.id}
className={`proj-card-row${editMode && isSel ? " selected" : ""}`}
data-status={projBucket(project)}
data-name={project.name}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
{editMode && (
<span className={`row-check${isSel ? " on" : ""}`} aria-hidden="true"><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>
)}
<div className={`placeholder proj-card-row-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="proj-card-row-main">
<div className="proj-card-row-title">{project.name}</div>
<div className="proj-card-row-sub">{projCardMeta(project, productTitle(project.product))}</div>
</div>
<div className="proj-card-row-stage">
<div className="proj-card-row-stage-lbl">当前阶段</div>
<div className="proj-card-row-stage-name">{projStageLabel(project)}</div>
<div className="prog proj-prog-yz">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
</div>
{!editMode && (
<>
<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="proj-card-row-cta"
className="card-del-btn"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
title="删除项目"
onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}
>
{projRowCta(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>
)}
{!editMode && (
<span className="row-more proj-card-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>
)}
</article>
);
})}
</div>
<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">专业创作</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} />
</>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && !loading && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
</section>
<div className={`bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量管理">
<span className="ct">已选 <b>{selected.size}</b> 项</span>
<button className="clear-sel" type="button" disabled={selected.size === 0} onClick={() => setSelected(new Set())}>清空</button>
<span className="sep" />
<button className="danger" type="button" disabled={selected.size === 0 || bulkDeleting} onClick={() => setBulkConfirm(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" 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>
{bulkDeleting ? "删除中…" : "删除所选"}
</button>
<button type="button" onClick={() => setEditMode(false)}>完成</button>
<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="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
<ConfirmModal open={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
<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>
);
}