fix: 测试清单遗留bug批量修复(15条:商品库/消费/团队/设置/消息中心/平台套图/资产库)

- R6 商品图删除不生效: 封面asset移交+绕开prefetch缓存, 前端封面缩图补删除入口
- R48 消息中心长文本与UI框错位: 胶囊单行省略+优先级标签禁压缩
- R70 账单流水筛选: 后端加ledger_type过滤, count随筛选变化, 切筛选重置页码
- R72 月限额改后不刷新: 团队页/消费页自取最新teamSettings, 两页同源
- R83/R86 平台套图提示词框加大+模型胶囊嵌入输入框(复用Pill组件)
- R96 未读角标: 修KeyTextTransform注解过滤触发MySQL 3141致接口500
- R100 工作台记录丢失: 新增GET /api/ai/tasks/workbench/ 同源恢复, 废除localStorage方案
- R104 卖点添加按钮移出输入框, 新建抽屉+编辑态统一
- R106 商品库宽屏锁5列, 10个/页铺满两行
- R108 资产删除改软删+单张删除+回收站二次确认; trash页商品/资产双分区+restore/purge端点
- R109 生成图自动入资产库(person演员立绘除外), 迁移0008存量回填, 移除全部加入资产库入口
- R65 无需改动(22268d6已修, 测旧部署所致)

