import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Button, Switch, Tooltip } from "antd"; import { Image as ImageIcon, LoaderCircle, Minus, Play, Plus, Rows3, Trash2, Upload } from "lucide-react"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MIN_BATCH_REFERENCE_COLUMNS, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchReferenceMentionToken, } from "@/lib/canvas/canvas-batch-table"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { CanvasTheme } from "@/lib/canvas-theme"; import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas"; type ReferenceCell = { rowId: string; columnIndex: number }; type Props = { node: CanvasNodeData; nodes: CanvasNodeData[]; connections: CanvasConnection[]; batch?: CanvasGenerationBatch; theme: CanvasTheme; onPatchTable: (patch: Partial) => void; onAddRow: () => void; onRemoveRow: (rowId: string) => void; onUpdateRow: (rowId: string, patch: Partial) => void; onFillRows: () => void; onGenerate: (rowIds?: string[]) => void; onRetryItem: (batchId: string, itemId: string) => void; onAddReferenceColumn: () => void; onRemoveReferenceColumn?: () => void; onFocusOutput?: (nodeId: string) => void; onReorderReferenceColumns?: (fromColumnId: string, toColumnId: string) => void; onMoveReferenceCell?: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; onUploadReference?: (rowId: string, columnIndex: number, file: File) => void; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; readOnly?: boolean; }; const OPERATION_OPTIONS = [ { value: "try_on", label: "批量换装" }, { value: "creative", label: "创意生图" }, ] satisfies Array<{ value: CanvasBatchOperation; label: string }>; const CONCURRENCY_OPTIONS = [1, 5, 10] as const; export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn, onReorderReferenceColumns, onMoveReferenceCell, onUploadReference, onFocusOutput, onConnectStart, onConnectDrop, readOnly = false }: Props) { const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; const referenceColumns = batchReferenceColumns(table); const globalPrompt = table.globalPrompt || ""; const hasGlobalPrompt = Boolean(globalPrompt.trim()); const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]); const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").map((connection) => connection.fromNodeId)).size, [connections, node.id]); const completed = table.rows.filter((row) => hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length; const unfinishedReadyCount = table.rows.filter((row) => rowReady(row, table, nodeById) && !hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length; const gridTemplateColumns = `64px repeat(${referenceColumns.length}, 88px) minmax(280px, 1fr) 88px 80px`; const subtleSurface = `color-mix(in srgb, ${theme.node.text} 4%, transparent)`; const inputSurface = theme.node.panel; const fileInputRef = useRef(null); const uploadTargetRef = useRef(null); const [draggingColumnId, setDraggingColumnId] = useState(null); const [draggingCell, setDraggingCell] = useState(null); const draggingCellRef = useRef(null); const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); const suppressClickRef = useRef(false); const clearReferenceDrag = useCallback(() => { dragStartRef.current = null; draggingCellRef.current = null; setDraggingCell(null); }, []); const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => { const target = document.elementFromPoint(clientX, clientY)?.closest("[data-batch-reference-cell]"); const rowId = target?.dataset.rowId; const columnIndex = Number(target?.dataset.columnIndex); return rowId && Number.isInteger(columnIndex) ? { rowId, columnIndex } : null; }, []); useEffect(() => { if (readOnly) return; const handleMove = (event: PointerEvent) => { const start = dragStartRef.current; if (!start || start.pointerId !== event.pointerId) return; const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); if (!draggingCellRef.current && distance < 5) return; if (!draggingCellRef.current) { draggingCellRef.current = start.cell; suppressClickRef.current = true; setDraggingCell(start.cell); } }; const handleUp = (event: PointerEvent) => { const start = dragStartRef.current; if (!start || start.pointerId !== event.pointerId) return; const sourceCell = draggingCellRef.current; const targetCell = sourceCell ? findReferenceCellAtPoint(event.clientX, event.clientY) : null; if (sourceCell && targetCell) onMoveReferenceCell?.(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex); const didDrag = Boolean(sourceCell); clearReferenceDrag(); if (didDrag) { suppressClickRef.current = true; window.setTimeout(() => { suppressClickRef.current = false; }, 0); } }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); window.addEventListener("pointercancel", handleUp); return () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointercancel", handleUp); }; }, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell, readOnly]); const startCellDrag = (event: ReactPointerEvent, rowId: string, columnIndex: number) => { if (readOnly || event.button !== 0) return; event.stopPropagation(); dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } }; }; const pickReferenceFile = (rowId: string, columnIndex: number) => { if (readOnly) return; if (suppressClickRef.current) { suppressClickRef.current = false; return; } uploadTargetRef.current = { rowId, columnIndex }; fileInputRef.current?.click(); }; return (
{!readOnly ? { const file = event.currentTarget.files?.[0]; const target = uploadTargetRef.current; event.currentTarget.value = ""; uploadTargetRef.current = null; if (file && target) onUploadReference?.(target.rowId, target.columnIndex, file); }} /> : null} {!readOnly ? : null}
event.stopPropagation()}> onPatchTable({ operation: operation as CanvasBatchOperation })} />
并发 ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} />
{referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考 {!readOnly ? ( <> = MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}> ) : null}
已连 {connectedImageCount} · 完成 {completed}/{table.rows.length}
{!readOnly ? (
event.stopPropagation()}>
) : null}
event.stopPropagation()}> 全局提示词 onPatchTable({ globalPrompt: event.target.value })} />
event.stopPropagation()}>
任务 {referenceColumns.map((column) => ( setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDragOver={(event) => { event.preventDefault(); }} onDrop={() => { if (draggingColumnId) onReorderReferenceColumns?.(draggingColumnId, column.id); setDraggingColumnId(null); }} > {column.label} ))} 任务提示词 生成结果 操作
{table.rows.length ? ( table.rows.map((row, index) => { const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined; const item = batchItemByRowId.get(row.id); const status = row.enabled ? rowStatus(item, output) : { label: "已停用", tone: "idle" as const, loading: false, retryable: false }; const ready = rowReady(row, table, nodeById); const completedRow = hasNodeMedia(output); const references = batchRowMentionReferences(row, referenceColumns, nodeById); const effectivePrompt = batchPromptForRow(table, row); const disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !effectivePrompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.filter(Boolean).length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : ""; return (
{!readOnly ? onUpdateRow(row.id, { enabled })} /> : null} {index + 1}
{referenceColumns.map((column, columnIndex) => (
pickReferenceFile(row.id, columnIndex)} onUploadFile={(file) => onUploadReference?.(row.id, columnIndex, file)} onPointerDown={(event) => startCellDrag(event, row.id, columnIndex)} />
))}
onUpdateRow(row.id, { prompt })} onSubmit={!readOnly && ready && !status.loading ? () => onGenerate([row.id]) : undefined} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} />
{readOnly ? "输入 @ 插入参考图" : "输入 @ 插入参考图 · ⌘/Ctrl + Enter 生成此行"}
{ if (row.outputNodeId) onFocusOutput?.(row.outputNodeId); }} />
{!readOnly ? (
) : }
); }) ) : (
还没有批量任务

把图片连接到左侧参考图端口后同步连线,或先添加一行手工配置。

{!readOnly ? : null}
)}
); } function BatchChoiceGroup({ ariaLabel, options, value, onChange, theme, compact = false, disabled = false }: { ariaLabel: string; options: Array<{ value: string | number; label: string }>; value: string | number; onChange: (value: string | number) => void; theme: CanvasTheme; compact?: boolean; disabled?: boolean }) { return (
{options.map((option) => { const selected = option.value === value; return ; })}
); } function BatchReferenceHandles({ columns, theme, onConnectStart, onConnectDrop }: { columns: ReturnType; theme: CanvasTheme; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) { const commonStyle = { left: 0, width: 36, height: 36, transform: "translate(-50%, -50%)", transformOrigin: "center" }; return <>{columns.map((column, index) => )}; } function BatchReferenceHandle({ column, index, theme, commonStyle, onConnectStart, onConnectDrop }: { column: { id: string; label: string }; index: number; theme: CanvasTheme; commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) { const [hovered, setHovered] = useState(false); const [offset, setOffset] = useState({ x: 0, y: 0 }); const handleId = batchReferenceHandleId(column.id); const reset = useCallback(() => { setHovered(false); setOffset({ x: 0, y: 0 }); }, []); const update = useCallback((event: ReactPointerEvent) => { const bounds = event.currentTarget.getBoundingClientRect(); const dx = event.clientX - (bounds.left + bounds.width / 2); const dy = event.clientY - (bounds.top + bounds.height / 2); const limit = 10; setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) }); }, []); return ( ); } function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType, nodeById: Map): CanvasResourceReference[] { return columns.flatMap((column, index) => { const source = nodeById.get(row.inputNodeIds[index]); if (!source) return []; return [{ id: `${row.id}:${column.id}:${source.id}`, nodeId: source.id, kind: "image" as const, label: `参考图${index + 1}`, title: `${column.label} · ${source.title || "图片"}`, previewUrl: source.metadata?.previewContent || source.metadata?.content, storageKey: source.metadata?.storageKey, active: true, sourceType: source.type, mentionToken: batchReferenceMentionToken(index) }]; }); } function ReferenceThumbnail({ node, label, theme, readOnly, rowId, columnIndex, isDraggingCell, onPickFile, onUploadFile, onPointerDown }: { node?: CanvasNodeData; label: string; theme: CanvasTheme; readOnly: boolean; rowId: string; columnIndex: number; isDraggingCell: boolean; onPickFile: () => void; onUploadFile: (file: File) => void; onPointerDown: (event: ReactPointerEvent) => void }) { const filled = node && hasNodeMedia(node); const fallback = ; return ( ); } function ResultThumbnail({ output, status, theme, onFocus }: { output?: CanvasNodeData; status: ReturnType; theme: CanvasTheme; onFocus: () => void }) { const filled = hasNodeMedia(output); const tone = statusColor(status.tone, theme.node.stroke); const title = filled ? `${status.label} · 点击定位到画布节点` : status.label; return ( ); } function statusColor(tone: RowStatusTone, fallback: string) { if (tone === "success") return "var(--status-success)"; if (tone === "error") return "var(--status-error)"; if (tone === "loading") return "var(--status-loading)"; return fallback; } function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode { const sizeClass = "size-full"; return (
{compact ? : ( 上传 )}
); } function rowReady(row: CanvasBatchRow, table: CanvasBatchTableData, nodeById: Map) { if (!row.enabled || !batchPromptForRow(table, row).trim()) return false; const inputNodeIds = row.inputNodeIds.filter(Boolean); if (table.operation === "try_on" && inputNodeIds.length < 2) return false; if (!inputNodeIds.length) return false; return inputNodeIds.every((id) => hasNodeMedia(nodeById.get(id))); } function hasNodeMedia(node?: CanvasNodeData) { return Boolean(node?.metadata?.content || node?.metadata?.storageKey); } type RowStatusTone = "success" | "error" | "loading" | "idle"; type RowStatus = { label: string; tone: RowStatusTone; loading: boolean; retryable: boolean }; function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined): RowStatus { if (hasNodeMedia(output)) return { label: "生成完成", tone: "success", loading: false, retryable: false }; if (item?.status === "failed") return { label: item.errorDetails || "生成失败", tone: "error", loading: false, retryable: true }; if (item?.status === "cancelled") return { label: "已停止", tone: "error", loading: false, retryable: false }; if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", tone: "loading", loading: true, retryable: false }; if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", tone: "error", loading: false, retryable: false }; return { label: "待生成", tone: "idle", loading: false, retryable: false }; }