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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user