大量修改UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user