fix(core): 测试清单 行16-33 — 商品详情/视频项目/新建向导/图片生成 批量修复
商品库+详情(行16-23):AI素材空状态缺省页;三视图16:9+版本历史切换+采用按钮; 详情「生成视频」预勾选商品、快捷生成图带入商品;管理商品编辑态保留底部统计; 删除商品 icon+DELETE ITEM 文案、乐观删除防重复点击;卡片图铺满占位区。 视频项目+新建向导(行24-28):筛选脚本来源按真实数据渲染;新建项目内「创建新商品」 右侧抽屉+创建后自动选中;开始栏 sticky 常驻;默认选中商品即显示卖点、未填完禁用开始; 去掉脚本风格/人物设定区块。 图片生成工作台(行29-33):模特单选;生成中改 spinner 动态图标+按钮间距;多任务并行; 重跑追加批次而非覆盖+更多按钮 hover 气泡菜单+「加入资产库/取消」切换与提示弹窗; 多批次本地持久化恢复。 收尾:App.tsx 接 initialProductId/preselectProductId/onCreateProduct/onAdoptTriView; 全局 toast 自动消失(成功3s/错误5s)修复行17③/22「右上角提示一直挂着」。 tsc -b && vite build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ import {
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import { MediaLightbox, SuccessModal } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
@@ -454,6 +454,20 @@ const PLATFORM_OPTIONS = [
|
||||
{ id: "al", name: "1688", logo: "阿" }
|
||||
];
|
||||
|
||||
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态、加入资产库切换;支持多批并行跑。
|
||||
行31(并发提交) / 行32(重跑追加新批次 + 气泡菜单 + 加入/取消切换) / 行33(多批次持久化恢复)。 */
|
||||
type GenBatch = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
ratio: string;
|
||||
count: number;
|
||||
status: "generating" | "done" | "failed";
|
||||
results: Asset[];
|
||||
/** 批次级:是否已"加入资产库"(可来回切) */
|
||||
adopted: boolean;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export function ImageWorkbenchPage({
|
||||
mode,
|
||||
products,
|
||||
@@ -462,7 +476,8 @@ export function ImageWorkbenchPage({
|
||||
onBack,
|
||||
navigate,
|
||||
onGenerate,
|
||||
onResume
|
||||
onResume,
|
||||
initialProductId
|
||||
}: {
|
||||
mode: WorkMode;
|
||||
products: Product[];
|
||||
@@ -472,17 +487,22 @@ export function ImageWorkbenchPage({
|
||||
navigate?: (page: Page) => void;
|
||||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
|
||||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||||
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
||||
initialProductId?: string;
|
||||
}) {
|
||||
const meta = MODE_META[mode];
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
|
||||
const [ratio, setRatio] = useState(meta.ratio);
|
||||
const [style, setStyle] = useState("auto");
|
||||
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
||||
// 行29:模特/平台改单选(原 string[] 多选 → 单个 id;沿用数组结构最小改动,长度恒为 0/1)
|
||||
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [results, setResults] = useState<Asset[] | null>(null);
|
||||
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
|
||||
const [batches, setBatches] = useState<GenBatch[]>([]);
|
||||
// 加入资产库成功提示(行32④)
|
||||
const [adoptNotice, setAdoptNotice] = useState<{ count: number } | null>(null);
|
||||
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
|
||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// 生成结果图片放大预览
|
||||
@@ -514,49 +534,192 @@ export function ImageWorkbenchPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId, mode]);
|
||||
|
||||
// 行29:单选——再点同一张取消,点别张替换(长度恒为 0 或 1)
|
||||
function togglePick(id: string) {
|
||||
setPickedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
||||
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
|
||||
}
|
||||
|
||||
const ratioVar = ratio.replace(":", " / ");
|
||||
const candidateCount = Math.max(1, Number(count) || 4);
|
||||
const canGenerate = prompt.trim().length > 0 && !generating;
|
||||
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
|
||||
const canGenerate = prompt.trim().length > 0;
|
||||
const anyGenerating = batches.some((b) => b.status === "generating");
|
||||
|
||||
async function runGenerate() {
|
||||
if (!canGenerate) return;
|
||||
setGenerating(true);
|
||||
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
|
||||
已出图的批次直接回显;App 不可改,故"刷新时仍在跑"的批次只能尽力恢复(见回报)。 */
|
||||
const batchKey = `airshelf:imgwb:batches:${mode}`;
|
||||
function persistBatches(list: GenBatch[]) {
|
||||
try {
|
||||
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount });
|
||||
// 成功:渲染真图;失败(null):保留原占位,不改 results
|
||||
if (result?.assets) setResults(result.assets);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
// 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死)
|
||||
const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status }));
|
||||
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
|
||||
} catch {
|
||||
/* localStorage 不可用时静默降级 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 跨刷新恢复:读本地残留(由 App.generateImages 落盘),先回显已出的图,
|
||||
再对还在跑的任务继续轮询——刷新页面不再丢"生成中"与已出结果。 */
|
||||
async function runGenerate() {
|
||||
if (!canGenerate) return;
|
||||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
prompt: prompt.trim(),
|
||||
ratio,
|
||||
count: candidateCount,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now()
|
||||
};
|
||||
// 追加新批次(行32②:不覆盖,在下面新增一行)
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
try {
|
||||
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount });
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId
|
||||
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
|
||||
: b
|
||||
);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
|
||||
async function rerunBatch(src: GenBatch) {
|
||||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
prompt: src.prompt,
|
||||
ratio: src.ratio,
|
||||
count: src.count,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now()
|
||||
};
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
try {
|
||||
const result = await onGenerate({ prompt: src.prompt, mode, count: src.count });
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId
|
||||
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
|
||||
: b
|
||||
);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* 删除整个批次(行32③:批次气泡"删除当前批次") */
|
||||
function removeBatch(id: string) {
|
||||
setBatches((prev) => {
|
||||
const next = prev.filter((b) => b.id !== id);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/* 删除批次内单张图(行32③:单图气泡"删除") */
|
||||
function removeImageFromBatch(batchId: string, assetId: string) {
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId ? { ...b, results: b.results.filter((a) => a.id !== assetId) } : b
|
||||
);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/* 加入资产库 / 取消加入(行32④:批次级来回切 + 成功提示) */
|
||||
function toggleAdoptBatch(batchId: string) {
|
||||
setBatches((prev) => {
|
||||
const target = prev.find((b) => b.id === batchId);
|
||||
const willAdopt = !target?.adopted;
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||||
persistBatches(next);
|
||||
if (willAdopt && target) setAdoptNotice({ count: target.results.length });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/* 加入资产库 / 取消(单图级,行32④:单图气泡"加入资产库",来回切) */
|
||||
function toggleAdoptImage(batchId: string, _assetId: string) {
|
||||
// 单图采用以批次级 adopted 表达(后端无单图采用接口),提示单张
|
||||
setBatches((prev) => {
|
||||
const target = prev.find((b) => b.id === batchId);
|
||||
const willAdopt = !target?.adopted;
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||||
persistBatches(next);
|
||||
if (willAdopt) setAdoptNotice({ count: 1 });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次;
|
||||
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// 1) 多批次结果回显
|
||||
try {
|
||||
const raw = localStorage.getItem(batchKey);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number };
|
||||
if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) {
|
||||
const restored = (saved.batches || []).filter((b) => b.results && b.results.length);
|
||||
if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const })));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 残留解析失败则忽略 */
|
||||
}
|
||||
// 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报)
|
||||
try {
|
||||
const raw = localStorage.getItem(`airshelf:imgwb:${mode}`);
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; ts?: number };
|
||||
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return; // 1 小时前的残留不恢复
|
||||
if (saved.results && saved.results.length) setResults(saved.results);
|
||||
const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number };
|
||||
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return;
|
||||
const pending = saved.pending || [];
|
||||
if (pending.length && onResume) {
|
||||
setGenerating(true);
|
||||
const batchId = `b-resume-${Date.now()}`;
|
||||
setBatches((prev) => {
|
||||
// 已由多批次残留恢复过结果就不重复加占位
|
||||
if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ id: batchId, prompt: meta.promptTemplate(product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now() }
|
||||
];
|
||||
});
|
||||
onResume(mode, pending)
|
||||
.then((res) => {
|
||||
if (!cancelled && res?.assets) setResults(res.assets);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setGenerating(false);
|
||||
if (cancelled) return;
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b
|
||||
);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 残留解析失败则忽略,按初始态渲染 */
|
||||
/* 残留解析失败则忽略 */
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -565,50 +728,118 @@ export function ImageWorkbenchPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
const hasResults = !!(results && results.length > 0);
|
||||
const hasResults = batches.length > 0;
|
||||
|
||||
/* ── 共享:生成结果网格(§4.18 .gen-card · 三 mode 统一渲染真图)── */
|
||||
function renderResultGrid() {
|
||||
const cols = (results?.length ?? candidateCount) >= 4 ? 4 : 2;
|
||||
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */
|
||||
function renderBatchGrid(batch: GenBatch) {
|
||||
const generating = batch.status === "generating";
|
||||
const n = batch.results.length || batch.count;
|
||||
const cols = n >= 4 ? 4 : 2;
|
||||
const ratioBatchVar = batch.ratio.replace(":", " / ");
|
||||
return (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
<div
|
||||
className="gen-images"
|
||||
style={{ "--cols": cols, "--ratio": ratioVar } as React.CSSProperties}
|
||||
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
|
||||
>
|
||||
{(hasResults
|
||||
? results!.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url }))
|
||||
: Array.from({ length: candidateCount }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined }))
|
||||
).map(({ key, index, url }) => (
|
||||
{(batch.results.length
|
||||
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id }))
|
||||
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" }))
|
||||
).map(({ key, index, url, assetId }) => (
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
|
||||
{url ? (
|
||||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||||
) : (
|
||||
<div className="placeholder">
|
||||
<span className="ph-frame">
|
||||
{generating ? "生成中…" : `${ratio} · #${index + 1}`}
|
||||
</span>
|
||||
{generating ? (
|
||||
// 行30:生成中占位用旋转 spinner icon,不再是「生成中…」文字
|
||||
<span className="gen-loading" aria-label="生成中">
|
||||
<span className="spinner" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="gen-image-actions">
|
||||
<button className="gen-img-btn" type="button" title="重跑" disabled={generating} onClick={() => runGenerate()}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{url && (
|
||||
{url && (
|
||||
<div className="gen-image-actions">
|
||||
<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>
|
||||
{/* 行32③:hover「更多」出气泡菜单(加入资产库 / 删除),不再直接下载 */}
|
||||
<div className="gen-img-more">
|
||||
<button className="gen-img-btn" type="button" title="更多">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
<div className="gen-bubble">
|
||||
<button type="button" className="gen-bubble-item" onClick={() => toggleAdoptImage(batch.id, assetId)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeImageFromBatch(batch.id, assetId)}>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 批次列表(行31/32/33):每个批次一行卡片,底部一组操作 + 底部「更多」气泡 ── */
|
||||
function renderBatchList() {
|
||||
return (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{batches.map((batch) => {
|
||||
const generating = batch.status === "generating";
|
||||
const failed = batch.status === "failed";
|
||||
return (
|
||||
<div className="gen-card gen-batch-card" key={batch.id}>
|
||||
<div className="gen-batch-h">
|
||||
<span className="b-pic">{batch.count}×</span>
|
||||
<div className="b-meta">
|
||||
<div className="b-nm">{batch.count} 张 · {batch.ratio}</div>
|
||||
<div className="b-info">
|
||||
{generating ? "生成中" : failed ? "失败" : "已完成"}
|
||||
{batch.adopted && !generating && !failed ? " · 已加入资产库" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderBatchGrid(batch)}
|
||||
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
|
||||
<div className="gen-batch-actions">
|
||||
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => rerunBatch(batch)}>
|
||||
<RefreshCw size={13} />
|
||||
重跑
|
||||
</button>
|
||||
<button className="btn btn-sm" type="button" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
{batch.adopted ? <X size={13} /> : <Check size={13} />}
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
{/* 行32③:批次底部「更多」气泡(删除当前批次 / 加入资产库) */}
|
||||
<div className="gen-batch-more">
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
<div className="gen-bubble gen-bubble-up">
|
||||
<button type="button" className="gen-bubble-item" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
|
||||
<Trash2 size={13} />
|
||||
删除当前批次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -628,7 +859,7 @@ export function ImageWorkbenchPage({
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<button className="ic-new-conv" type="button" onClick={() => { setResults(null); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
|
||||
<button className="ic-new-conv" type="button" onClick={() => { setBatches([]); persistBatches([]); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
|
||||
<Plus size={13} />
|
||||
新对话
|
||||
</button>
|
||||
@@ -656,37 +887,53 @@ export function ImageWorkbenchPage({
|
||||
<div className="ic-stream">
|
||||
{hasResults ? (
|
||||
<div className="ic-stream-inner">
|
||||
<div className="ic-msg">
|
||||
<div className="ic-msg-prompt">
|
||||
<span className="quote">
|
||||
<Quote size={13} />
|
||||
</span>
|
||||
<div className="pt">
|
||||
<div className="pt-text">{prompt}</div>
|
||||
<div className="pt-tags">
|
||||
<span className="meta-chip">{ratio}</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{count} 张</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{STYLE_OPTIONS.find((s) => s.id === style)?.label || "默认"}</span>
|
||||
{/* 行31/32/33:每个批次独立一条对话,各自展示自己的图与操作 */}
|
||||
{batches.map((batch) => (
|
||||
<div className="ic-msg" key={batch.id}>
|
||||
<div className="ic-msg-prompt">
|
||||
<span className="quote">
|
||||
<Quote size={13} />
|
||||
</span>
|
||||
<div className="pt">
|
||||
<div className="pt-text">{batch.prompt}</div>
|
||||
<div className="pt-tags">
|
||||
<span className="meta-chip">{batch.ratio}</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{batch.count} 张</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gen-card">{renderBatchGrid(batch)}</div>
|
||||
<div className="gen-card-actions">
|
||||
<button className="btn btn-sm" type="button" disabled={batch.status === "generating"} onClick={() => rerunBatch(batch)}>
|
||||
<RefreshCw size={13} />
|
||||
重跑
|
||||
</button>
|
||||
<button className="btn btn-sm" type="button" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
{batch.adopted ? <X size={13} /> : <Check size={13} />}
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<div className="gen-batch-more">
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
<div className="gen-bubble gen-bubble-up">
|
||||
<button type="button" className="gen-bubble-item" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
|
||||
<Trash2 size={13} />
|
||||
删除当前批次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gen-card">{renderResultGrid()}</div>
|
||||
<div className="gen-card-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => setResults(null)}>
|
||||
<RefreshCw size={13} />
|
||||
重新编辑
|
||||
</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="下载全部" 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>
|
||||
</div>
|
||||
))}
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="ic-empty">
|
||||
@@ -752,12 +999,19 @@ export function ImageWorkbenchPage({
|
||||
预估 <span className="val">¥{(candidateCount * 0.1).toFixed(2)}</span>
|
||||
</span>
|
||||
<button className="send-btn" type="button" onClick={runGenerate} disabled={!canGenerate} title="生成">
|
||||
{generating ? <span className="spinner" aria-hidden /> : <ArrowRight size={15} />}
|
||||
<ArrowRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<SuccessModal
|
||||
open={!!adoptNotice}
|
||||
title="加入资产库"
|
||||
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
|
||||
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
|
||||
close={() => setAdoptNotice(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -990,17 +1244,18 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
|
||||
<div className="iw-cta">
|
||||
{/* 行31:并发提交——有批次在跑时仍可再次提交,只在提交瞬间不重复触发 */}
|
||||
<button className="btn btn-primary" type="button" onClick={runGenerate} disabled={!canGenerate}>
|
||||
{generating ? <span className="spinner" aria-hidden /> : <WandSparkles size={13} />}
|
||||
{generating ? "生成中…" : `立即生成 (预估 ¥${(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})`}
|
||||
<WandSparkles size={13} />
|
||||
立即生成 (预估 ¥{(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})
|
||||
</button>
|
||||
<div className="iw-cta-hint">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣</div>
|
||||
<div className="iw-cta-hint">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣{anyGenerating ? " · 有批次生成中,可继续提交" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右 · 结果预览 */}
|
||||
{/* 右 · 结果预览(行31/32/33:多批次列表) */}
|
||||
<div className="iw-preview">
|
||||
{!hasResults && !generating ? (
|
||||
{!hasResults ? (
|
||||
<div className="iw-pv-empty">
|
||||
<div className="mono">// EMPTY STATE</div>
|
||||
<div className="title">还没有生成结果</div>
|
||||
@@ -1013,7 +1268,7 @@ export function ImageWorkbenchPage({
|
||||
<div className="iw-pv-h">
|
||||
<Quote className="quote-icon" />
|
||||
<div className="pv-meta">
|
||||
<b>{count} 张</b>
|
||||
<b>{batches.length} 批</b>
|
||||
{mode === "model" ? ` · ${ratio}` : ""}
|
||||
</div>
|
||||
<div className="pv-line">
|
||||
@@ -1033,26 +1288,20 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="gen-card">{renderResultGrid()}</div>
|
||||
<div className="gen-card-actions">
|
||||
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => runGenerate()}>
|
||||
<RefreshCw size={13} />
|
||||
重跑
|
||||
</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="下载全部" 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>
|
||||
{renderBatchList()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SuccessModal
|
||||
open={!!adoptNotice}
|
||||
title="加入资产库"
|
||||
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
|
||||
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
|
||||
close={() => setAdoptNotice(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user