From 506f463d0752f05d14213892899637faf3ec4324 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 18 Sep 2026 13:03:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=89=B9=E9=87=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E8=A1=A8=20-=20=E6=94=AF=E6=8C=81=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E3=80=81=E5=8F=82=E8=80=83=E5=9B=BE?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E4=B8=8E=E8=BF=9E=E7=BA=BF=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/app/cloud_agent_batch_table.go | 41 +- .../app/cloud_agent_batch_table_test.go | 95 ++++- backend/internal/app/cloud_agent_tools.go | 5 +- backend/internal/canvas/capability/builtin.go | 6 +- .../canvas/canvas-batch-table-node.tsx | 372 ++++++++++++++---- web/src/constant/canvas.ts | 2 +- web/src/lib/canvas/canvas-batch-table.ts | 71 +++- .../definitions/builtin-nodes.tsx | 2 +- web/src/pages/canvas/project.tsx | 42 +- .../pages/canvas/use-canvas-batch-table.ts | 72 +++- web/src/pages/canvas/use-canvas-upload.ts | 32 ++ web/src/types/canvas.ts | 2 +- web/test/canvas-batch-table.test.ts | 88 ++++- 13 files changed, 714 insertions(+), 116 deletions(-) diff --git a/backend/internal/app/cloud_agent_batch_table.go b/backend/internal/app/cloud_agent_batch_table.go index 2e6ae8b08..b0aa9c02c 100644 --- a/backend/internal/app/cloud_agent_batch_table.go +++ b/backend/internal/app/cloud_agent_batch_table.go @@ -29,6 +29,7 @@ type cloudAgentBatchTableEditArgs struct { Patch map[string]any `json:"patch"` Operation string `json:"operation"` Concurrency int `json:"concurrency"` + GlobalPrompt string `json:"globalPrompt"` } type cloudAgentBatchTableMutationPlan struct { @@ -87,6 +88,12 @@ func batchTableNodeFromDocument(doc map[string]any, nodeID string) (map[string]a if !ok || !cloudAgentBatchConcurrencyAllowed(concurrency) { return nil, nil, nil, nil, BadAuthRequest("批量创作表包含无效的并发数") } + if rawPrompt, exists := table["globalPrompt"]; exists { + globalPrompt, ok := rawPrompt.(string) + if !ok || utf8.RuneCountInString(globalPrompt) > maxCloudAgentBatchPromptRunes { + return nil, nil, nil, nil, BadAuthRequest("批量创作表包含无效或过长的全局提示词") + } + } columns := creationMaps(table["referenceColumns"]) if len(columns) < 1 || len(columns) > maxCloudAgentBatchReferences { return nil, nil, nil, nil, BadAuthRequest("批量创作表必须包含 1 到 6 个参考图列") @@ -182,7 +189,7 @@ func prepareCloudAgentBatchTableEdit(repo *repository.Repository, userID, canvas if err := validateCloudAgentID(args.NodeID, "批量创作表节点ID", 80); err != nil { return nil, err } - allowedActions := map[string]bool{"append": true, "update": true, "remove": true, "set_operation": true, "set_concurrency": true, "add_reference_column": true} + allowedActions := map[string]bool{"append": true, "update": true, "remove": true, "set_operation": true, "set_concurrency": true, "add_reference_column": true, "remove_reference_column": true, "set_global_prompt": true} if !allowedActions[args.Action] { return nil, BadAuthRequest("批量创作表操作无效") } @@ -302,6 +309,38 @@ func prepareCloudAgentBatchTableEdit(repo *repository.Repository, userID, canvas table["referenceColumns"] = mapsAsAny(columns) fields = []string{"参考图列"} summaryVerb = "新增参考图列到" + case "remove_reference_column": + if len(columns) <= 1 { + return nil, BadAuthRequest("批量创作表至少保留1组参考图") + } + if args.RowID != "" || len(args.Patch) != 0 || args.Operation != "" || args.Concurrency != 0 { + return nil, BadAuthRequest("减少参考图列不接受其他修改参数") + } + columns = columns[:len(columns)-1] + for index, column := range columns { + column["label"] = fmt.Sprintf("参考图 %d", index+1) + } + table["referenceColumns"] = mapsAsAny(columns) + for _, row := range rows { + row["inputNodeIds"] = cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns)) + } + table["rows"] = mapsAsAny(rows) + fields = []string{"参考图列"} + summaryVerb = "减少参考图列自" + case "set_global_prompt": + if utf8.RuneCountInString(args.GlobalPrompt) > maxCloudAgentBatchPromptRunes { + return nil, BadAuthRequest("全局提示词必须是不超过20000字的文本") + } + if args.RowID != "" || len(args.Patch) != 0 || args.Operation != "" || args.Concurrency != 0 { + return nil, BadAuthRequest("设置全局提示词不接受其他修改参数") + } + if strings.TrimSpace(args.GlobalPrompt) == "" { + delete(table, "globalPrompt") + } else { + table["globalPrompt"] = args.GlobalPrompt + } + fields = []string{"全局提示词"} + summaryVerb = "设置全局提示词到" } metadata["batchTable"] = table title := cloudAgentApprovalNodeTitle(node, "批量创作表") diff --git a/backend/internal/app/cloud_agent_batch_table_test.go b/backend/internal/app/cloud_agent_batch_table_test.go index 28a5cfe9c..94f46686c 100644 --- a/backend/internal/app/cloud_agent_batch_table_test.go +++ b/backend/internal/app/cloud_agent_batch_table_test.go @@ -48,7 +48,7 @@ func TestCloudAgentBatchTableCapabilityMatchesCanvasComponent(t *testing.T) { if !ok { t.Fatal("batch-table capability missing") } - if descriptor.Label != "批量创作表" || descriptor.DefaultWidth != 900 || descriptor.DefaultHeight != 520 || descriptor.InputKind != "text" || descriptor.ProjectionKind != "batch_table" || descriptor.ProjectionField != "batchTable" { + if descriptor.Label != "批量创作表" || descriptor.DefaultWidth != 1280 || descriptor.DefaultHeight != 560 || descriptor.InputKind != "text" || descriptor.ProjectionKind != "batch_table" || descriptor.ProjectionField != "batchTable" { t.Fatalf("batch-table capability differs from component contract: %+v", descriptor) } if err := descriptor.ValidateConnection("image"); err != nil { @@ -97,6 +97,20 @@ func TestCloudAgentBatchTableToolsAreScopedAndStructured(t *testing.T) { t.Fatalf("batch row patch schema missing %s", field) } } + if properties["globalPrompt"] == nil { + t.Fatal("batch table edit schema missing globalPrompt") + } + actionEnum, _ := properties["action"].(map[string]any)["enum"].([]string) + foundGlobalPrompt := false + for _, action := range actionEnum { + if action == "set_global_prompt" { + foundGlobalPrompt = true + break + } + } + if !foundGlobalPrompt { + t.Fatalf("batch table edit schema missing set_global_prompt: %+v", actionEnum) + } for _, forbidden := range []string{"outputNodeId", "taskId", "url", "storageKey", "metadata"} { if patch[forbidden] != nil { t.Fatalf("batch row patch schema exposes forbidden field %s", forbidden) @@ -187,6 +201,85 @@ func TestCloudAgentCanReadAndEditBatchTableRows(t *testing.T) { } } +func TestCloudAgentCanSetBatchTableGlobalPrompt(t *testing.T) { + s, canvas := cloudAgentBatchTableFixture(t) + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + t.Fatal(err) + } + policy, err := s.RuntimePolicy() + if err != nil { + t.Fatal(err) + } + appendCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "append-empty-prompt", map[string]any{ + "snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "batch-1", "action": "append", + "patch": map[string]any{"inputNodeIds": []string{"image-1", "image-2"}, "prompt": " "}, + }) + appendResult, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, appendCall, policy) + if err != nil { + t.Fatal(err) + } + state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: canvas.ID}} + readCall := cloudAgentBatchTableCall(t, "canvas_read_batch_table", "read-before-global", map[string]any{"nodeId": "batch-1", "offset": 0}) + before, err := cloudAgentReadTool(s.repo, "user", state, readCall) + if err != nil { + t.Fatal(err) + } + beforePreview := before.(map[string]any)["batchTable"].(map[string]any)["generationPreview"].(map[string]any) + if beforePreview["readyRows"] != 0 || beforePreview["missingPromptRows"] != 1 { + t.Fatalf("empty row prompt should not be ready before global prompt: %+v", beforePreview) + } + + setCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "set-global", map[string]any{ + "snapshotHash": appendResult.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt", + "globalPrompt": " 全局换装提示词 ", + }) + setResult, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, setCall, policy) + if err != nil { + t.Fatal(err) + } + after, err := cloudAgentReadTool(s.repo, "user", state, readCall) + if err != nil { + t.Fatal(err) + } + table := after.(map[string]any)["batchTable"].(map[string]any) + if table["globalPrompt"] != "全局换装提示词" && table["globalPrompt"] != " 全局换装提示词 " { + t.Fatalf("global prompt was not projected: %+v", table) + } + preview := table["generationPreview"].(map[string]any) + if preview["readyRows"] != 1 || preview["missingPromptRows"] != 0 { + t.Fatalf("global prompt should make the row ready: %+v", preview) + } + + clearCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "clear-global", map[string]any{ + "snapshotHash": setResult.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt", + "globalPrompt": " ", + }) + if _, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, clearCall, policy); err != nil { + t.Fatal(err) + } + cleared, err := cloudAgentReadTool(s.repo, "user", state, readCall) + if err != nil { + t.Fatal(err) + } + clearedTable := cleared.(map[string]any)["batchTable"].(map[string]any) + if _, exists := clearedTable["globalPrompt"]; exists { + t.Fatalf("empty global prompt should be cleared: %+v", clearedTable) + } + clearedPreview := clearedTable["generationPreview"].(map[string]any) + if clearedPreview["readyRows"] != 0 { + t.Fatalf("cleared global prompt should restore missing prompt: %+v", clearedPreview) + } + + reject := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "reject-global-row", map[string]any{ + "snapshotHash": cleared.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt", + "rowId": "row-should-not-be-accepted", "globalPrompt": "x", + }) + if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, reject); err == nil || !strings.Contains(err.Error(), "不接受其他修改参数") { + t.Fatalf("set_global_prompt should reject row-level parameters: %v", err) + } +} + func TestCloudAgentBatchTableRejectsUnsafeAndStaleEdits(t *testing.T) { s, canvas := cloudAgentBatchTableFixture(t) doc, err := creationDocument(canvas.PayloadJSON) diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index a2fa4e8ad..aec49afa4 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -192,14 +192,15 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma "rowId": str("update/remove 使用 canvas_read_storyboard 返回的真实 rowId;append 留空"), "patch": cloudAgentStoryboardPatchSchema(), }, "snapshotHash", "nodeId", "action") - add("canvas_edit_batch_table", "操作批量创作表组件:追加、修改或删除任务行,切换批量换装/创意生图,设置1/5/10并发,或新增参考图列。必须先用 canvas_read_batch_table 获取最新 snapshotHash 和真实 rowId。行 patch 仅允许 enabled、inputNodeIds、prompt;prompt 可使用读取结果中的 @参考图1、@参考图2 等 mentionToken 指代本行对应位置的图片。append 未传 inputNodeIds 时会继承上一行参考图;图片ID必须来自当前画布。不能写 outputNodeId、任务状态、URL、storageKey 或任意 metadata。本工具只编辑计划,不提交收费生成。", map[string]any{ + add("canvas_edit_batch_table", "操作批量创作表组件:追加、修改或删除任务行,切换批量换装/创意生图,设置1/5/10并发,新增或减少参考图列,或设置覆盖各任务的全局提示词。必须先用 canvas_read_batch_table 获取最新 snapshotHash 和真实 rowId。行 patch 仅允许 enabled、inputNodeIds、prompt;prompt 可使用读取结果中的 @参考图1、@参考图2 等 mentionToken 指代本行对应位置的图片。append 未传 inputNodeIds 时会继承上一行参考图;图片ID必须来自当前画布。不能写 outputNodeId、任务状态、URL、storageKey 或任意 metadata。本工具只编辑计划,不提交收费生成。", map[string]any{ "snapshotHash": str("最近一次批量创作表读取返回的 snapshotHash"), "nodeId": str("真实批量创作表节点ID"), - "action": map[string]any{"type": "string", "enum": []string{"append", "update", "remove", "set_operation", "set_concurrency", "add_reference_column"}}, + "action": map[string]any{"type": "string", "enum": []string{"append", "update", "remove", "set_operation", "set_concurrency", "add_reference_column", "remove_reference_column", "set_global_prompt"}}, "rowId": str("update/remove 使用 canvas_read_batch_table 返回的真实 rowId;其他操作留空"), "patch": cloudAgentBatchTablePatchSchema(), "operation": map[string]any{"type": "string", "enum": []string{"try_on", "creative"}}, "concurrency": map[string]any{"type": "integer", "enum": []int{1, 5, 10}}, + "globalPrompt": str("set_global_prompt 使用;非空时覆盖各任务提示词,空字符串清除全局提示词"), }, "snapshotHash", "nodeId", "action") opProperties := map[string]any{ "type": map[string]any{"type": "string", "enum": []string{"add_node", "update_node", "connect_nodes"}}, diff --git a/backend/internal/canvas/capability/builtin.go b/backend/internal/canvas/capability/builtin.go index 25852a431..9802f023c 100644 --- a/backend/internal/canvas/capability/builtin.go +++ b/backend/internal/canvas/capability/builtin.go @@ -51,12 +51,12 @@ func BuiltinRegistry() *Registry { }, }, { - Type: "batch-table", Version: "1", Label: "批量创作表", DefaultWidth: 900, DefaultHeight: 520, - Purpose: "面向电商批量换装和创意生图的结构化任务表;每行绑定最多六组画布图片、可用 @参考图1 等位置引用编写独立提示词,并追踪生成结果。", + Type: "batch-table", Version: "1", Label: "批量创作表", DefaultWidth: 1280, DefaultHeight: 560, + Purpose: "面向电商批量换装和创意生图的结构化任务表;每行绑定最多六组画布图片、可用 @参考图1 等位置引用编写独立提示词,也可设置全局提示词覆盖各行,并追踪生成结果。", GoodFor: []string{"商品与模特批量换装", "同一商品多场景创意图", "多组参考图组合生成", "批量结果追踪与失败重试"}, NotIdealFor: []string{"通用数据库或库存管理", "单张图片快速试验", "多镜头叙事连续性"}, Tradeoffs: []string{"参考图必须先作为图片节点进入画布", "批量提交会产生多项生成任务,执行前必须确认模型、数量和费用"}, - Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "set_operation", "set_concurrency", "add_reference_column", "preview_batch_generation"}, + Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "set_operation", "set_concurrency", "add_reference_column", "remove_reference_column", "set_global_prompt", "preview_batch_generation"}, InputKind: "text", Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"image"}}, CanUpdate: true, SummaryFields: []string{"batchTable"}, DetailFields: []string{"batchTable"}, ProjectionKind: "batch_table", ProjectionField: "batchTable", diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index fe3a54fe3..5c9350d3c 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +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, Play, Plus, RefreshCw, Rows3, Trash2 } from "lucide-react"; +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"; @@ -8,6 +8,8 @@ import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, + MIN_BATCH_REFERENCE_COLUMNS, + batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchReferenceMentionToken, @@ -16,6 +18,8 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer 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[]; @@ -30,6 +34,11 @@ type Props = { 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; @@ -42,83 +51,212 @@ const OPERATION_OPTIONS = [ const CONCURRENCY_OPTIONS = [1, 5, 10] as const; -export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onConnectStart, onConnectDrop, readOnly = false }: Props) { +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.operation, nodeById) && !hasNodeMedia(row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined)).length; - const gridTemplateColumns = `54px repeat(${referenceColumns.length}, 84px) minmax(280px, 1fr) 146px 76px`; + 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 ( -
event.stopPropagation()}> +
+ {!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} -
- onPatchTable({ operation: operation as CanvasBatchOperation })} /> -
- 并发 - ({ value, label: String(value) }))} value={table.concurrency} onChange={(concurrency) => onPatchTable({ concurrency: Number(concurrency) })} /> -
-
- {referenceColumns.length}/{MAX_BATCH_REFERENCE_COLUMNS} 组参考 +
+
+
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 ? ( - = MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}> - - +
event.stopPropagation()}> + + + + + +
) : null}
- 已连 {connectedImageCount} · 完成 {completed}/{table.rows.length} - - {!readOnly ? ( -
- - - - - -
- ) : null} +
event.stopPropagation()}> + 全局提示词 + onPatchTable({ globalPrompt: event.target.value })} + /> +
-
-
- 任务 - {referenceColumns.map((column, index) => ( - - {column.label} - {batchReferenceMentionToken(index)} +
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: "已停用", loading: false, retryable: false }; - const ready = rowReady(row, table.operation, nodeById); + 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 disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !row.prompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : ""; + 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) => )} -
+ {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 单行生成 : null} +
+ {readOnly ? "输入 @ 插入参考图" : "输入 @ 插入参考图 · ⌘/Ctrl + Enter 生成此行"}
-
- {completedRow ? } /> : } -
-
{status.label}
- {!readOnly && status.retryable && batch && item ? : completedRow ? 已关联到画布 : null} -
- {status.loading ? : null} +
+ { if (row.outputNodeId) onFocusOutput?.(row.outputNodeId); }} + />
{!readOnly ? (
@@ -176,7 +313,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t 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 ; @@ -219,39 +356,108 @@ function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType; +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 EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode { - 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 rowReady(row: CanvasBatchRow, operation: CanvasBatchOperation, nodeById: Map) { - if (!row.enabled || !row.prompt.trim()) return false; - if (operation === "try_on" && row.inputNodeIds.length < 2) return false; - if (!row.inputNodeIds.length) return false; - return row.inputNodeIds.every((id) => hasNodeMedia(nodeById.get(id))); +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 = compact ? "size-12" : "size-16"; + 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); } -function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) { - if (hasNodeMedia(output)) return { label: "生成完成", loading: false, retryable: false }; - if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true }; - if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false }; - if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false }; - if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false }; - return { label: "待生成", loading: false, retryable: false }; +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 }; } diff --git a/web/src/constant/canvas.ts b/web/src/constant/canvas.ts index b9c7d721b..e940927cf 100644 --- a/web/src/constant/canvas.ts +++ b/web/src/constant/canvas.ts @@ -27,7 +27,7 @@ export const NODE_DEFAULT_SIZE = { [CanvasNodeType.Chart]: { width: 480, height: 320, title: "图表" }, [CanvasNodeType.ColorGrade]: { width: 420, height: 360, title: "调色" }, [CanvasNodeType.MediaConversion]: { width: 480, height: 460, title: "转换" }, - [CanvasNodeType.BatchTable]: { width: 900, height: 520, title: "批量创作表" }, + [CanvasNodeType.BatchTable]: { width: 1280, height: 560, title: "批量创作表" }, } satisfies Record; export const NODE_SPECS = { diff --git a/web/src/lib/canvas/canvas-batch-table.ts b/web/src/lib/canvas/canvas-batch-table.ts index 960b4dc5f..9d81df0ae 100644 --- a/web/src/lib/canvas/canvas-batch-table.ts +++ b/web/src/lib/canvas/canvas-batch-table.ts @@ -5,9 +5,18 @@ import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。"; 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; +/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */ +export const BATCH_REFERENCE_HANDLE_TOP = 102; +export const BATCH_REFERENCE_HANDLE_GAP = 40; +export const MIN_BATCH_REFERENCE_COLUMNS = 1; export const MAX_BATCH_REFERENCE_COLUMNS = 6; +const LEGACY_BATCH_TABLE_WIDTH = 900; + +/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */ +export function promoteLegacyBatchTableSize(node: CanvasNodeData): CanvasNodeData { + if (node.type !== "batch-table" || node.width !== LEGACY_BATCH_TABLE_WIDTH) return node; + return { ...node, width: 1280, height: Math.max(node.height, 560) }; +} export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] { return [ @@ -20,10 +29,6 @@ export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] { export function batchReferenceColumns(table?: CanvasBatchTableData) { const columns = table?.referenceColumns; if (!columns?.length) return defaultBatchReferenceColumns(); - // Older tables were created before the second default slot existed. They - // never had a remove-column action, so extending this exact legacy shape is - // a safe migration and makes reference slot 2 immediately selectable. - if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns(); return columns; } @@ -63,6 +68,60 @@ export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hi return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined; } +export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): CanvasBatchTableData | null { + const columns = batchReferenceColumns(table); + if (columns.length <= MIN_BATCH_REFERENCE_COLUMNS) return null; + const nextColumns = columns.slice(0, -1).map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })); + return { + ...table, + referenceColumns: nextColumns, + rows: table.rows.map((row) => ({ ...row, inputNodeIds: nextColumns.map((_, index) => row.inputNodeIds[index] || "") })), + }; +} + +export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData { + const columns = batchReferenceColumns(table); + const fromIndex = columns.findIndex((column) => column.id === fromColumnId); + const toIndex = columns.findIndex((column) => column.id === toColumnId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table; + const nextColumns = [...columns]; + const [moved] = nextColumns.splice(fromIndex, 1); + nextColumns.splice(toIndex, 0, moved); + return { + ...table, + referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })), + rows: table.rows.map((row) => ({ + ...row, + inputNodeIds: nextColumns.map((column) => { + const oldIndex = columns.findIndex((item) => item.id === column.id); + return row.inputNodeIds[oldIndex] || ""; + }), + })), + }; +} + +export function moveBatchReferenceCell(table: CanvasBatchTableData, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number): CanvasBatchTableData { + if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table; + if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table; + const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1); + const sourceRow = table.rows.find((row) => row.id === sourceRowId); + const targetRow = table.rows.find((row) => row.id === targetRowId); + if (!sourceRow || !targetRow) return table; + const nextRows = table.rows.map((row) => ({ ...row, inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || "") })); + const nextSource = nextRows.find((row) => row.id === sourceRowId)!; + const nextTarget = nextRows.find((row) => row.id === targetRowId)!; + const sourceNodeId = nextSource.inputNodeIds[sourceColumnIndex]; + if (!sourceNodeId) return table; + const targetNodeId = nextTarget.inputNodeIds[targetColumnIndex]; + nextTarget.inputNodeIds[targetColumnIndex] = sourceNodeId; + nextSource.inputNodeIds[sourceColumnIndex] = targetNodeId || ""; + return { ...table, rows: nextRows }; +} + +export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) { + return table.globalPrompt?.trim() || row.prompt; +} + export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) { const columns = batchReferenceColumns(node.metadata?.batchTable); const indexById = new Map(columns.map((column, index) => [column.id, index])); diff --git a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx index c611fc15a..83f766db8 100644 --- a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx +++ b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx @@ -154,7 +154,7 @@ const BUILTIN_NODE_TRAITS = { showInCreateMenu: true, inputKind: "text", }, - [CanvasNodeType.BatchTable]: { label: "批量创作表", icon: , minSize: { width: 720, height: 360 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null }, + [CanvasNodeType.BatchTable]: { label: "批量创作表", icon: , minSize: { width: 1080, height: 420 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null }, [CanvasNodeType.MediaConversion]: { label: "转换", icon: , minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" }, [CanvasNodeType.ColorGrade]: { label: "调色", diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 25be33087..710905532 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -68,6 +68,7 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; import { CanvasShareModal } from "@/components/canvas/canvas-share-modal"; import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node"; import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node"; +import { promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table"; import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout"; import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel"; import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal"; @@ -742,6 +743,7 @@ function InfiniteCanvasPage() { pasteAssistantImage, pasteSystemClipboard, replaceNodeMedia, + createFileNode, startUploadStatus, uploadModalOpen, uploadTimelineMedia, @@ -1886,7 +1888,7 @@ function InfiniteCanvasPage() { handleGenerateNode, }); - const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, patchTable: patchBatchTable, removeRow: removeBatchRow, updateRow: updateBatchRow } = useCanvasBatchTable({ + const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeReferenceColumn: removeBatchReferenceColumn, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, @@ -1895,6 +1897,22 @@ function InfiniteCanvasPage() { enqueueGenerationBatch, }); + useEffect(() => { + if (!projectLoaded) return; + setNodes((current) => { + let changed = false; + const next = current.map((node) => { + const promoted = promoteLegacyBatchTableSize(node); + if (promoted !== node) changed = true; + return promoted; + }); + return changed ? next : current; + }); + nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => { + syncRowsFromConnections(node.id, true); + }); + }, [connections, projectLoaded, setNodes, syncRowsFromConnections]); + const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } = useCanvasStoryboard({ projectId, @@ -2105,6 +2123,26 @@ function InfiniteCanvasPage() { onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)} onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)} onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)} + onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)} + onFocusOutput={(nodeId) => focusCanvasImageNode(nodeId)} + onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)} + onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)} + onUploadReference={(rowId, columnIndex, file) => { + const row = contentNode.metadata?.batchTable?.rows.find((item) => item.id === rowId); + const existingId = row?.inputNodeIds[columnIndex]; + if (existingId) { + void replaceNodeMedia(existingId, file); + return; + } + void createFileNode(file, { x: contentNode.position.x - 180, y: contentNode.position.y + columnIndex * 90 }).then((insertedId) => { + if (!insertedId) return; + const currentRow = nodesRef.current.find((item) => item.id === contentNode.id)?.metadata?.batchTable?.rows.find((item) => item.id === rowId); + const ids = [...(currentRow?.inputNodeIds || [])]; + while (ids.length <= columnIndex) ids.push(""); + ids[columnIndex] = insertedId; + updateBatchRow(contentNode.id, rowId, { inputNodeIds: ids }); + }); + }} onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)} onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)} /> @@ -2189,6 +2227,7 @@ function InfiniteCanvasPage() { createScriptVideoNodes, currentProject?.directorScenes, fillRowsFromConnections, + focusCanvasImageNode, generateBatchRows, generateScriptImages, generateScriptRows, @@ -2203,6 +2242,7 @@ function InfiniteCanvasPage() { openDirectorWorkbench, openStoryInput, patchBatchTable, + removeBatchReferenceColumn, removeBatchRow, removeScriptRow, retryFailedBatchItems, diff --git a/web/src/pages/canvas/use-canvas-batch-table.ts b/web/src/pages/canvas/use-canvas-batch-table.ts index e1a8c68a1..8c9090641 100644 --- a/web/src/pages/canvas/use-canvas-batch-table.ts +++ b/web/src/pages/canvas/use-canvas-batch-table.ts @@ -3,7 +3,7 @@ import { App } from "antd"; import { nanoid } from "nanoid"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; -import { batchInputColumns, batchReferenceColumns, createBatchRowsFromColumns, createInheritedBatchRow } from "@/lib/canvas/canvas-batch-table"; +import { batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, createBatchRowsFromColumns, createInheritedBatchRow, moveBatchReferenceCell, removeLastBatchReferenceColumn, 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"; @@ -55,23 +55,65 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] }); }, [message, nodesRef, patchTable]); - const fillRowsFromConnections = useCallback((nodeId: string) => { + const removeReferenceColumn = useCallback((nodeId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const columns = batchReferenceColumns(table); + const nextTable = removeLastBatchReferenceColumn(table); + if (!nextTable) return message.info("至少保留 1 组参考图"); + const removed = columns.at(-1); + patchTable(nodeId, nextTable); + if (removed) { + const handleId = batchReferenceHandleId(removed.id); + setConnections((current) => current.filter((connection) => !(connection.toNodeId === nodeId && connection.toHandleId === handleId))); + } + }, [message, nodesRef, patchTable, setConnections]); + + 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; + 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)) return message.warning("请先把图片节点连接到批量创作表"); + if (!columns.some((column) => column.length)) { + if (!silent) message.warning("请先把图片节点连接到批量创作表"); + return false; + } const rows = createBatchRowsFromColumns(table.operation, columns, table.rows); - if (!rows.length) return message.warning("批量换装至少需要一张人物图和一张服装图"); + if (!rows.length) { + if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图"); + return false; + } + if (JSON.stringify(rows) === JSON.stringify(table.rows)) return true; patchTable(nodeId, { rows }); - const added = Math.max(0, rows.length - table.rows.length); - message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留"); + if (!silent) { + const added = Math.max(0, rows.length - table.rows.length); + message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留"); + } + 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; @@ -84,9 +126,10 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon 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 || (requested && !requested.has(row.id)) || !row.prompt.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 inputNodeIds = row.inputNodeIds.filter(Boolean); + if (!row.enabled || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false; + if (table.operation === "try_on" && inputNodeIds.length < 2) return false; + if (!inputNodeIds.length || 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; if (output && activeNodeIds.has(output.id)) return false; // 操作列的单行生成允许对已完成结果重新生成;顶部批量按钮仍只提交未完成项。 @@ -112,10 +155,11 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon 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: row.prompt.trim(), - composerContent: row.prompt.trim(), + prompt, + composerContent: prompt, model: buildGenerationConfig(effectiveConfig, undefined, "image").model, generationMode: "image" as const, generationType: "edit" as const, @@ -134,7 +178,7 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon 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 })); + row.inputNodeIds.filter(Boolean).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 }); @@ -149,5 +193,5 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon 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, addRow, fillRowsFromConnections, generateRows, patchTable, removeRow, updateRow }; + return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow }; } diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index b98db81fe..facd22987 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -47,6 +47,13 @@ const BATCH_UPLOAD_COLUMNS = 3; const BATCH_UPLOAD_COLUMN_GAP = 380; const BATCH_UPLOAD_ROW_GAP = 300; +const CANVAS_BATCH_TABLE_SELECTOR = "[data-canvas-batch-table]"; + +function isBatchTableDragEvent(event: DragEvent) { + const target = event.target instanceof Element ? event.target : null; + return Boolean(target?.closest(CANVAS_BATCH_TABLE_SELECTOR)); +} + export function useCanvasUpload({ canvasId, domainProjectId, @@ -535,6 +542,12 @@ export function useCanvasUpload({ }, [createFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]); const handleDrop = useCallback((event: DragEvent) => { + if (isBatchTableDragEvent(event)) { + event.preventDefault(); + fileDragDepthRef.current = 0; + setFileDropActive(false); + return; + } event.preventDefault(); fileDragDepthRef.current = 0; setFileDropActive(false); @@ -575,6 +588,12 @@ export function useCanvasUpload({ }, [createFileNode, createImageAssetNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]); const handleFileDragEnter = useCallback((event: DragEvent) => { + if (isBatchTableDragEvent(event)) { + event.preventDefault(); + fileDragDepthRef.current = 0; + setFileDropActive(false); + return; + } if (!hasDraggedFiles(event)) return; event.preventDefault(); fileDragDepthRef.current += 1; @@ -582,12 +601,24 @@ export function useCanvasUpload({ }, []); const handleFileDragOver = useCallback((event: DragEvent) => { + if (isBatchTableDragEvent(event)) { + event.preventDefault(); + fileDragDepthRef.current = 0; + setFileDropActive(false); + return; + } if (!hasDraggedFiles(event) && !Array.from(event.dataTransfer.types).includes(CANVAS_PROJECT_CHAPTER_DND_TYPE)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }, []); const handleFileDragLeave = useCallback((event: DragEvent) => { + if (isBatchTableDragEvent(event)) { + event.preventDefault(); + fileDragDepthRef.current = 0; + setFileDropActive(false); + return; + } if (!hasDraggedFiles(event)) return; fileDragDepthRef.current = Math.max(0, fileDragDepthRef.current - 1); if (fileDragDepthRef.current === 0) setFileDropActive(false); @@ -721,6 +752,7 @@ export function useCanvasUpload({ pasteAssistantImage, pasteSystemClipboard, replaceNodeMedia, + createFileNode, startUploadStatus, uploadModalOpen, uploadStatus, diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index b743acf30..1ad0dc59e 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -166,7 +166,7 @@ export type CanvasGenerationBatch = { export type CanvasBatchOperation = "try_on" | "creative"; export type CanvasBatchRow = { id: string; enabled: boolean; inputNodeIds: string[]; prompt: string; outputNodeId?: string }; export type CanvasBatchReferenceColumn = { id: string; label: string }; -export type CanvasBatchTableData = { operation: CanvasBatchOperation; concurrency: number; referenceColumns?: CanvasBatchReferenceColumn[]; rows: CanvasBatchRow[] }; +export type CanvasBatchTableData = { operation: CanvasBatchOperation; concurrency: number; globalPrompt?: string; referenceColumns?: CanvasBatchReferenceColumn[]; rows: CanvasBatchRow[] }; export type CanvasSkillSnapshot = { id: string; diff --git a/web/test/canvas-batch-table.test.ts b/web/test/canvas-batch-table.test.ts index 63e7aea4f..8331eb070 100644 --- a/web/test/canvas-batch-table.test.ts +++ b/web/test/canvas-batch-table.test.ts @@ -1,12 +1,20 @@ import { describe, expect, test } from "bun:test"; import { + batchPromptForRow, + batchReferenceColumns, batchReferenceHandleAtY, batchReferenceHandleId, batchReferenceMentionToken, createBatchRowsFromColumns, createBatchRowsFromInputs, createInheritedBatchRow, + moveBatchReferenceCell, + promoteLegacyBatchTableSize, + removeLastBatchReferenceColumn, + reorderBatchReferenceColumns, + BATCH_REFERENCE_HANDLE_GAP, + BATCH_REFERENCE_HANDLE_TOP, TRY_ON_BATCH_PROMPT, } from "@/lib/canvas/canvas-batch-table"; import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; @@ -67,8 +75,44 @@ describe("batch creation table", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); expect(node.title).toBe("批量创作表"); + expect(node.width).toBe(1280); + expect(node.height).toBe(560); expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] }); }); + + test("promotes the previous 900-wide default without touching resized tables", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + const legacy = { ...node, width: 900, height: 520 }; + const resized = { ...node, width: 1100, height: 480 }; + + expect(promoteLegacyBatchTableSize(legacy)).toEqual({ ...legacy, width: 1280, height: 560 }); + expect(promoteLegacyBatchTableSize(resized)).toBe(resized); + }); + test("keeps a single explicitly stored reference column", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + node.metadata = { ...node.metadata, batchTable: { operation: "creative", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }], rows: [] } }; + + expect(batchReferenceColumns(node.metadata.batchTable).map((column) => column.id)).toEqual(["reference-1"]); + }); + + test("removes the last reference column down to one and trims row inputs", () => { + const table = { + operation: "creative" as const, + concurrency: 10, + referenceColumns: [ + { id: "reference-1", label: "参考图 1" }, + { id: "reference-2", label: "参考图 2" }, + { id: "reference-3", label: "参考图 3" }, + ], + rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }], + }; + + const next = removeLastBatchReferenceColumn(table); + expect(next?.referenceColumns?.map((column) => column.id)).toEqual(["reference-1", "reference-2"]); + expect(next?.rows[0].inputNodeIds).toEqual(["a", "b"]); + expect(removeLastBatchReferenceColumn({ ...table, referenceColumns: [{ id: "reference-1", label: "参考图 1" }] })).toBeNull(); + }); + test("keeps the second reference handle addressable", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); const referenceColumns = [ @@ -77,12 +121,52 @@ describe("batch creation table", () => { ]; node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } }; - expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2")); + expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP)).toBe(batchReferenceHandleId("reference-2")); }); test("chooses the nearest reference handle when magnetic hit areas overlap", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2")); + expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP, 90)).toBe(batchReferenceHandleId("reference-2")); + }); + + test("reorders reference columns and keeps row inputs aligned", () => { + const table = { + operation: "try_on" as const, + concurrency: 10, + referenceColumns: [ + { id: "reference-1", label: "参考图 1" }, + { id: "reference-2", label: "参考图 2" }, + { id: "reference-3", label: "参考图 3" }, + ], + rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }], + }; + const next = reorderBatchReferenceColumns(table, "reference-1", "reference-3"); + expect(next.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]); + expect(next.rows[0].inputNodeIds).toEqual(["b", "c", "a"]); + }); + + test("swaps reference cells across rows and columns", () => { + const table = { + operation: "creative" as const, + concurrency: 10, + referenceColumns: [ + { id: "reference-1", label: "参考图 1" }, + { id: "reference-2", label: "参考图 2" }, + ], + rows: [ + { id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "one" }, + { id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "two" }, + ], + }; + const next = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1); + expect(next.rows[0].inputNodeIds).toEqual(["d", "b"]); + expect(next.rows[1].inputNodeIds).toEqual(["c", "a"]); + }); + + test("uses a non-empty global prompt instead of the row prompt", () => { + const table = { operation: "creative" as const, concurrency: 10, globalPrompt: " 全局覆盖 ", rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a"], prompt: "行提示词" }] }; + expect(batchPromptForRow(table, table.rows[0])).toBe("全局覆盖"); + expect(batchPromptForRow({ ...table, globalPrompt: " " }, table.rows[0])).toBe("行提示词"); }); });