Files
yingqing/core/frontend/src/routes/projects.tsx
T
zycandClaude Opus 4.8 ffada7caab fix: 测试清单(8) 新一轮 — ZWQ/PMC/设置/团队 多项修复
视频/工作台/商品库(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>
2026-06-29 16:45:29 +08:00

736 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}