修改部份ui

This commit is contained in:
Azmat@qq.com
2026-08-18 18:32:19 +08:00
parent dfe94a923e
commit 18b3927817
19 changed files with 2151 additions and 681 deletions
+125 -136
View File
@@ -4,6 +4,7 @@ import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
ArrowUpRight,
Bookmark,
Check,
ChevronDown,
@@ -48,6 +49,25 @@ const MODE_LABEL: Record<string, string> = {
cover: "平台套图",
image: "图片创作"
};
const MODE_TAG: Record<string, string> = {
model: "模特上身",
cover: "平台套图",
image: "自由创作",
};
function formatAfTime(iso: string) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
const pad = (n: number) => String(n).padStart(2, "0");
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const now = new Date();
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`;
const yest = new Date(now);
yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return `昨天 ${hm}`;
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${hm}`;
}
const STATUS_LABEL: Record<string, string> = {
succeeded: "已完成",
@@ -108,36 +128,33 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return () => { alive = false; };
}, []);
const taskImage = useMemo(() => {
const map: Record<string, string> = {};
const map: Record<string, { url: string; name: string }> = {};
for (const asset of assets) {
const taskId = asset.origin_task;
if (!taskId || map[taskId]) continue;
const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true));
if (file?.preview_url) map[taskId] = file.preview_url;
if (file?.preview_url) map[taskId] = { url: file.preview_url, name: asset.name || "" };
}
return map;
}, [assets]);
const cards = [
{
page: "modelPhoto" as Page,
tag: "[ MODEL · TRY-ON ]",
title: "模特上身图",
desc: "选择模特,AI 生成商品模特上身效果图",
cost: "≈ 20 积分 / 张"
title: "模特上身",
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
image: "/exact/assets/mock/person-linxi.png",
},
{
page: "platformCover" as Page,
tag: "[ PLATFORM · KIT ]",
title: "平台套图",
desc: "选择平台模板,AI 生成电商平台套图",
cost: "≈ 20 积分 / 张"
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
image: "/exact/assets/mock/cover-earbuds.png",
},
{
page: "imageOptimize" as Page,
tag: "[ IMAGE · STUDIO ]",
title: "图片创作",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
cost: "≈ 20 积分 / 张"
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
image: "",
}
];
@@ -145,8 +162,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
// 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。
// 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。
type TaskBatch = {
key: string; batchId: string | null; firstTaskId: string; label: string; mode: string;
count: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
key: string; batchId: string | null; firstTaskId: string; label: string; title: string; mode: string;
count: number; doneCount: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
};
const taskBatches = useMemo<TaskBatch[]>(() => {
const groups = new Map<string, AITask[]>();
@@ -159,27 +176,25 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
}
const out: TaskBatch[] = [];
for (const [key, tasks] of groups) {
const first = tasks[0];
const first = tasks[0] as AITask & { product_title?: string; prompt?: string };
const pills = tasks.map((t) => statusPill(t.status));
const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err";
const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), "");
const cover = tasks.map((t) => taskImage[t.id]).find(Boolean) || "";
out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!], mode: first.mode!, count: tasks.length, status, created_at: created, cover });
const coverHit = tasks.map((t) => taskImage[t.id]).find(Boolean);
const doneCount = pills.filter((p) => p === "ok").length;
const title = first.product_title || first.prompt?.slice(0, 24) || coverHit?.name || MODE_LABEL[first.mode!] || "图片任务";
out.push({
key, batchId: first.batch_id || null, firstTaskId: first.id,
label: MODE_LABEL[first.mode!] || "图片创作",
title, mode: first.mode!,
count: tasks.length, doneCount, status, created_at: created,
cover: coverHit?.url || "",
});
}
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
return out;
}, [aiTasks, taskImage]);
const counts = useMemo(() => {
const acc = { gen: 0, ok: 0, err: 0 };
for (const b of taskBatches) {
if (b.status === "ok") acc.ok += 1;
else if (b.status === "err") acc.err += 1;
else acc.gen += 1;
}
return acc;
}, [taskBatches]);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
@@ -187,8 +202,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useViewMode<"grid" | "list">("viewmode:tasks", "grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
// 任务中心分页:每页 8 条(4 列网格两行),筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 8;
const [taskPage, setTaskPage] = useState(1);
// 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大
const [openBatch, setOpenBatch] = useState<TaskBatch | null>(null);
@@ -232,7 +247,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
if (days > Number(timeFilter)) return false;
}
if (query) {
const hay = `${batch.label} ${batch.key}`.toLowerCase();
const hay = `${batch.label} ${batch.title} ${batch.key}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
@@ -245,140 +260,113 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return (
<div className="asset-factory">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 一键生成</span>
<span>·</span>
<span></span>
</div>
</div>
</div>
<header className="af-hero-head">
<h1></h1>
<p></p>
</header>
<div className="factory-hero">
<div className="af-hero">
{cards.map((card) => (
<article className="factory-card with-corners" key={card.title}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="factory-body">
<div className="factory-text">
<span className="factory-tag">{card.tag}</span>
<div className="factory-title">{card.title}</div>
<div className="factory-desc">{card.desc}</div>
{/* CTA 放在 factory-text 内(对齐 asset-factory.html):按钮紧跟描述,
而非被 factory-body 的 height:100% + margin-top:auto 顶到卡片最底部 */}
<div className="factory-cta">
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
<ArrowRight size={14} />
</button>
<span className="cost">[ {card.cost} ]</span>
</div>
</div>
<button className={`af-hero-card${card.image ? "" : " gradient"}`} type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="af-hero-visual">
{card.image ? <img src={card.image} alt="" /> : null}
</div>
</article>
<div className="af-hero-copy">
<span className="af-hero-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
</div>
</button>
))}
</div>
<div className="section-h">
<h2></h2>
<span className="sub-mono">
// {taskBatches.length} 批 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
</span>
</div>
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
<div className="tabs" id="tc-tabs">
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}> <span className="count">{taskBatches.length}</span></div>
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}> <span className="count">{counts.gen}</span></div>
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}> <span className="count">{counts.ok}</span></div>
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}> <span className="count">{counts.err}</span></div>
</div>
<div className="toolbar">
<div className="search-inline">
<Search size={14} />
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
<section className="af-recent">
<div className="af-recent-head">
<h2></h2>
<p></p>
</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">{TIME_OPTS.find((o) => o.value === timeFilter)?.label !== "全部时间" ? TIME_OPTS.find((o) => o.value === timeFilter)?.label : "时间"}</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 className="af-toolbar">
<div className="af-pills" role="tablist" aria-label="任务状态">
<button className={`af-pill${filter === "all" ? " active" : ""}`} type="button" onClick={() => setFilter("all")}></button>
<button className={`af-pill${filter === "gen" ? " active" : ""}`} type="button" onClick={() => setFilter("gen")}></button>
<button className={`af-pill${filter === "ok" ? " active" : ""}`} type="button" onClick={() => setFilter("ok")}></button>
<button className={`af-pill${filter === "err" ? " active" : ""}`} type="button" onClick={() => setFilter("err")}></button>
</div>
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter || "任务类型"}</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${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); 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 className="af-toolbar-right">
<div className="af-search">
<Search size={14} />
<input placeholder="搜索任务名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); 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>{t}
<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">{TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间"}</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 === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter || "全部任务"}</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${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); 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>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); 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>{t}
</div>
))}
</div>
</div>
<div className="af-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>
</div>
</div>
{(filter !== "all" || query || timeFilter !== "all" || typeFilter) && (
<button className="clear-filters" type="button" onClick={() => { setFilter("all"); setQuery(""); setTimeFilter("all"); setTypeFilter(""); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
)}
<span className="spacer" />
<div className="view-toggle">
<button type="button" className={view === "grid" ? "active" : ""} data-view="grid" onClick={() => setView("grid")}>
<Grid2X2 size={13} />
</button>
<button type="button" className={view === "list" ? "active" : ""} data-view="list" onClick={() => setView("list")}>
<List size={13} />
</button>
</div>
</div>
<div className="result-meta">
// 显示 {paged.length} / {visible.length} 批
</div>
{tasksLoading && aiTasks.length === 0 ? (
<SkeletonRows count={5} />
) : aiTasks.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO TASKS YET</div>
<div></div>
<div></div>
</div>
) : visible.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO MATCH</div>
<div></div>
</div>
) : view === "grid" ? (
<div className="history-grid">
<div className="af-grid">
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<article className="task-card history-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
<article className="af-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<div className={`placeholder${batch.cover ? " has-img" : ""}`}>{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label}</span>}</div>
<div className="history-body">
<div className="history-name">{batch.label}</div>
<div className="history-type">// {batch.count} 张</div>
<div className="history-foot">
<span className="mono">{(batch.created_at || "").slice(0, 10)}</span>
<span className={`pill ${batch.status}`}><span className="dot" />{statusLabel}</span>
<div className={`af-card-visual${batch.cover ? " has-img" : ""}`}>
{batch.cover ? <img src={batch.cover} alt={batch.title} loading="lazy" decoding="async" /> : null}
{batch.status === "info" ? (
<div className="af-card-prog"> · {batch.doneCount}/{batch.count}</div>
) : null}
</div>
<div className="af-card-body">
<div className="af-card-name">{batch.title}</div>
<div className="af-card-meta">{batch.status === "err" ? "生成失败" : `${formatAfTime(batch.created_at)} · ${batch.count}`}</div>
<div className="af-card-foot">
<span className={`af-tag ${batch.status}`}>{statusLabel}</span>
<span className={`af-tag type ${batch.mode}`}>{MODE_TAG[batch.mode] || batch.label}</span>
</div>
</div>
</article>
@@ -446,6 +434,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
</section>
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
{openBatch && createPortal(
+207 -102
View File
@@ -1,138 +1,243 @@
import { useEffect, useState } from "react";
import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import type { BillingSummary, Product, Project } from "../types";
import type { NavigateFn } from "./route-config";
import { api } from "../api";
import { SkeletonRows } from "../components/loading";
import { money, stageMeta, statusPill } from "./stage-config";
import type { NavigateFn, Page } from "./route-config";
import { IconKitSvg } from "../components/IconKitSvg";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
// 工作台「最近项目」进度条:4 段(与视频项目菜单一致)
// script→1 / base_assets→2 / storyboard→3 / video|export→4
// completed:全 done(绿);failed:当前格 fail(红),之前 done;其余:之前 done,当前 cur(橙闪)
const DASH_STAGE_TOTAL = 4;
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
type CreateTone = "blue-a" | "blue-b" | "light";
type DashTab = "all" | "wip" | "done";
const CREATE_GROUPS: Array<{
label: string;
cards: Array<{
title: string;
desc: string;
icon: string;
tone: CreateTone;
page: Page;
}>;
}> = [
{
label: "从商品开始",
cards: [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", icon: "wand", tone: "blue-a", page: "projectWizard" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", icon: "clapperboard", tone: "blue-b", page: "projectWizard" },
],
},
{
label: "从参考视频开始",
cards: [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", icon: "scan", tone: "light", page: "freeCreate" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", icon: "swap", tone: "light", page: "freeCreate" },
],
},
];
const DASH_STAGE_TOTAL = 5;
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
const DASH_TABS: Array<{ filter: DashTab; label: string }> = [
{ filter: "all", label: "全部" },
{ filter: "wip", label: "进行中" },
{ filter: "done", label: "已成片" },
];
function dashBucket(project: Project): DashTab {
return project.status === "completed" ? "done" : "wip";
}
function dashStageNo(project: Project) {
return project.status === "completed" ? DASH_STAGE_TOTAL : (DASH_STAGE_NO[project.current_stage] || 1);
}
function dashProgClass(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 DashProgress({ project }: { project: Project }) {
const no = project.status === "completed"
? DASH_STAGE_TOTAL
: (DASH_STAGE_NO[project.current_stage] || 1);
return (
<div className="prog">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={dashProgClass(project, i, no)} />
))}
</div>
);
function dashStageLabel(project: Project): string {
if (project.status === "failed") return "失败";
const map: Record<string, string> = {
script: "脚本",
base_assets: "资产创建",
storyboard: "故事板",
video: "视频生成",
export: "视频生成",
};
return map[project.current_stage] || "视频生成";
}
function dashCardMeta(project: Project, productTitle: string): string {
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
return ["专业创作", productTitle, shots ? `${shots}` : null].filter(Boolean).join(" / ");
}
// 与「视频项目」共用项目列表返回的商品主图;空值继续走现有 9:16 占位图。
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, loading = false, navigate, onCreateProduct }: {
export function Dashboard({
products,
projects,
productTotal: _productTotal,
projectTotal,
billing: _billing,
userName,
loading: _loading = false,
navigate,
}: {
products: Product[];
projects: Project[];
productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示
productTotal?: number;
projectTotal?: number;
billing: BillingSummary | null;
userName?: string;
loading?: boolean;
navigate: NavigateFn;
// YYX#11:工作台点「新建商品」直接弹抽屉(复用商品库同一组件),而非跳商品库
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
onCreateProduct?: (payload: unknown) => Promise<Product | null | undefined> | void;
}) {
// YYX#11:新建商品抽屉开关
const [createDrawer, setCreateDrawer] = useState(false);
const [tab, setTab] = useState<DashTab>("all");
const projCount = projectTotal ?? projects.length;
const prodCount = productTotal ?? products.length;
// 资产条目总数:与资产库六个 Tab 一致,包含按项目聚合的「视频成品」素材包。
const [assetCount, setAssetCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
Promise.all([api.assetSummary(), api.videoPacks()])
.then(([c, packs]) => {
if (!alive) return;
const imageCreations = c.image_creations ?? c.creations ?? 0;
setAssetCount((c.tryon || 0) + (c.kits || 0) + imageCreations + (c.video_creations || 0) + packs.length + (c.others || 0));
})
.catch(() => {});
return () => { alive = false; };
}, []);
const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const counts = {
all: projCount,
wip: running,
done: completed,
};
// 日期 mono:`// 06.19 · 周四`(对齐 V1 mono 注释风,补零到两位)
const now = new Date();
const pad2 = (n: number) => String(n).padStart(2, "0");
const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
const dateMono = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())} · 周${weekdays[now.getDay()]}`;
// 「总项目」delta = 本月新建数(真实,按 created_at 算)。
// 但 projects 仅首页分页数据;当存在未加载的项目(projCount > 已加载)时,本月数会少算 → 宁可不显示也不显假值。
const dateLabel = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())}`;
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
const newThisMonth = projects.filter((p) => p.created_at && new Date(p.created_at).getTime() >= monthStart).length;
const allProjectsLoaded = projects.length >= projCount;
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
const greetName = userName ? userName.split("@")[0] : "";
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const listed = useMemo(() => {
const rows = tab === "all" ? projects : projects.filter((project) => dashBucket(project) === tab);
return rows.slice(0, 3);
}, [projects, tab]);
// 冻结信息:真实「冻结 X 积分 / 共 Y 积分」,无 budget/已用字段,故不复刻 V1「已用/共」。
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
const balanceNum = Number(billing?.account.balance ?? 0);
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
const fundsTotal = balanceNum + reservedNum;
return (
<>
<div className="page-head">
<div style={{ flex: 1, minWidth: 0 }}>
<h1>{userName ? `${userName.split("@")[0]}` : ""}</h1>
<div className="sub">
<span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span>
<span className="dash-funds-mono" style={{ marginLeft: "auto" }} onClick={() => navigate("account")}>
{money(balanceNum)} · {money(reservedNum)} / {money(fundsTotal)}
</span>
<section className="dashboard-page">
<header className="dash-hero">
<h1>{greetName ? `${greetName}` : ""}</h1>
<p className="dash-hero-sub">
{dateLabel} · {running}
</p>
</header>
<section className="dash-create" aria-label="开始创作">
<h2></h2>
{CREATE_GROUPS.map((group) => (
<div className="dash-create-group" key={group.label}>
<div className="dash-create-label">{group.label}</div>
<div className="dash-create-row">
{group.cards.map((card) => (
<button
key={card.title}
className={`dash-create-card ${card.tone}`}
type="button"
onClick={() => navigate(card.page)}
>
<span className="dash-create-ic" aria-hidden="true">
<IconKitSvg name={card.icon} size={22} strokeWidth={1.7} />
</span>
<span className="dash-create-copy">
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
</span>
<span className="dash-create-arrow" aria-hidden="true">
<IconKitSvg name="arrowRight" size={18} strokeWidth={1.8} />
</span>
</button>
))}
</div>
</div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={() => (onCreateProduct ? setCreateDrawer(true) : navigate("products"))}>
<IconKitSvg name="productPlus" size={16} />
))}
</section>
<section className="dash-overview" aria-label="项目概览">
<div className="dash-overview-h"></div>
<div className="dash-overview-bar">
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "all" })}>
<span className="lbl"></span>
<span className="v">{projCount}</span>
</button>
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
<IconKitSvg name="clapperboard" size={16} />
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "wip" })}>
<span className="lbl"></span>
<span className="v">{running}</span>
</button>
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "done" })}>
<span className="lbl"></span>
<span className="v">{completed}</span>
</button>
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects")}>
<span className="lbl"></span>
<span className="v">+{newThisMonth}</span>
</button>
</div>
</div>
<div className="stats with-corners" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => {
const cover = project.cover_preview_url || "";
return <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className={`placeholder thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>;
})}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
{onCreateProduct && (
<ProductCreateDrawer
open={createDrawer}
close={() => setCreateDrawer(false)}
onCreate={onCreateProduct}
onCreated={() => { setCreateDrawer(false); navigate("products"); }}
/>
)}
</>
</section>
<section className="dash-mine" aria-label="我的项目">
<div className="dash-mine-head">
<h2></h2>
<button className="dash-mine-all" type="button" onClick={() => navigate("projects")}>
<IconKitSvg name="chevronRight" size={14} />
</button>
</div>
<div className="dash-mine-pills" role="tablist" aria-label="项目筛选">
{DASH_TABS.map((item) => (
<button
key={item.filter}
className={`dash-mine-pill${tab === item.filter ? " active" : ""}`}
type="button"
role="tab"
aria-selected={tab === item.filter}
onClick={() => setTab(item.filter)}
>
{item.label} {counts[item.filter]}
</button>
))}
</div>
<div className="dash-mine-list">
{listed.length === 0 ? (
<div className="dash-mine-empty"></div>
) : listed.map((project) => {
const no = dashStageNo(project);
const cover = project.cover_preview_url || "";
const openProject = () => navigate("pipeline", { projectId: project.id });
return (
<article
key={project.id}
className="dash-proj-card"
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
<div className={`placeholder dash-proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}>
<span className="ph-frame">9:16</span>
</div>
<div className="dash-proj-main">
<div className="dash-proj-title">{project.name}</div>
<div className="dash-proj-sub">{dashCardMeta(project, productTitle(project.product))}</div>
</div>
<div className="dash-proj-stage">
<div className="dash-proj-stage-lbl"></div>
<div className="dash-proj-stage-name">{dashStageLabel(project)}</div>
<div className="dash-proj-prog">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={dashProgClass(project, i, no)} />
))}
</div>
</div>
<button
className="dash-proj-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{project.status === "completed" ? "查看" : "继续"}
</button>
</article>
);
})}
</div>
</section>
</section>
);
}
export function KpiStat({ label, badge, value, delta, onClick }: { label: string; badge: string; value: string | number; delta?: string; onClick?: () => void }) {
const inner = <><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</>;
// 可点的统计块复用现有 .stat 类(与余额块一致),不新造样式/色值。
if (onClick) return <button className="stat" type="button" onClick={onClick}>{inner}</button>;
return <div className="stat">{inner}</div>;
}
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
return <a className="shortcut" onClick={onClick}><div className="ic"><IconKitSvg name={name} size={16} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></a>;
}
+6
View File
@@ -1766,6 +1766,12 @@ export function PipelinePage(props: {
onNotify?.("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
return;
}
// 与后端 video_digest.MAX_UPLOAD_BYTES 对齐;超了别发请求,否则网关直接 413。
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
if (file.size > VIDEO_DIGEST_MAX_BYTES) {
onNotify?.("error", "视频不能超过 200MB,请压缩后再传");
return;
}
setVideoDigestBusy(true);
pushMsg("user", `已上传视频《${file.name}`);
pushMsg("ai", "正在抽帧拆解这条视频,大约需要半分钟…");
+255 -199
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowUpRight, Grid2X2, List, Search } from "lucide-react";
import type { Asset, Product, Project, ScriptTemplate } from "../types";
import { api } from "../api";
import type { Page } from "./route-config";
@@ -11,7 +12,7 @@ import { SkeletonRows } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
const PROJ_PAGE_SIZE = 8; // 4 列网格两行
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
@@ -380,7 +381,12 @@ function projProgClass(project: Project, i: number, no: number): string {
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 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] || "进行中";
}
@@ -416,9 +422,53 @@ function projShotMeta(project: Project): string {
// (原先按项目名关键词映射静态 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: "失败" }
const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" },
{ filter: "draft", label: "草稿" },
{ filter: "wip", label: "生成中" },
{ filter: "done", label: "已完成" },
{ filter: "fail", label: "失败" },
];
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_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" },
];
function projCardSub(project: Project): string {
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
return projStageLabel(project);
}
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> = {
script: "脚本",
base_assets: "资产创建",
storyboard: "故事板",
video: "视频生成",
export: "视频生成",
};
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[];
@@ -431,12 +481,12 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
// 工作台统计块点击带入的初始 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");
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "grid");
const [tab, setTab] = useState<ProjTab>("all");
// initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。
useEffect(() => {
if (initialTab && ["all", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as "all" | "wip" | "done" | "fail");
if (initialTab && ["all", "draft", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as ProjTab);
}
}, [initialTab]);
const [query, setQuery] = useState("");
@@ -460,28 +510,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
}
// 成片播放:合成过的项目点播放键直接弹窗播成片,不再跳去视频生成页
const [playing, setPlaying] = useState<Project | null>(null);
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
const [openChip, setOpenChip] = useState<"" | "product" | "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 || "创建时间";
const timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间";
useEffect(() => {
if (!openChip) return;
@@ -492,21 +535,10 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
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;
@@ -520,7 +552,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
});
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, catFilter, sourceFilter, timeFilter]);
useEffect(() => { setPage(1); }, [tab, query, catFilter, 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);
@@ -546,192 +578,216 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
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>
<header className="vc-head">
<h1></h1>
<p></p>
</header>
<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)} />
<section className="vc-sec">
<div className="vc-sec-h">
<h2></h2>
<p></p>
</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 className="vc-row cols-2">
{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>
</div>
</button>
))}
</div>
</section>
<section className="vc-sec">
<div className="vc-sec-h">
<h2>AI </h2>
<p></p>
</div>
<div className="vc-row cols-3">
{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>
</div>
</button>
))}
</div>
</section>
<section className="vc-projects">
<div className="vc-sec-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>
<div className="vc-toolbar-right">
<div className="vc-search">
<Search size={14} />
<input placeholder="搜索项目名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</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 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>
</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: "11px" }}>{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="#"
title={project.final_video_url ? "播放成片" : "继续"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
}}
><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>
) : 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>
);
})}
</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 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="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 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>
<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>
{!editMode && (
<button
className="proj-card-row-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{projRowCta(project)}
</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>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
{filtered.length === 0 && !loading && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
</section>
{/* 批量管理栏 · 吸底浮动(显隐绑「有无选中」)· 蓝本同 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>
+3 -3
View File
@@ -375,13 +375,13 @@ export function SettingsPage({
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
<div className="sub"></div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={discardChanges} disabled={!isDirty || saving}></button>
<button className="btn btn-primary" type="button" onClick={handleSaveAll} disabled={!isDirty || saving}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
{isDirty ? <span className="save-count">· {dirtyCount} </span> : null}
</button>
</div>
@@ -459,7 +459,7 @@ export function SettingsPage({
<div className="lbl"></div>
<div className="val">
<input className="input" value={phone} onChange={(event) => patchDraft("phone", event.target.value)} placeholder="138****8000" />
<span className="switch-note">// 在「保存所有更」中一并提交</span>
<span className="switch-note">// 在「保存所有更」中一并提交</span>
</div>
</div>
<div className="form-row">