feat: 批量创作表增加完整生成设置对话框,参考图上限提升到10

- 新增 BatchGenerationSettingsDialog 组件:模型选择 + 图像质量/尺寸/宽高比/透明背景/生成张数
- 替换原简单确认框为完整设置面板,用户可在生成前调整参数
- 参考图列上限从 6 提升到 10,列宽调整为 72px 以适配
This commit is contained in:
Codex
2026-09-19 13:35:15 +08:00
parent caa02a1307
commit 97e6b045d9
4 changed files with 360 additions and 204 deletions

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { Button, Modal } from "antd";
import { WandSparkles, X } from "lucide-react";
import { ImageSettingsPanel } from "@/components/image-settings-panel";
import { ModelPicker } from "@/components/model-picker";
import { canvasThemes } from "@/lib/canvas-theme";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
export type BatchGenerationSettings = Pick<AiConfig, "model" | "imageModel" | "quality" | "size" | "transparentBackground" | "count">;
type BatchGenerationSettingsDialogProps = {
open: boolean;
config: AiConfig;
rowCount: number;
concurrency: number;
onClose: () => void;
onConfirm: (settings: BatchGenerationSettings) => void;
};
export function BatchGenerationSettingsDialog({ open, config, rowCount, concurrency, onClose, onConfirm }: BatchGenerationSettingsDialogProps) {
const theme = canvasThemes[useActiveTheme()];
const [generationConfig, setGenerationConfig] = useState<AiConfig>(config);
useEffect(() => {
if (open) setGenerationConfig(config);
}, [config, open]);
const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => {
setGenerationConfig((current) => ({ ...current, [key]: value }));
};
const handleModelChange = (model: string) => {
setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }));
};
const imageModel = generationConfig.imageModel || generationConfig.model;
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
centered
destroyOnHidden
width={480}
title="批量生成设置"
>
<div className="flex flex-col gap-4 py-2">
<div className="rounded-lg bg-black/5 px-3 py-2 text-sm dark:bg-white/[0.04]">
共 <span className="font-semibold">{rowCount}</span> 个未完成任务 · 并发上限 <span className="font-semibold">{concurrency}</span>
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">生成模型</div>
<ModelPicker
config={generationConfig}
value={imageModel}
capability="image"
fullWidth
showSelectedPrice={false}
onChange={handleModelChange}
/>
</div>
<div className="border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<ImageSettingsPanel
config={generationConfig}
onConfigChange={handleConfigChange}
theme={theme}
showTitle={false}
showCount={true}
quickCount={4}
maxCount={10}
className="w-full space-y-3"
/>
</div>
<div className="mt-2 flex justify-end gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
<Button
type="primary"
icon={<WandSparkles className="size-4" />}
onClick={() => onConfirm({
model: generationConfig.model,
imageModel: generationConfig.imageModel,
quality: generationConfig.quality,
size: generationConfig.size,
transparentBackground: generationConfig.transparentBackground,
count: generationConfig.count,
})}
>
开始生成 {rowCount} 个任务
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { nanoid } from "nanoid";
import { nanoid } from "nanoid";
import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasNodeData } from "@/types/canvas";
@@ -7,7 +7,7 @@ export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图
export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
export const BATCH_REFERENCE_HANDLE_TOP = 112;
export const BATCH_REFERENCE_HANDLE_GAP = 38;
export const MAX_BATCH_REFERENCE_COLUMNS = 6;
export const MAX_BATCH_REFERENCE_COLUMNS = 10;
export const MAX_BATCH_TEXT_COLUMNS = 4;
export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:";
@@ -15,7 +15,7 @@ export function batchGridTemplateColumns(referenceCount: number, textCount: numb
return [
"36px",
"68px",
`repeat(${referenceCount}, 88px)`,
`repeat(\${referenceCount}, 72px)`,
textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined,
"minmax(280px, 1fr)",
"148px",

View File

@@ -1,4 +1,4 @@
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -110,6 +110,7 @@ import { TapNowImportDialog } from "./components/tapnow-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog";
import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
@@ -1910,7 +1911,7 @@ function InfiniteCanvasPage() {
handleGenerateNode,
});
const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({
const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow, batchGenDialogOpen, batchGenDialogRowCount, batchGenDialogConcurrency, batchGenDialogConfig, closeBatchGenDialog, confirmBatchGenDialog } = useCanvasBatchTable({
nodesRef,
connectionsRef,
setNodes,
@@ -3176,6 +3177,15 @@ function InfiniteCanvasPage() {
config={effectiveConfig}
/>
<BatchGenerationSettingsDialog
open={batchGenDialogOpen}
config={batchGenDialogConfig}
rowCount={batchGenDialogRowCount}
concurrency={batchGenDialogConcurrency}
onClose={closeBatchGenDialog}
onConfirm={confirmBatchGenDialog}
/>
<CanvasProjectStatusDialogs
theme={theme}
task={taskDetail}

View File

@@ -1,199 +1,244 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { batchInputColumns, batchPromptForRow, batchReferenceColumns, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { navigateToSettings } from "@/lib/settings-navigation";
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas";
type Options = {
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined;
};
export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) {
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const patchTable = useCallback((nodeId: string, patch: Partial<CanvasBatchTableData>) => {
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 3, rows: [], ...node.metadata?.batchTable, ...patch } } }));
}, [setNodes]);
const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial<CanvasBatchRow>) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node?.metadata?.batchTable) return;
patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) });
}, [nodesRef, patchTable]);
const addRow = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
patchTable(nodeId, { rows: [...table.rows, createBatchRow(table.operation)] });
}, [nodesRef, patchTable]);
const removeRow = useCallback((nodeId: string, rowId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) });
}, [nodesRef, patchTable]);
const addReferenceColumn = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const columns = batchReferenceColumns(table);
if (columns.length >= 6) return message.info("最多支持 6 组参考图");
const nextIndex = columns.length + 1;
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
}, [message, nodesRef, patchTable]);
const addTextColumn = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const columns = table.textColumns || [];
if (columns.length >= 4) return message.info("最多支持 4 组文字");
patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] });
}, [message, nodesRef, patchTable]);
const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
const table = node?.metadata?.batchTable;
if (!node || !table) return false;
const nodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
const input = nodeById.get(inputNodeId);
return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey);
}));
if (!columns.some((column) => column.length)) {
if (!silent) message.warning("请先把图片节点连接到批量创作表");
return false;
}
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
const input = nodeById.get(inputNodeId);
return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt);
}));
const rowsWithText = rows.map((row, index) => ({
...row,
textNodeIds: textColumns.flatMap((column) => {
const input = column.length === 1 ? column[0] : column[index];
return input ? [input] : [];
}),
}));
if (!rows.length) {
if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图");
return false;
}
const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText);
if (!rowsChanged) return false;
patchTable(nodeId, { rows: rowsWithText });
if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`);
return true;
}, [connectionsRef, message, nodesRef, patchTable]);
const fillRowsFromConnections = useCallback((nodeId: string) => {
syncRowsFromConnections(nodeId);
}, [syncRowsFromConnections]);
const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId);
if (nextTable !== table) patchTable(nodeId, nextTable);
}, [nodesRef, patchTable]);
const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex);
if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows });
}, [nodesRef, patchTable]);
const generateRows = useCallback(async (nodeId: string, requestedRowIds?: string[]) => {
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
const table = sourceNode?.metadata?.batchTable;
if (!sourceNode || !table) return;
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, imageModel)) {
navigateToSettings({ continueCreation: true });
return;
}
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
const rows = table.rows.filter((row) => {
if (row.enabled === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false;
if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false;
if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined;
return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id));
});
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
const confirmed = await new Promise<boolean>((resolve) => modal.confirm({
title: `确认提交 ${rows.length} 个批量图片任务`,
content: `模型:${modelDisplayName(effectiveConfig, imageModel)};并发上限:${table.concurrency}。这些任务可能消耗积分或产生外部模型费用。`,
okText: "确认生成",
cancelText: "取消",
centered: true,
onOk: () => resolve(true),
onCancel: () => resolve(false),
}));
if (!confirmed) return;
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const nextNodes = [...nodesRef.current];
let nextConnections = [...connectionsRef.current];
const outputByRowId = new Map<string, string>();
const targets: Array<{ rowId: string; nodeId: string }> = [];
rows.forEach((row, index) => {
const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1;
const prompt = batchPromptForRow(table, row).trim();
const metadata = {
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
prompt,
composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => {
const textNode = nodesRef.current.find((node) => node.id === textNodeId);
return textNode?.metadata?.content || textNode?.metadata?.prompt || "";
})].filter(Boolean).join("\n\n"),
model: buildGenerationConfig(effectiveConfig, undefined, "image").model,
generationMode: "image" as const,
generationType: "edit" as const,
workflowKind: "final" as const,
workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`,
status: "idle" as const,
batchSourceNodeId: nodeId,
batchRowId: row.id,
batchOperation: table.operation,
batchInputNodeIds: row.inputNodeIds,
};
const output = existingIndex >= 0
? { ...nextNodes[existingIndex], metadata }
: createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata);
output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`;
if (existingIndex >= 0) nextNodes[existingIndex] = output;
else nextNodes.push(output);
nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id);
row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id });
outputByRowId.set(row.id, output.id);
targets.push({ rowId: row.id, nodeId: output.id });
});
const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id);
nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } };
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
setSelectedNodeIds(new Set(targets.map((target) => target.nodeId)));
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
return { addReferenceColumn, addTextColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow };
}
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { MAX_BATCH_REFERENCE_COLUMNS, batchInputColumns, batchPromptForRow, batchReferenceColumns, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas";
import type { BatchGenerationSettings } from "@/components/canvas/batch-generation-settings-dialog";
type Options = {
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined;
};
type PendingBatchGen = {
nodeId: string;
rows: CanvasBatchRow[];
concurrency: number;
};
export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const [batchGenDialog, setBatchGenDialog] = useState<{ open: boolean; pending: PendingBatchGen | null }>({ open: false, pending: null });
const patchTable = useCallback((nodeId: string, patch: Partial<CanvasBatchTableData>) => {
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 3, rows: [], ...node.metadata?.batchTable, ...patch } } }));
}, [setNodes]);
const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial<CanvasBatchRow>) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node?.metadata?.batchTable) return;
patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) });
}, [nodesRef, patchTable]);
const addRow = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
patchTable(nodeId, { rows: [...table.rows, createBatchRow(table.operation)] });
}, [nodesRef, patchTable]);
const removeRow = useCallback((nodeId: string, rowId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) });
}, [nodesRef, patchTable]);
const addReferenceColumn = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const columns = batchReferenceColumns(table);
if (columns.length >= MAX_BATCH_REFERENCE_COLUMNS) return message.info("最多支持 10 组参考图");
const nextIndex = columns.length + 1;
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
}, [message, nodesRef, patchTable]);
const addTextColumn = useCallback((nodeId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const columns = table.textColumns || [];
if (columns.length >= 4) return message.info("最多支持 4 组文字");
patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] });
}, [message, nodesRef, patchTable]);
const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
const table = node?.metadata?.batchTable;
if (!node || !table) return false;
const nodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
const input = nodeById.get(inputNodeId);
return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey);
}));
if (!columns.some((column) => column.length)) {
if (!silent) message.warning("请先把图片节点连接到批量创作表");
return false;
}
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
const input = nodeById.get(inputNodeId);
return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt);
}));
const rowsWithText = rows.map((row, index) => ({
...row,
textNodeIds: textColumns.flatMap((column) => {
const input = column.length === 1 ? column[0] : column[index];
return input ? [input] : [];
}),
}));
if (!rows.length) {
if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图");
return false;
}
const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText);
if (!rowsChanged) return false;
patchTable(nodeId, { rows: rowsWithText });
if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`);
return true;
}, [connectionsRef, message, nodesRef, patchTable]);
const fillRowsFromConnections = useCallback((nodeId: string) => {
syncRowsFromConnections(nodeId);
}, [syncRowsFromConnections]);
const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId);
if (nextTable !== table) patchTable(nodeId, nextTable);
}, [nodesRef, patchTable]);
const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => {
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
if (!table) return;
const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex);
if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows });
}, [nodesRef, patchTable]);
const executeBatchGeneration = useCallback((pending: PendingBatchGen, settings: BatchGenerationSettings) => {
const { nodeId, rows, concurrency } = pending;
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
const table = sourceNode?.metadata?.batchTable;
if (!sourceNode || !table) return;
const mergedConfig = { ...effectiveConfig, ...settings };
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const nextNodes = [...nodesRef.current];
let nextConnections = [...connectionsRef.current];
const outputByRowId = new Map<string, string>();
const targets: Array<{ rowId: string; nodeId: string }> = [];
rows.forEach((row, index) => {
const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1;
const prompt = batchPromptForRow(table, row).trim();
const metadata = {
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
prompt,
composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => {
const textNode = nodesRef.current.find((node) => node.id === textNodeId);
return textNode?.metadata?.content || textNode?.metadata?.prompt || "";
})].filter(Boolean).join("\n\n"),
model: buildGenerationConfig(mergedConfig, undefined, "image").model,
size: mergedConfig.size,
quality: mergedConfig.quality,
transparentBackground: mergedConfig.transparentBackground,
count: Number(mergedConfig.count) || 1,
generationMode: "image" as const,
generationType: "edit" as const,
workflowKind: "final" as const,
workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`,
status: "idle" as const,
batchSourceNodeId: nodeId,
batchRowId: row.id,
batchOperation: table.operation,
batchInputNodeIds: row.inputNodeIds,
};
const output = existingIndex >= 0
? { ...nextNodes[existingIndex], metadata }
: createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata);
output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`;
if (existingIndex >= 0) nextNodes[existingIndex] = output;
else nextNodes.push(output);
nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id);
row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id });
outputByRowId.set(row.id, output.id);
targets.push({ rowId: row.id, nodeId: output.id });
});
const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id);
nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } };
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
setSelectedNodeIds(new Set(targets.map((target) => target.nodeId)));
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, message, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
const generateRows = useCallback((nodeId: string, requestedRowIds?: string[]) => {
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
const table = sourceNode?.metadata?.batchTable;
if (!sourceNode || !table) return;
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, imageModel)) {
navigateToSettings({ continueCreation: true });
return;
}
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
const rows = table.rows.filter((row) => {
if (row.enabled === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false;
if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false;
if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined;
return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id));
});
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
setBatchGenDialog({ open: true, pending: { nodeId, rows, concurrency: table.concurrency } });
}, [effectiveConfig, isAiConfigReady, message, nodesRef]);
const closeBatchGenDialog = useCallback(() => {
setBatchGenDialog({ open: false, pending: null });
}, []);
const confirmBatchGenDialog = useCallback((settings: BatchGenerationSettings) => {
if (batchGenDialog.pending) {
executeBatchGeneration(batchGenDialog.pending, settings);
}
setBatchGenDialog({ open: false, pending: null });
}, [batchGenDialog.pending, executeBatchGeneration]);
const dialogConfig = useMemo(() => effectiveConfig, [effectiveConfig]);
return {
addReferenceColumn,
addTextColumn,
addRow,
fillRowsFromConnections,
generateRows,
moveReferenceCell,
patchTable,
removeRow,
reorderReferenceColumns,
syncRowsFromConnections,
updateRow,
batchGenDialogOpen: batchGenDialog.open,
batchGenDialogRowCount: batchGenDialog.pending?.rows.length ?? 0,
batchGenDialogConcurrency: batchGenDialog.pending?.concurrency ?? 1,
batchGenDialogConfig: dialogConfig,
closeBatchGenDialog,
confirmBatchGenDialog,
};
}