feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying

Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-09 14:46:42 +08:00
co-authored by Claude Opus 4.8
parent 92826dec14
commit 0873e724bf
29 changed files with 2393 additions and 423 deletions
+248 -65
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
@@ -52,6 +53,24 @@ function statusText(status: string) {
return STATUS_LABEL[status] || status;
}
// 下载图片:优先 fetch→blob 触发真实下载;跨域失败则回退到新标签打开(用户仍拿到图)
async function downloadImage(url: string, filename: string) {
try {
const res = await fetch(url, { mode: "cors" });
const blob = await res.blob();
const href = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(href);
} catch {
window.open(url, "_blank", "noopener");
}
}
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
const cards = [
{
@@ -88,7 +107,40 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
return acc;
}, [aiTasks]);
const visible = aiTasks.slice(0, 8);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("list");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
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 typeOptions = Array.from(new Set(aiTasks.map((t) => t.task_type).filter(Boolean)));
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
];
const visible = aiTasks.filter((task) => {
const pill = statusPill(task.status);
if (filter === "gen" && pill !== "info") return false;
if (filter === "ok" && pill !== "ok") return false;
if (filter === "err" && pill !== "err") return false;
if (typeFilter && task.task_type !== typeFilter) return false;
if (timeFilter !== "all" && task.created_at) {
const days = (Date.now() - new Date(task.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
}
if (query) {
const hay = `${TASK_TYPE_LABEL[task.task_type] || task.task_type} ${task.task_type} ${task.id}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
});
return (
<div className="asset-factory">
@@ -133,21 +185,63 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
</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">{aiTasks.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="搜索任务名" />
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{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>
</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 ? TASK_TYPE_LABEL[typeFilter] || 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>{TASK_TYPE_LABEL[t] || t}
</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="active">
<List size={13} />
列表
</button>
<button type="button">
<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>
@@ -160,6 +254,31 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
<div className="mono">// NO TASKS YET</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">
{visible.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
return (
<article className="task-card history-card" key={task.id}>
<div className="placeholder"><span className="ph-frame">{task.id.slice(0, 4)}</span></div>
<div className="history-body">
<div className="history-name">{typeLabel}</div>
<div className="history-type">// {task.task_type}</div>
<div className="history-foot">
<span className="mono">{(task.created_at || "").slice(0, 10)}</span>
<span className={`pill ${pill}`}><span className="dot" />{statusText(task.status)}</span>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="task-list-view">
<table className="t">
@@ -168,7 +287,8 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
<th style={{ width: "42%" }}>任务</th>
<th style={{ width: 160 }}>进度</th>
<th>状态</th>
<th style={{ width: 140 }}>任务 ID</th>
<th style={{ width: 120 }}>创建于</th>
<th style={{ width: 48 }} />
</tr>
</thead>
<tbody>
@@ -208,7 +328,8 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
{statusText(task.status)}
</span>
</td>
<td className="muted-2">{task.id.slice(0, 8)}</td>
<td className="muted-2 mono" style={{ fontSize: 11 }}>{(task.created_at || "").slice(0, 10)}</td>
<td />
</tr>
);
})}
@@ -243,7 +364,7 @@ const MODE_META: Record<
title: "模特上身图",
tag: "[ MODEL · TRY-ON ]",
desc: "选择模特和商品,生成电商模特上身图。",
ratio: "3:4",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
@@ -328,6 +449,19 @@ export function ImageWorkbenchPage({
const [pickedIds, setPickedIds] = useState<string[]>([]);
const [generating, setGenerating] = useState(false);
const [results, setResults] = useState<Asset[] | null>(null);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
const [gridQuery, setGridQuery] = useState("");
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
const [tbOpen, setTbOpen] = useState<"" | "time" | "model">("");
const [searchOpen, setSearchOpen] = useState(false);
function pickReference(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setRefImage({ name: file.name, url: URL.createObjectURL(file) });
event.target.value = "";
}
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
@@ -387,15 +521,19 @@ export function ImageWorkbenchPage({
</div>
)}
<div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="重跑">
<button className="gen-img-btn" type="button" title="重跑" disabled={generating} onClick={() => runGenerate()}>
<RefreshCw size={14} />
</button>
<button className="gen-img-btn" type="button" title="下载">
<Download size={14} />
</button>
<button className="gen-img-btn" type="button" title="更多">
<MoreHorizontal size={14} />
</button>
{url && (
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} />
</button>
)}
{url && (
<button className="gen-img-btn" type="button" title="查看原图" onClick={() => window.open(url, "_blank", "noopener")}>
<MoreHorizontal size={14} />
</button>
)}
</div>
</div>
))}
@@ -418,7 +556,7 @@ export function ImageWorkbenchPage({
返回
</button>
</div>
<button className="ic-new-conv" type="button">
<button className="ic-new-conv" type="button" onClick={() => { setResults(null); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
<Plus size={13} />
新对话
</button>
@@ -464,15 +602,15 @@ export function ImageWorkbenchPage({
</div>
<div className="gen-card">{renderResultGrid()}</div>
<div className="gen-card-actions">
<button className="btn btn-sm" type="button">
<button className="btn btn-sm" type="button" onClick={() => setResults(null)}>
<RefreshCw size={13} />
重新编辑
</button>
<button className="btn btn-sm" type="button">
<button className="btn btn-sm" type="button" onClick={() => navigate?.("library")}>
<Check size={13} />
全部加入资产库
</button>
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<button className="btn btn-sm btn-ghost" type="button" title="下载全部" onClick={() => results?.forEach((asset, i) => { const u = asset.files?.[0]?.preview_url; if (u) downloadImage(u, `${meta.title}-${i + 1}.png`); })}>
<MoreHorizontal size={13} />
</button>
</div>
@@ -501,9 +639,17 @@ export function ImageWorkbenchPage({
<div className="ic-input-wrap">
<div className="ic-input">
<div className="ic-input-top">
<button className="add-btn" type="button" title="上传参考图">
<button className="add-btn" type="button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
<Plus size={22} />
</button>
<input ref={refInputRef} type="file" accept="image/*" hidden onChange={pickReference} />
{refImage && (
<span className="meta-chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={refImage.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{refImage.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImage(null)}>×</button>
</span>
)}
</div>
<textarea
className="ic-input-text"
@@ -568,6 +714,12 @@ export function ImageWorkbenchPage({
</div>
<div className="iw-list-h">
<span className="mono">// 商品空间</span>
{navigate && (
<button className="new-prod" type="button" title="新建商品" onClick={() => navigate("productCreateUpload")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
<span>新建商品</span>
</button>
)}
</div>
<div className="iw-ps-list">
{products.length === 0 ? (
@@ -607,13 +759,33 @@ export function ImageWorkbenchPage({
</span>
</div>
<span className="spacer" />
<button className="search-btn" type="button" title="搜索">
<Search size={14} />
</button>
{mode === "model" && navigate && (
<button className="tb-chip" type="button" onClick={() => navigate("modelPhotoDemoA")}>
方案 A
<div className="tb-search-wrap">
{searchOpen && (
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索平台"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title={mode === "model" ? "搜索批次/模特" : "搜索"} onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
</button>
</div>
{mode === "model" && (
<>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "time" ? " open" : ""}`} data-filter="time">
<button className={`tb-chip${gridSort === "name" ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "time" ? "" : "time"))}><span className="lbl">{gridSort === "name" ? "按名称" : "时间"}</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="chip-menu align-right">
<div className={`mi${gridSort === "recent" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("recent"); setTbOpen(""); }}><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>
<div className={`mi${gridSort === "name" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("name"); setTbOpen(""); }}><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>
</div>
</div>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "model" ? " open" : ""}`} data-filter="model">
<button className={`tb-chip${gridQuery ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "model" ? "" : "model"))}><span className="lbl">{gridQuery || "模特"}</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="chip-menu align-right">
<div className={`mi${!gridQuery ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridQuery(""); setTbOpen(""); }}><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>
{(personAssets.length > 0 ? personAssets.map((p) => p.name) : FALLBACK_MODELS.map((m) => m.name)).map((nm) => (
<div className={`mi${gridQuery === nm ? " selected" : ""}`} key={nm} role="button" tabIndex={0} onClick={() => { setGridQuery(nm); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{nm}</div>
))}
</div>
</div>
</>
)}
</div>
@@ -629,7 +801,11 @@ export function ImageWorkbenchPage({
</div>
<div className="model-grid">
{personAssets.length > 0
? personAssets.slice(0, 6).map((item) => {
? personAssets
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : (b.created_at || "").localeCompare(a.created_at || "")))
.slice(0, 6)
.map((item) => {
const url = item.files?.[0]?.preview_url;
return (
<button
@@ -655,7 +831,10 @@ export function ImageWorkbenchPage({
</button>
);
})
: FALLBACK_MODELS.map((item) => (
: FALLBACK_MODELS
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : 0))
.map((item) => (
<button
type="button"
key={item.id}
@@ -681,7 +860,7 @@ export function ImageWorkbenchPage({
<span className="title">选择平台</span>
</div>
<div className="platform-grid">
{PLATFORM_OPTIONS.map((item) => (
{PLATFORM_OPTIONS.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())).map((item) => (
<button
type="button"
key={item.id}
@@ -867,7 +1046,11 @@ const DEMO_SIDE_PRODUCTS = [
{ id: "d6", title: "小熊 4L 可视空气炸锅", category: "家居家电", batches: 0 }
];
export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A" | "B"; products: Product[]; onBack: () => void }) {
export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { variant: "A" | "B"; products: Product[]; onBack: () => void; navigate?: (page: Page) => void }) {
// 方案 A/B 是设计展示页(mock 数据,无生成后端);所有动作按钮导向真实工具 / 改本地选中态,杜绝死按钮
const [demoCount, setDemoCount] = useState("4 张");
const [demoRatio, setDemoRatio] = useState("3:4");
const toRealTool = () => onBack();
// 左栏商品空间:优先真 products(最近 6 条),空则回退基线占位
const sideProducts =
products.length > 0
@@ -889,7 +1072,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-side-h">
<div className="ti-row">
<span className="ti">商品空间</span>
<button className="add" type="button" title="新建商品">
<button className="add" type="button" title="新建商品" onClick={() => navigate?.("productCreateUpload")}>
<Plus size={11} />
</button>
</div>
@@ -916,7 +1099,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
))}
</div>
<button className="dm-all" type="button">
<button className="dm-all" type="button" onClick={() => navigate?.("products")}>
<LayoutGrid size={12} />
全部商品
<span className="ct">{totalCount} 个</span>
@@ -987,7 +1170,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-field-h">生成张数</div>
<div className="dm-chip-row">
{["1 张", "2 张", "4 张", "8 张"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === "4 张" ? "active" : ""}`}>
<button type="button" key={label} className={`dm-chip ${label === demoCount ? "active" : ""}`} onClick={() => setDemoCount(label)}>
{label}
</button>
))}
@@ -998,7 +1181,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-field-h">画面比例</div>
<div className="dm-chip-row">
{["1:1", "3:4", "9:16", "16:9"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === "3:4" ? "active" : ""}`}>
<button type="button" key={label} className={`dm-chip ${label === demoRatio ? "active" : ""}`} onClick={() => setDemoRatio(label)}>
{label}
</button>
))}
@@ -1016,7 +1199,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<span>预估扣费 <span className="v">≈ ¥1.20</span></span>
<span>余额 ¥327.40</span>
</div>
<button className="dm-gen" type="button">
<button className="dm-gen" type="button" onClick={toRealTool}>
<Sparkles size={15} />
立即生成 · {active.title} × Ava
</button>
@@ -1042,9 +1225,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@@ -1069,9 +1252,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@@ -1096,7 +1279,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="取消"><X size={13} /></button>
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@@ -1140,10 +1323,10 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
<span className="spacer" />
<div className="dm-tb">
<button className="icbtn" type="button" title="搜索批次"><Search size={13} /></button>
<button className="chip" type="button">时间 <ChevronDown size={10} /></button>
<button className="chip" type="button">状态 <ChevronDown size={10} /></button>
<button className="chip" type="button">模特 <ChevronDown size={10} /></button>
<button className="icbtn" type="button" title="搜索批次" onClick={toRealTool}><Search size={13} /></button>
<button className="chip" type="button" onClick={toRealTool}>时间 <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}>状态 <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}>模特 <ChevronDown size={10} /></button>
</div>
</div>
</div>
@@ -1168,9 +1351,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@@ -1193,9 +1376,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@@ -1218,7 +1401,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="取消"><X size={13} /></button>
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@@ -1245,9 +1428,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@@ -1274,7 +1457,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="删除"><Trash2 size={13} /></button>
</div>
</div>
@@ -1288,28 +1471,28 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
{/* 底部 fixed 参数面板 */}
<div className="dm-param-wrap">
<div className="dm-param">
<button className="pchip active" type="button">
<button className="pchip active" type="button" onClick={toRealTool}>
<span className="lbl-mono">模特</span>
<span>Ava</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono">张数</span>
<span>4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono">比例</span>
<span>3:4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono">补充提示词</span>
<span className="muted">+ 添加</span>
</button>
<span className="spacer" />
<span className="meta-right">预估 <span className="v">¥1.20</span> · 余额 <span className="v">¥327.40</span></span>
<button className="gen-btn" type="button">
<button className="gen-btn" type="button" onClick={toRealTool}>
<Sparkles size={14} />
生成 · {active.title} × Ava
</button>