feat: 生图模型可选(火山/gpt-image) + 工作台图「加入资产库」收纳 + 任务中心排序修复

- AI 工作室生图支持显式选模型:resolve_image_model 解析 volcano(Seedream 图生图,
  无 image_edit 时走 image_generation 带参考图)/ gpt-image(image_edit 多图编辑),
  未选回落系统默认;enqueue_standalone_images 透传 image_model
- 资产库收纳:Asset 加 in_library 字段(迁移 0007,既有资产 db_default=True 不动);
  工作台生成图默认 in_library=False,只在工作台展示,用户「加入资产库」后才进库列表;
  assets 视图/序列化器/library 页/types 配套
- 任务中心修复:AITaskViewSet 默认 order_by(-created_at),前端 aiTasks 取 page_size=200。
  原因:列表无排序时 MySQL 按 UUID 主键乱序返回,把一批旧失败记录顶到首页,
  前端又只取首页 20 条算 全部/已完成/失败 → 误显示「全部失败」(实为 421 成功/少量失败)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-27 10:34:12 +08:00
co-authored by Claude Opus 4.8
parent de4b20cc7b
commit 26577b99b1
15 changed files with 472 additions and 104 deletions
+89 -45
View File
@@ -433,6 +433,13 @@ const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
const COUNT_OPTIONS = ["1", "2", "4"];
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
const MODEL_COUNT_OPTIONS = ["1", "2", "4"];
// 生图模型选择(写入 localStorage,下次进页面读回)。默认火山。
const GEN_MODEL_KEY = "airshelf:imgwb:gen_model";
const GEN_MODEL_OPTIONS: { value: string; label: string }[] = [
{ value: "volcano", label: "火山 Seedream" },
{ value: "gpt-image", label: "gpt-image-2" },
];
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
@@ -514,7 +521,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string }) => Promise<{ assets: Asset[] } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string }) => Promise<{ assets: Asset[] } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
@@ -533,6 +540,12 @@ export function ImageWorkbenchPage({
const [ratioW, setRatioW] = useState("");
const [ratioH, setRatioH] = useState("");
const [style, setStyle] = useState("auto");
// 生图模型选择:默认火山(内衣等敏感品类不被审核拦,还原也更好),可切 gpt-image-2。持久化到 localStorage。
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③:可选多个平台,各出一组结果)
const [pickedIds, setPickedIds] = useState<string[]>([]);
@@ -698,7 +711,7 @@ export function ImageWorkbenchPage({
};
setBatches((prev) => [...prev, newBatch]);
try {
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio });
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 });
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);
@@ -773,30 +786,50 @@ export function ImageWorkbenchPage({
});
}
/* 加入资产库 / 取消加入(批次级来回切;§4.18:就地反馈,亮在该批首图上,不弹全局窗) */
function toggleAdoptBatch(batchId: string) {
/* 单图 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 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);
const firstId = target?.results[0]?.id;
if (willAdopt && firstId) flashFeedback(`${batchId}:${firstId}`);
return next;
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;
});
});
}
/* 加入资产库 / 取消(单图级,§4.18:单图气泡"加入资产库",就地反馈不弹全局窗) */
/* 加入资产库 / 取消加入(批次级:全部已入库 → 整批移出,否则整批加入) */
function toggleAdoptBatch(batchId: string) {
const batch = batches.find((b) => b.id === batchId);
if (!batch) return;
const withId = batch.results.filter((a) => a.id);
if (!withId.length) return;
const next = !withId.every((a) => a.in_library);
if (next) flashFeedback(`${batchId}:${withId[0].id}`);
applyLibraryState(batchId, withId.map((a) => a.id), next);
}
/* 加入资产库 / 取消(单图级:只改被点的那一张,§4.18 就地反馈不弹全局窗) */
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) flashFeedback(`${batchId}:${assetId}`);
return next;
});
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;
if (next) flashFeedback(`${batchId}:${assetId}`);
applyLibraryState(batchId, [assetId], next);
}
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
@@ -887,13 +920,13 @@ export function ImageWorkbenchPage({
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
>
{(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 }) => {
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id, inLib: !!asset.in_library }))
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "", inLib: false }))
).map(({ key, index, url, assetId, inLib }) => {
const cellKey = `${batch.id}:${assetId || key}`;
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
return (
<div className={`gen-image ${generating && !url ? "gen" : ""}${batch.adopted && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
<div className={`gen-image ${generating && !url ? "gen" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} 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}` })} />
) : (
@@ -907,11 +940,11 @@ export function ImageWorkbenchPage({
)}
</div>
)}
{/* ③ 左上「已采用」绿角标(批次 adopted 时常驻) */}
{url && batch.adopted && (
{/* ③ 左上「已入库」绿角标(该图已加入资产库时常驻) */}
{url && inLib && (
<span className="gen-adopt-badge">
<Check size={11} />
已采用
已入库
</span>
)}
{/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */}
@@ -937,7 +970,7 @@ export function ImageWorkbenchPage({
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
<Bookmark size={13} />
{batch.adopted ? "取消加入资产库" : "加入资产库"}
{inLib ? "取消加入资产库" : "加入资产库"}
</button>
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
<Trash2 size={13} />
@@ -971,13 +1004,10 @@ export function ImageWorkbenchPage({
const failed = batch.status === "failed";
return (
<Fragment key={batch.id}>
{/* 行34:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
{/* 行34:每批一个合并导航头——商品/模特(平台)+ 结果(数量·比例·状态)左对齐一块;
原右上「1 批·比例」与「N×」徽标系重复信息,已剔除 */}
<div className="iw-pv-h">
<Quote className="quote-icon" />
<div className="pv-meta">
<b>1 批</b>
{mode === "model" ? ` · ${batch.ratio}` : ""}
</div>
<div className="pv-line">
<span className="k">商品</span>
<span className="v">{batchProductTitle(batch)}</span>
@@ -1000,18 +1030,17 @@ export function ImageWorkbenchPage({
</span>
</div>
)}
</div>
<div className="gen-card gen-batch-card">
<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 className="pv-line">
<span className="k">结果</span>
<span className="v">
{batch.count} 张 · {batch.ratio} · {generating ? "生成中" : failed ? "失败" : "已完成"}
{!generating && !failed && batch.results.some((a) => a.in_library)
? ` · ${batch.results.filter((a) => a.in_library).length} 张已入库`
: ""}
</span>
</div>
</div>
<div className="gen-card gen-batch-card">
{renderBatchGrid(batch)}
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
<div className="gen-batch-actions">
@@ -1330,6 +1359,21 @@ export function ImageWorkbenchPage({
{product?.title || "未选择 · 请在左侧商品空间选一个"}
</span>
</div>
{/* 生图模型选择(默认火山,可切 gpt-image-2;选择写入 localStorage) */}
<div className={`tb-menu-wrap chip-wrap${genModelOpen ? " open" : ""}`} data-filter="genmodel">
<button className="tb-chip" type="button" title="生图模型" onClick={() => setGenModelOpen((v) => !v)}>
<Sparkles size={12} />
<span className="lbl">{GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}</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">
{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>
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
<LayoutGrid size={13} />
@@ -1551,7 +1595,7 @@ export function ImageWorkbenchPage({
<WandSparkles size={13} />
立即生成 (预估 ¥{(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})
</button>
<div className="iw-cta-hint">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣{anyGenerating ? " · 有批次生成中,可继续提交" : ""}</div>
<div className="iw-cta-hint">// 生成即扣费 · 生成的图先在工作台 · 点「加入资产库」才进资产库列表{anyGenerating ? " · 有批次生成中,可继续提交" : ""}</div>
</div>
</div>
+85 -13
View File
@@ -5,7 +5,7 @@ import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2,
import { api } from "../api";
import { SkeletonGrid } from "../components/loading";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import type { Asset } from "../types";
import type { Asset, AssetBatch } from "../types";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { Pager } from "../components/pager";
@@ -44,6 +44,9 @@ const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
];
// 图片成品三类按「生成批次」成组展示(一次提交的多张图 = 一张批次卡,点开看整批);其余 tab 仍平铺
const BATCH_TABS: LibTab[] = ["tryon", "kits", "creations"];
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
@@ -92,6 +95,10 @@ function AssetDetailModal({ asset, close, onZoom }: {
// 多图资产:当前选中的主图索引(缩略图切换)
const [activeIdx, setActiveIdx] = useState(0);
useEffect(() => { setActiveIdx(0); }, [asset?.id]);
// 审核盾本地覆盖态:必须在提前 return 之前声明,否则 hook 数量随 asset 有无变化 → React 崩溃白屏
const [reviewOverride, setReviewOverride] = useState<string | null>(null);
const [submittingReview, setSubmittingReview] = useState(false);
useEffect(() => { setReviewOverride(null); }, [asset?.id]);
if (!mounted || !asset) return null;
const files = asset.files || [];
@@ -132,10 +139,8 @@ function AssetDetailModal({ asset, close, onZoom }: {
];
// 审核盾:仅送审类(角色/三视图/分镜)显示;灰盾可点提交。本地覆盖提交后即时反映,切资产清空
// (状态 hook 已上移到提前 return 之前)
const REVIEW_CATS = ["person", "tri_view", "storyboard"];
const [reviewOverride, setReviewOverride] = useState<string | null>(null);
const [submittingReview, setSubmittingReview] = useState(false);
useEffect(() => { setReviewOverride(null); }, [asset?.id]);
const review = (reviewOverride ?? asset.review_status ?? "") as ReviewStatus;
async function submitReview() {
if (!asset) return;
@@ -437,6 +442,9 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const [items, setItems] = useState<Asset[]>([]);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 });
// 图片成品:按生成批次成组(点批次卡 → 弹窗看整批图片)
const [batches, setBatches] = useState<AssetBatch[]>([]);
const [openBatch, setOpenBatch] = useState<AssetBatch | null>(null);
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
@@ -456,26 +464,38 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
// 图片成品三类按批次成组展示;其余 tab 平铺
const isBatchTab = BATCH_TABS.includes(tab);
// 列表数据(分页 + 过滤)
const [reloadFlag, setReloadFlag] = useState(0);
useEffect(() => {
if (tab === "videopacks") { setLoading(false); return; } // 视频成品走素材包,不拉扁平资产
let alive = true;
setLoading(true);
api.assetsPage({
const common = {
tab,
q: debouncedQuery || undefined,
source: srcFilter || undefined,
asset_type: kindFilter || undefined,
meta: metaFilter,
ordering: sortDesc ? "-created_at" : "created_at",
page,
pageSize: LIB_PAGE_SIZE
}).then((res) => { if (alive) { setItems(res.results); setTotal(res.count); } })
.catch(() => { if (alive) { setItems([]); setTotal(0); } })
.finally(() => { if (alive) setLoading(false); });
};
if (isBatchTab) {
// 图片成品:按生成批次成组(每批一张卡)
api.assetBatches(common)
.then((res) => { if (alive) { setBatches(res.results); setTotal(res.count); } })
.catch(() => { if (alive) { setBatches([]); setTotal(0); } })
.finally(() => { if (alive) setLoading(false); });
} else {
api.assetsPage({ ...common, asset_type: kindFilter || undefined })
.then((res) => { if (alive) { setItems(res.results); setTotal(res.count); } })
.catch(() => { if (alive) { setItems([]); setTotal(0); } })
.finally(() => { if (alive) setLoading(false); });
}
return () => { alive = false; };
}, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
@@ -541,7 +561,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="page-head">
<div>
<h1>资产库</h1>
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} · 视频 {counts.videopacks} 包</span></div>
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} 批 · 视频 {counts.videopacks} 包</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
@@ -674,9 +694,32 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{shown.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {isBatchTab ? "个批次" : "个资产"}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
{shown.length ? (
{isBatchTab ? (
batches.length ? (
<div className="packs-grid" id="batch-grid">
{batches.map((batch) => (
<article className="pack-card" key={batch.batch_id} onClick={() => setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.id)); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" 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 className="placeholder asset-thumb pack-thumb">
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
<span className="pack-count mono">{batch.count} 张</span>
</div>
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
</article>
))}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)
) : shown.length ? (
<div className="asset-grid" id="asset-grid">
{shown.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
@@ -771,6 +814,35 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
document.body
)}
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图) */}
{openBatch && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
<h2>{openBatch.name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 生成批次 · {openBatch.count} 张</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="pack-clip-grid">
{openBatch.items.map((a, i) => {
const cover = a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
return (
<button className="pack-clip as-btn" type="button" key={a.id} onClick={() => { setOpenBatch(null); setDetail(a); }} title="查看详情">
{cover ? <img src={cover} alt={a.name} loading="lazy" /> : <span className="pack-clip-ph ph-frame">无图</span>}
<div className="pack-clip-name mono">第 {i + 1} 张</div>
</button>
);
})}
</div>
</div>
</div>,
document.body
)}
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct">已选 <b>{selected.size}</b> 项</span>