验证: tsc/build/check/makemigrations --check 通过, 后端测试210/210, perf-probe PASS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-02 11:30:06 +08:00
co-authored by Claude Fable 5
parent 22268d608b
commit aab4d7ea4c
28 changed files with 924 additions and 476 deletions
+180 -283
View File
@@ -27,11 +27,11 @@ import {
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product } from "../types";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import { api } from "../api";
import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading";
import { MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import { useViewMode } from "../components/use-view-mode";
import type { Page } from "./route-config";
@@ -214,36 +214,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return () => document.removeEventListener("click", close);
}, [openChip]);
// row38:任务中心批次弹窗 —— 每张生成图都可「加入资产库 / 取消加入」,真源是后端 Asset.in_library。
// 乐观更新 batchImgs(弹窗)+ assets(列表 cover 用),失败回滚。资产生成时已落库扣费,此口只控是否进资产库列表、不二次扣费。
const [libBusy, setLibBusy] = useState(false);
function patchInLib(ids: Set<string>, next: boolean) {
setBatchImgs((prev) => prev.map((a) => (ids.has(a.id) ? { ...a, in_library: next } : a)));
setAssets((prev) => prev.map((a) => (ids.has(a.id) ? { ...a, in_library: next } : a)));
}
async function toggleBatchImgLibrary(asset: Asset) {
if (!asset.id || libBusy) return;
const next = !asset.in_library;
const ids = new Set([asset.id]);
patchInLib(ids, next);
try { await api.setAssetLibrary(asset.id, next); }
catch { patchInLib(ids, !next); }
}
// 一键:有未入库 → 全部加入;已全部入库 → 全部移出
const batchWithId = batchImgs.filter((a) => a.id);
const allInLib = batchWithId.length > 0 && batchWithId.every((a) => a.in_library);
async function toggleAllBatchLibrary() {
if (!batchWithId.length || libBusy) return;
const next = !allInLib;
const targets = batchWithId.filter((a) => a.in_library !== next).map((a) => a.id);
if (!targets.length) return;
const ids = new Set(targets);
setLibBusy(true);
patchInLib(ids, next);
try { await Promise.all(targets.map((id) => api.setAssetLibrary(id, next))); }
catch { patchInLib(ids, !next); }
finally { setLibBusy(false); }
}
// R109:生成图已「自动入资产库」,任务中心批次弹窗不再提供加入/取消入库操作(纯查看 + 放大)。
const typeOptions = Array.from(new Set(taskBatches.map((b) => b.label).filter(Boolean)));
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
@@ -484,13 +455,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
<div className="ic-m"><LayoutGrid size={17} /></div>
<div className="ti">{openBatch.label}<span>// {openBatch.count} 张 · {(openBatch.created_at || "").slice(0, 10)}</span></div>
<span style={{ flex: 1 }} />
{/* row38:一键全部加入资产库 / 全部移出(整批切换) */}
{batchWithId.length > 0 && (
<button className={`btn btn-sm${allInLib ? "" : " btn-primary"}`} type="button" disabled={libBusy} style={{ marginRight: 8 }} onClick={toggleAllBatchLibrary}>
<Bookmark size={13} />
{allInLib ? "全部移出资产库" : "一键全部加入资产库"}
</button>
)}
{/* R109:成图自动入资产库,批次弹窗不再放「一键加入/移出资产库」按钮 */}
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
</div>
<div className="modal-b">
@@ -502,30 +467,13 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
<div className="gen-images" style={{ "--cols": Math.min(4, batchImgs.length), "--ratio": "1 / 1" } as React.CSSProperties}>
{batchImgs.map((a, i) => {
const u = a.files?.find((f) => f.preview_url)?.preview_url || a.files?.[0]?.preview_url || "";
const inLib = !!a.in_library;
return (
<div className={`gen-image${inLib && u ? " adopted" : ""}`} key={a.id}>
<div className="gen-image" key={a.id}>
{u ? (
<img className="gen-image-img" src={u} alt={a.name} loading="lazy" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: u, name: a.name })} />
) : (
<div className="placeholder"><span className="ph-frame">#{i + 1}</span></div>
)}
{/* row38:左上「已入库」绿标 + 右下角加入/取消按钮(单图粒度) */}
{u && inLib && (
<span className="gen-adopt-badge"><Check size={11} />已入库</span>
)}
{u && (
<button
type="button"
className={`tc-lib-btn${inLib ? " on" : ""}`}
title={inLib ? "取消加入资产库" : "加入资产库"}
disabled={libBusy}
onClick={() => toggleBatchImgLibrary(a)}
>
<Bookmark size={13} />
{inLib ? "取消" : "加入资产库"}
</button>
)}
</div>
);
})}
@@ -641,8 +589,8 @@ function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: s
);
}
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态、加入资产库切换;支持多批并行跑。
行31(并发提交) / 行32(重跑追加新批次 + 气泡菜单 + 加入/取消切换) / 行33(多批次持久化恢复)。 */
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。
行31(并发提交) / 行32(重跑追加 + 气泡菜单) / R100(批次从后端持久任务恢复) / R109(成图自动入库)。 */
type GenBatch = {
id: string;
prompt: string;
@@ -650,8 +598,6 @@ type GenBatch = {
count: number;
status: "generating" | "done" | "failed";
results: Asset[];
/** 批次级:是否已"加入资产库"(可来回切) */
adopted: boolean;
ts: number;
/** 该批次归属的商品(用于按商品分组,各商品一个导航头) */
productId?: string;
@@ -664,10 +610,15 @@ type GenBatch = {
platformIds?: string[];
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
refs?: { name: string; url: string }[];
/** 该批次已提交的生图任务 id:落盘后切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
pendingIds?: string[];
};
/* R100:后端 platform_id(douyin/taobao…) → 前端平台 key(dy/tb…),恢复批次时还原平台分组 */
const PLATFORM_KEY_MAP: Record<string, string> = Object.fromEntries(
Object.entries(PLATFORM_ID_MAP).map(([key, canonical]) => [canonical, key])
);
export function ImageWorkbenchPage({
mode,
products,
@@ -715,7 +666,6 @@ export function ImageWorkbenchPage({
const [genModel, setGenModel] = useState<string>(() => {
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
});
const [genModelOpen, setGenModelOpen] = useState(false);
useEffect(() => { try { localStorage.setItem(GEN_MODEL_KEY, genModel); } catch { /* 忽略 */ } }, [genModel]);
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
@@ -872,22 +822,9 @@ export function ImageWorkbenchPage({
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
已出图的批次直接回显;App 不可改,故"刷新时仍在跑"的批次只能尽力恢复(见回报)。 */
const batchKey = `airshelf:imgwb:batches:${mode}`;
function persistBatches(list: GenBatch[]) {
try {
// 已出图/失败的批次照常落盘;仍在跑(generating)但已拿到 pendingIds 的批次也落盘(保留 generating + pendingIds),
// 这样切走再回来每一批都能按各自的 pendingIds 续轮询,不再「数量少 / 只恢复一批」(PMC#5/#10)。
// 还没拿到 pendingIds 的占位批次(刚点、尚未提交成功)不落盘,避免恢复出一个永远空转的壳。
const slim = list
.filter((b) => b.status !== "generating" || (b.pendingIds && b.pendingIds.length))
.map((b) => ({ ...b, refs: undefined })); // refs 含本地 blob url,不落盘
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
} catch {
/* localStorage 不可用时静默降级 */
}
}
/* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、
工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect),
image 模式仍由后端「对话」驱动 —— 两边与任务中心同源(同一批 AITask)。 */
/* ════════ 图片创作「对话」实体的增删改查 + 历史回填 ════════ */
@@ -903,27 +840,67 @@ export function ImageWorkbenchPage({
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
const assets = list.flatMap((t) => t.assets || []);
const allTerminal = list.every((t) => TERMINAL.has(t.status));
// R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []);
const allTerminal = live.every((t) => TERMINAL.has(t.status));
const status: GenBatch["status"] = assets.length > 0 ? "done" : allTerminal ? "failed" : "generating";
const withId = assets.filter((a) => a.id);
result.push({
id: key,
prompt: list[0]?.prompt || "",
ratio: list[0]?.ratio || meta.ratio,
count: list.length,
prompt: live[0]?.prompt || "",
ratio: live[0]?.ratio || meta.ratio,
count: live.length,
status,
results: assets,
adopted: withId.length > 0 && withId.every((a) => a.in_library),
ts: new Date(list[0]?.created_at || Date.now()).getTime(),
ts: new Date(live[0]?.created_at || Date.now()).getTime(),
// 该批用过的参考图(后端按 reference_image_ids 解析回 {name,url}),切换/刷新后仍可见
refs: (list[0]?.reference_images || []).length ? list[0].reference_images : undefined,
refs: (live[0]?.reference_images || []).length ? live[0].reference_images : undefined,
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
return result.sort((a, b) => a.ts - b.ts);
}
/* R100:后端工作台任务 → 前端批次(与 batchesFromConvTasks 同思路,多带商品/模特/平台归属)。
仍在跑的任务留在 pendingIds,挂载后按批续轮询;成功但成图已全删的任务不回显(R109 绑定)。 */
function batchesFromWorkbenchTasks(tasks: WorkbenchTask[]): GenBatch[] {
const groups = new Map<string, WorkbenchTask[]>();
for (const t of tasks) {
const key = t.batch_id || t.id;
const list = groups.get(key) || [];
list.push(t);
groups.set(key, list);
}
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []);
const hasRunning = live.some((t) => !TERMINAL.has(t.status));
const status: GenBatch["status"] = hasRunning ? "generating" : assets.length ? "done" : "failed";
const first = live[0];
const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : "";
result.push({
id: key,
prompt: first.prompt || "",
ratio: first.ratio || meta.ratio,
count: live.length,
status,
results: assets,
ts: new Date(first.created_at || Date.now()).getTime(),
productId: first.product_id || undefined,
modelId: first.model_id || undefined,
platformIds: platformKey ? [platformKey] : undefined,
// 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results,
// 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。
pendingIds: hasRunning ? live.map((t) => t.id) : undefined,
});
}
return result.sort((a, b) => a.ts - b.ts);
}
// 拉某对话的历史批次并回显;非终态批次继续轮询补齐
const loadConvBatches = useCallback(async (convId: string) => {
try {
@@ -1039,18 +1016,10 @@ export function ImageWorkbenchPage({
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
if (opts.reuseBatchId) {
// 全批重跑:把原卡复位为「生成中」,清掉旧(失败/旧)结果,保留其归属与参数
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b));
persistBatches(next);
return next;
});
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b)));
} else if (opts.appendToBatchId) {
// 单图重跑:原卡转「生成中」补图,保留已有好图
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b));
persistBatches(next);
return next;
});
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b)));
} else {
const newBatch: GenBatch = {
id: batchId,
@@ -1059,7 +1028,6 @@ export function ImageWorkbenchPage({
count: opts.count,
status: "generating",
results: [],
adopted: false,
ts: Date.now(),
productId: opts.productId,
productTitle: opts.productTitle,
@@ -1091,37 +1059,29 @@ export function ImageWorkbenchPage({
referenceImageIds = ids.filter((x): x is string => !!x);
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10)
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) });
onSubmitted: (taskIds) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b))) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
setActiveConvId(convId);
void loadConversations(false);
}
setBatches((prev) => {
const next = prev.map((b) => {
if (b.id !== batchId) return b;
const newAssets = result?.assets || [];
// 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果
if (opts.appendToBatchId) {
const seen = new Set(b.results.map((a) => a.id));
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))];
return { ...b, status: merged.length ? ("done" as const) : ("failed" as const), results: merged };
}
return { ...b, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets };
});
persistBatches(next);
return next;
});
setBatches((prev) => prev.map((b) => {
if (b.id !== batchId) return b;
const newAssets = result?.assets || [];
// 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果
if (opts.appendToBatchId) {
const seen = new Set(b.results.map((a) => a.id));
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))];
return { ...b, status: merged.length ? ("done" as const) : ("failed" as const), results: merged, pendingIds: undefined };
}
return { ...b, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
}));
} catch {
setBatches((prev) => {
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
const next = prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b));
persistBatches(next);
return next;
});
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b)));
}
}
@@ -1185,68 +1145,29 @@ export function ImageWorkbenchPage({
});
}
/* 删除整个批次(行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;
});
}
/* 单图 in_library 写库 + 本地乐观更新 + 失败回滚的公共逻辑。
真源是后端 Asset.in_library:加入 → 出现在资产库列表;取消 → 从列表移出。batch.adopted 同步成「整批是否全已入库」。 */
function applyLibraryState(batchId: string, assetIds: string[], next: boolean) {
const ids = new Set(assetIds.filter(Boolean));
if (!ids.size) return;
const sync = (b: GenBatch, want: (a: Asset) => boolean): GenBatch => {
const results = b.results.map((a) => (ids.has(a.id) ? { ...a, in_library: want(a) } : a));
const withId = results.filter((a) => a.id);
return { ...b, results, adopted: withId.length > 0 && withId.every((a) => a.in_library) };
};
setBatches((prev) => {
const updated = prev.map((b) => (b.id === batchId ? sync(b, () => next) : b));
persistBatches(updated);
return updated;
});
void Promise.all([...ids].map((id) => api.setAssetLibrary(id, next))).catch(() => {
// 写库失败:回滚到改前状态(按 id 翻回 !next)
setBatches((prev) => {
const reverted = prev.map((b) => (b.id === batchId ? sync(b, () => !next) : b));
persistBatches(reverted);
return reverted;
});
});
}
/* 加入资产库 / 取消加入(批次级:全部已入库 → 整批移出,否则整批加入) */
function toggleAdoptBatch(batchId: string) {
const batch = batches.find((b) => b.id === batchId);
/* R108/R109:工作台的「删除」= 真删资产(软删进回收站)—— 生成记录与资产库数据绑定,
删了才不会刷新后从后端记录里"复活"。删除前二次确认(ConfirmModal),文案与资产库一致。
confirmDel.assetId 有值 = 删单张;无值 = 删整批(该批全部存活成图)。 */
const [confirmDel, setConfirmDel] = useState<{ batchId: string; assetId?: string } | null>(null);
function doConfirmedDelete() {
const target = confirmDel;
setConfirmDel(null);
if (!target) return;
const batch = batches.find((b) => b.id === target.batchId);
if (!batch) return;
const withId = batch.results.filter((a) => a.id);
if (!withId.length) return;
const next = !withId.every((a) => a.in_library);
applyLibraryState(batchId, withId.map((a) => a.id), next);
}
/* 加入资产库 / 取消(单图级:只改被点的那一张) */
function toggleAdoptImage(batchId: string, assetId: string) {
if (!assetId) return;
const asset = batches.find((b) => b.id === batchId)?.results.find((a) => a.id === assetId);
if (!asset) return;
const next = !asset.in_library;
applyLibraryState(batchId, [assetId], next);
const ids = (target.assetId ? [target.assetId] : batch.results.map((a) => a.id)).filter(Boolean);
ids.forEach((id) => { void api.deleteAsset(id).catch(() => undefined); });
if (target.assetId) {
// 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除
setBatches((prev) => prev.flatMap((b) => {
if (b.id !== target.batchId) return [b];
const results = b.results.filter((a) => a.id !== target.assetId);
if (!results.length && b.status === "done") return [];
return [{ ...b, results, count: Math.max(1, b.count - 1) }];
}));
} else {
setBatches((prev) => prev.filter((b) => b.id !== target.batchId));
}
}
/* 图片创作「加入模特库」:把该图复用为模特形象图建 Model 条目(后端不二次扣费;幂等)。
@@ -1280,49 +1201,39 @@ export function ImageWorkbenchPage({
});
}
/* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次;
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
/* R100:工作台记录从后端持久任务恢复(与任务中心同源)—— 原 localStorage 残留 1 小时即丢、
换浏览器/清缓存即空,造成「任务中心有记录、工作台丢」。挂载 / 切换商品时按 mode+product 拉取
该商品的全部生成记录;仍在跑的批次带回 pendingIds,各自续轮询补齐(刷新也不断)。 */
useEffect(() => {
let cancelled = false;
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,避免双源打架
if (mode === "image") return;
// 模特/平台套图:从多批次残留恢复「全部」批次 —— 已出图/失败的直接回显;仍在跑(带 pendingIds)的
// 恢复成 generating 并各自按本批 pendingIds 续轮询。这样切走再回来,多批/多平台都在,不再「数量少 / 分类丢」(PMC#5/#10)。
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 all = saved.batches || [];
if (all.length) {
setBatches(all);
if (onResume) {
for (const b of all) {
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
onResume(mode, b.pendingIds).then((res) => {
if (cancelled) return;
setBatches((prev) => {
const next = prev.map((x) =>
x.id === b.id ? { ...x, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || x.results } : x
);
persistBatches(next);
return next;
});
});
}
}
if (!productId) { setBatches([]); return; }
let cancelled = false;
api.workbenchTasks(mode, productId)
.then((res) => {
if (cancelled) return;
const next = batchesFromWorkbenchTasks(res.tasks || []);
// 竞态保护:拉取在途时用户又提交了新批次(本地 id 以 b- 开头、还在生成中)→ 合并保留,别被整体替换冲掉
setBatches((prev) => [...next, ...prev.filter((b) => b.id.startsWith("b-") && b.status === "generating")]);
if (onResume) {
for (const b of next) {
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
onResume(mode, b.pendingIds).then((r) => {
if (cancelled || !r?.assets) return;
setBatches((prev) => prev.map((x) =>
x.id === b.id ? { ...x, status: "done" as const, results: r.assets, pendingIds: undefined } : x
));
});
}
}
}
} catch {
/* 残留解析失败则忽略 */
}
})
.catch(() => { /* 拉取失败(后端未更新/网络抖动):保留当前内存批次,不清空 */ });
return () => {
cancelled = true;
};
// 仅在挂载/切换 mode 时恢复一次
// 仅在挂载 / 切 mode / 切商品时恢复(不依赖 onResume 身份,避免反复重拉)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
}, [mode, productId]);
// YYX#3:工作台数据按商品隔离 —— 模特/平台模式右侧结果只展示当前选中商品的批次
// (旧批次无 productId 则全局可见,兜底);image 模式按对话维度,沿用全部 batches。
@@ -1342,8 +1253,8 @@ export function ImageWorkbenchPage({
const hasResults = productBatches.length > 0;
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除)
② 中央 .gen-image-feedback 就地反馈(采用后亮 1.5s)③ 左上「已采用」绿角标。 */
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→删除)
② 失败格占位 + 单张重跑。R109:成图自动入资产库,不再有「已入库」角标/入库切换。 */
function renderBatchGrid(batch: GenBatch) {
const generating = batch.status === "generating";
const n = batch.results.length || batch.count;
@@ -1360,12 +1271,11 @@ export function ImageWorkbenchPage({
const asset = batch.results[index];
const url = asset?.files?.[0]?.preview_url;
const assetId = asset?.id || "";
const inLib = !!asset?.in_library;
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
const key = assetId || `ph-${index}`;
const cellKey = `${batch.id}:${assetId || key}`;
return (
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}${inLib && url ? " adopted" : ""}`} key={key}>
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}`} 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}` })} />
) : (
@@ -1389,14 +1299,7 @@ export function ImageWorkbenchPage({
)}
</div>
)}
{/* ③ 左上「已入库」绿角标(该图已加入资产库时常驻) */}
{url && inLib && (
<span className="gen-adopt-badge">
<Check size={11} />
已入库
</span>
)}
{/* YYX#20:去掉「加入资产库」后的中央遮罩反馈,只保留左上角「已入库」标签 */}
{/* R109:成图自动入资产库 —— 去掉「已入库」角标与加入/取消入口 */}
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
{url && (
<div className="gen-image-actions">
@@ -1410,13 +1313,7 @@ export function ImageWorkbenchPage({
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
<MoreHorizontal size={14} />
</button>
{/* YYX#20 更正:单图「更多」气泡保留「加入资产库」(单图粒度入库),这里不是重复入口;
重复入口是批次底部那个,已在批次气泡里删掉。 */}
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
<Bookmark size={13} />
{inLib ? "取消加入资产库" : "加入资产库"}
</button>
{/* row41:仅图片创作(mode=image)显示「加入模特库」;模特上身图/平台套图不显示 */}
{mode === "image" && (
<button type="button" className="gen-bubble-item" disabled={enrolledModelIds.has(assetId) || enrollingModelId === assetId} onClick={() => { setOpenMore(""); enrollModelFromImage(assetId); }}>
@@ -1424,7 +1321,8 @@ export function ImageWorkbenchPage({
{enrolledModelIds.has(assetId) ? "已加入模特库" : (enrollingModelId === assetId ? "加入中…" : "加入模特库")}
</button>
)}
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
{/* R108/R109:删除 = 真删资产(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); setConfirmDel({ batchId: batch.id, assetId }); }}>
<Trash2 size={13} />
删除
</button>
@@ -1461,27 +1359,22 @@ export function ImageWorkbenchPage({
prompt={batch.prompt}
count={batch.count}
statusText={generating ? "生成中" : failed ? "失败" : "已完成"}
adoptedCount={!generating && !failed ? batch.results.filter((a) => a.in_library).length : 0}
/>
<div className="gen-card gen-batch-card">
{renderBatchGrid(batch)}
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
{/* 行30/32:批次底部操作行(R109:成图自动入资产库,去掉「加入资产库」按钮) */}
<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>
{/* YYX#20:批次底部「加入资产库」已是上面的独立按钮,这里气泡只留「删除当前批次」,去掉重复入口 */}
<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 danger" onClick={() => removeBatch(batch.id)}>
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
删除当前批次
</button>
@@ -1651,16 +1544,14 @@ export function ImageWorkbenchPage({
<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>
{/* R109:成图自动入资产库,去掉「加入资产库」按钮 */}
<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 danger" onClick={() => removeBatch(batch.id)}>
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
删除当前批次
</button>
@@ -1743,6 +1634,17 @@ export function ImageWorkbenchPage({
</div>
</div>
</section>
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
<ConfirmModal
open={!!confirmDel}
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
icon={<Trash2 size={16} />}
detail="删除的图片将回收到垃圾桶,是否需要删除?"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
/>
</div>
);
}
@@ -2093,29 +1995,22 @@ export function ImageWorkbenchPage({
)}
<div className="iw-sub">
<div className="iw-sub-h">// 提示词 · 描述你想要的画面</div>
{/* YYX#7:提示词框加大;YYX#10:生图模型选择作胶囊嵌在框内靠左置底 */}
{/* YYX#7/R83:提示词框加大 —— 外层容器即视觉上的「输入框」,内部无边框 textarea(默认 4-5 行起步,随内容自动增高,可手动拉伸)+ 底部工具条,胶囊不再绝对定位盖在文字上;
YYX#10/R86:生图模型选择作胶囊嵌在框内工具条靠左置底(复用图片创作 chat 输入栏同款 Pill,菜单向上弹) */}
<div className="iw-prompt-box">
<textarea
className="textarea iw-prompt-text"
className="iw-prompt-text"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="iw-prompt-bar">
<div className={`chip-wrap iw-model-pill${genModelOpen ? " open" : ""}`} data-filter="genmodel">
<button className="chip" type="button" title="生图模型" onClick={() => setGenModelOpen((v) => !v)}>
<Sparkles size={12} />
<span className="chip-label">{GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}</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">
{GEN_MODEL_OPTIONS.map((o) => (
<div className={`mi${genModel === o.value ? " selected" : ""}`} key={o.value} role="button" tabIndex={0} onClick={() => { setGenModel(o.value); setGenModelOpen(false); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{o.label}
</div>
))}
</div>
</div>
<Pill
label="模型"
value={GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}
options={GEN_MODEL_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
onSelect={setGenModel}
/>
</div>
</div>
</div>
@@ -2222,12 +2117,24 @@ export function ImageWorkbenchPage({
navigate={navigate}
/>
)}
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
<ConfirmModal
open={!!confirmDel}
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
icon={<Trash2 size={16} />}
detail="删除的图片将回收到垃圾桶,是否需要删除?"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
/>
</div>
);
}
/* YYX#18:结果卡精简头部 —— 提示词单行折叠(超出给「展开/收起」)+ 灰色张数文案 */
function BatchHeader({ prompt, count, statusText, adoptedCount }: { prompt: string; count: number; statusText: string; adoptedCount: number }) {
function BatchHeader({ prompt, count, statusText }: { prompt: string; count: number; statusText: string }) {
const [expanded, setExpanded] = useState(false);
const [overflow, setOverflow] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -2244,7 +2151,7 @@ function BatchHeader({ prompt, count, statusText, adoptedCount }: { prompt: stri
</button>
)}
<div className="ibh-count">
{count} 张 · {statusText}{adoptedCount > 0 ? ` · ${adoptedCount} 张已入库` : ""}
{count} 张 · {statusText}
</div>
</div>
);
@@ -2622,9 +2529,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<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>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2649,9 +2554,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<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>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2748,9 +2651,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<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>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2773,9 +2674,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<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>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
@@ -2825,9 +2724,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
</div>
<div className="ops">
<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>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (