视频/工作台/商品库(ZWQ): - row19 视频阶段移除已取消的【上传视频】按钮 (pipeline.tsx) - row20 工作台统计栏(总项目/进行中/成片)点击跳转视频项目对应 tab (dashboard/projects/route-config/App) - row21 最新项目跳转改用真实 projectId, 不再跳到最近打开项目 (dashboard.tsx) - row22+PMC28 网格/列表视图模式持久化(localStorage), 跨菜单记住用户选择 (projects/ai-tools + 新 hook use-view-mode.ts) - row24 已有三视图的演员/模特直接复用, 不再强制点AI生成三视图 (pipeline.tsx) 图片生成/设置(PMC): - row27 任务中心: 6个进行中状态补全归类, 不再把生成中误判为失败栏 (stage-config.ts) - row29 头像上传500: avatar_url 存公读直链替代超长预签名URL(URLField max_length=200溢出) (accounts/views.py, 需部署) - row30 显示名保存后回显: 改读 first_name 回退 username, 修刷新仍旧名 (settings.tsx) - row31/32 隐藏【创作默认】【显示】两个设置模块 (settings.tsx) 平台套图(YYX): - row3 图片预览铺满占位 (ai-tools-page.css) 团队: - PMC25 充值/邀请成员/设置月限额三弹窗禁止点遮罩误关 (team.tsx) 含平台套图线上提示词优化(services.py/views.py/api.ts, 前序会话遗留一并提交) 构建: tsc --noEmit=0, npm run build=0 (1755模块); 后端 py_compile OK 注: row29 头像/后端项需部署生效 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
736 lines
42 KiB
TypeScript
736 lines
42 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import type { CSSProperties, FormEvent } from "react";
|
||
import type { Asset, Product, Project } from "../types";
|
||
import type { Page } from "./route-config";
|
||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||
import { ProductCreateDrawer } from "../components/product-create-drawer";
|
||
import { Pager } from "../components/pager";
|
||
import { SkeletonRows } from "../components/loading";
|
||
import { useViewMode } from "../components/use-view-mode";
|
||
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 商品
|
||
|
||
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, onUploadImage }: {
|
||
products: Product[];
|
||
projects?: Project[];
|
||
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
|
||
preselectProductId?: string;
|
||
onBack: () => void;
|
||
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
|
||
// 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
|
||
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
|
||
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
|
||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
||
}) {
|
||
// 预选 id 命中商品库时用它,否则默认第一个商品
|
||
const initialProductId =
|
||
(preselectProductId && products.some((p) => p.id === preselectProductId) && preselectProductId) ||
|
||
products[0]?.id || "";
|
||
const [productId, setProductId] = useState(initialProductId);
|
||
const product = products.find((item) => item.id === productId) || products[0];
|
||
// 默认项目名跟随所选商品+脚本风格,版本号按已有同名项目自动递增(避免连建多个同名 v1);
|
||
// 用户手动改过名字后不再覆盖。
|
||
const [name, setName] = useState("");
|
||
const [nameTouched, setNameTouched] = useState(false);
|
||
|
||
// Step 1 · 商品选择器本地交互态
|
||
const [pickSearch, setPickSearch] = useState("");
|
||
const [pickCat, setPickCat] = useState("全部");
|
||
const [catOpen, setCatOpen] = useState(false);
|
||
const [pickView, setPickView] = useState<"grid" | "list">("grid");
|
||
const [pickPage, setPickPage] = useState(1);
|
||
|
||
// 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程)
|
||
const [npOpen, setNpOpen] = 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; }
|
||
setNpOpen(true);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 商品封面图:用后端内嵌的 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 || "";
|
||
};
|
||
|
||
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) => {
|
||
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">{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>
|
||
|
||
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
|
||
{onCreateProduct && (
|
||
<ProductCreateDrawer
|
||
open={npOpen}
|
||
close={() => setNpOpen(false)}
|
||
onCreate={onCreateProduct}
|
||
onUploadImage={onUploadImage}
|
||
// 新建成功 → 自动选中该商品进入第 1 步选中态
|
||
onCreated={(created) => setProductId(created.id)}
|
||
/>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 对齐 api-bridge / pipeline:阶段编号 / 状态分桶 / 友好标签 / pill 类
|
||
// V1 雪藏「拼接导出」第 5 阶段(代码保留,V2 恢复):流程现为 4 步,export 归并到第 4(视频),
|
||
// 已完成项目落到第 4 阶段而非被藏的第 5。(与 pipeline.tsx STAGE_STEPS / projectStage 对齐)
|
||
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) { 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] || "进行中";
|
||
}
|
||
// 失败态用现成 .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 || [];
|
||
return list.find((v) => v.is_adopted) || list[0];
|
||
}
|
||
// 真实镜数:优先后端片段计数 / 片段数组长度;均缺时回落脚本片段数,再回落 0(不造假)
|
||
function projShots(project: Project): number {
|
||
return project.video_segment_count ?? project.video_segments?.length ?? projScript(project)?.segments?.length ?? 0;
|
||
}
|
||
// 真实成片时长档:① 向导选的时长 id("0-15"→"0-15s") ② 脚本各镜时长求和("0-{n}s") ③ 视频片段目标时长求和 ④ 缺则空串(副标题只显示「N 镜」,不硬编码 0-60s)
|
||
function projDurationLabel(project: Project): string {
|
||
const wiz = project.metadata?.wizard?.duration;
|
||
if (wiz) return /s$/i.test(wiz) ? wiz : `${wiz}s`;
|
||
const scriptSecs = (projScript(project)?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
||
if (scriptSecs > 0) return `0-${Math.round(scriptSecs)}s`;
|
||
const videoSecs = (project.video_segments || []).reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
|
||
if (videoSecs > 0) return `0-${Math.round(videoSecs)}s`;
|
||
return "";
|
||
}
|
||
// 副标题:N 镜[ · 时长档](真实数据,缺时长档则只显示镜数)
|
||
function projShotMeta(project: Project): string {
|
||
const shots = projShots(project);
|
||
const dur = projDurationLabel(project);
|
||
return dur ? `${shots} 镜 · ${dur}` : `${shots} 镜`;
|
||
}
|
||
// 项目卡封面 = 该项目商品的主图(后端 cover_preview_url,取商品 cover_asset)。无图 → 占位。
|
||
// (原先按项目名关键词映射静态 mock 假图,与真实数据脱节,已废弃。)
|
||
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } 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, loading = false, navigate, openPipeline, onDelete, initialTab }: {
|
||
products: Product[];
|
||
projects: Project[];
|
||
loading?: boolean;
|
||
navigate: (page: Page) => void;
|
||
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
|
||
openPipeline: (projectId: string) => void;
|
||
onDelete: (projectId: string) => Promise<unknown> | void;
|
||
// 工作台统计块点击带入的初始 tab(all/wip/done/fail);变化时覆盖当前 tab。
|
||
initialTab?: string;
|
||
}) {
|
||
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "list");
|
||
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
|
||
// initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。
|
||
useEffect(() => {
|
||
if (initialTab && ["all", "wip", "done", "fail"].includes(initialTab)) {
|
||
setTab(initialTab as "all" | "wip" | "done" | "fail");
|
||
}
|
||
}, [initialTab]);
|
||
const [query, setQuery] = useState("");
|
||
const [editMode, setEditMode] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||
// 批量管理:编辑态多选集合 + 批量删除确认开关。退出编辑态自动清空选择。
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||
const [bulkConfirm, setBulkConfirm] = useState(false);
|
||
useEffect(() => {
|
||
document.body.classList.toggle("edit-mode", editMode);
|
||
return () => document.body.classList.remove("edit-mode");
|
||
}, [editMode]);
|
||
useEffect(() => { if (!editMode) setSelected(new Set()); }, [editMode]);
|
||
function toggleSelect(id: string) {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
}
|
||
const [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_version_count ?? 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;
|
||
}).sort((a, b) => {
|
||
// 倒序排列:新创建的项目排最前;created_at 缺失时回退 updated_at,再回退 id 字典序
|
||
const ta = a.created_at || a.updated_at || a.id;
|
||
const tb = b.created_at || b.updated_at || b.id;
|
||
return ta < tb ? 1 : ta > tb ? -1 : 0;
|
||
});
|
||
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
|
||
const [page, setPage] = useState(1);
|
||
useEffect(() => { setPage(1); }, [tab, query, catFilter, 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);
|
||
}
|
||
|
||
// 批量删除选中项目:逐个调 onDelete(App 端每删一项做一次刷新),完成后清空选择并关确认框
|
||
async function confirmBulkDelete() {
|
||
if (selected.size === 0 || bulkDeleting) return;
|
||
setBulkDeleting(true);
|
||
try {
|
||
for (const id of Array.from(selected)) await onDelete(id);
|
||
setSelected(new Set());
|
||
setBulkConfirm(false);
|
||
} finally {
|
||
setBulkDeleting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="projects-page">
|
||
<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">// {loading && projects.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</>}</div>
|
||
|
||
{loading && projects.length === 0 ? (
|
||
<SkeletonRows count={6} />
|
||
) : 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 cover = project.cover_preview_url || "";
|
||
const isSel = selected.has(project.id);
|
||
return (
|
||
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} className={editMode && isSel ? "row-selected" : undefined} onClick={() => (editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
|
||
<td>
|
||
<div className="proj-name-cell">
|
||
{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-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
|
||
<div><div className="proj-name">{project.name}</div><div className="proj-sub">{projShotMeta(project)}</div></div>
|
||
</div>
|
||
</td>
|
||
<td>{productTitle(project.product)}</td>
|
||
<td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
|
||
<td>
|
||
<div className="hstack">
|
||
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
|
||
<span className="muted-2 mono" style={{ fontSize: "12px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
|
||
</div>
|
||
</td>
|
||
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
|
||
<td className="muted-2">{projDate(project)}</td>
|
||
<td>
|
||
{/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
|
||
{!editMode && (
|
||
<div className="row-action">
|
||
<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 = project.cover_preview_url || "";
|
||
const no = projStageNo(project);
|
||
const isSel = selected.has(project.id);
|
||
return (
|
||
<article className={`proj-card${editMode && isSel ? " selected" : ""}`} key={project.id} onClick={() => (editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
|
||
<span className="card-check" 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 card-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
|
||
<div className="card-body">
|
||
<div>
|
||
<div className="card-name">{project.name}</div>
|
||
<div className="card-sub">{productTitle(project.product)} · {projShots(project)} 镜</div>
|
||
</div>
|
||
<div className="hstack">
|
||
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
|
||
<span className="muted-2 mono" style={{ fontSize: "10.5px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
|
||
</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")} />}
|
||
|
||
{/* 批量管理栏 · 吸底浮动(显隐绑「有无选中」)· 蓝本同 products-page */}
|
||
<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>
|
||
|
||
<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} />
|
||
</section>
|
||
);
|
||
}
|