diff --git a/package.json b/package.json index 64c29ac..4469cd5 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,9 @@ "tailwind-merge": "^2.6.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", - "zod": "^4.3.5" + "zod": "^4.3.5", + "@xyflow/react": "^12.10.2", + "ag-psd": "^30.1.1" }, "devDependencies": { "@react-dev-inspector/babel-plugin": "^2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a03ee..acbcc41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,12 @@ importers: '@supabase/supabase-js': specifier: 2.95.3 version: 2.95.3 + '@xyflow/react': + specifier: ^12.10.2 + version: 12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + ag-psd: + specifier: ^30.1.1 + version: 30.1.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1403,24 +1409,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.4': resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.4': resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.4': resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.4': resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} @@ -2638,6 +2648,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -2650,6 +2663,9 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} @@ -2659,6 +2675,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2833,41 +2855,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2940,6 +2970,15 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.10.2': + resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.76': + resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2969,6 +3008,9 @@ packages: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} + ag-psd@30.1.1: + resolution: {integrity: sha512-0GbWYR4Rvm1QnWCYeMiVbUJBXnSyTUKvNUK2tIIVDt/wrUVUL9pHTsnwqOTonEC2RRh5I/aUcGydc1LNgXfJWA==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -3252,6 +3294,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3388,6 +3433,14 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -3408,6 +3461,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -3424,6 +3481,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -5245,6 +5312,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -6368,6 +6438,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9034,6 +9119,10 @@ snapshots: '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-interpolate@3.0.4': @@ -9046,6 +9135,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -9054,6 +9145,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -9362,6 +9462,29 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@xyflow/system': 0.0.76 + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.76': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -9381,6 +9504,11 @@ snapshots: address@1.2.2: {} + ag-psd@30.1.1: + dependencies: + base64-js: 1.5.1 + pako: 2.1.0 + agent-base@7.1.4: {} ajv-formats@2.1.1(ajv@8.18.0): @@ -9686,6 +9814,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -9812,6 +9942,13 @@ snapshots: d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} d3-format@3.1.2: {} @@ -9830,6 +9967,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -9844,6 +9983,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@4.0.1: {} @@ -11947,6 +12103,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13333,4 +13491,12 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.10 + immer: 9.0.21 + react: 19.2.3 + zwitch@2.0.4: {} diff --git a/public/canvas/workflows/scene01.jpeg b/public/canvas/workflows/scene01.jpeg new file mode 100755 index 0000000..7899c6f Binary files /dev/null and b/public/canvas/workflows/scene01.jpeg differ diff --git a/public/canvas/workflows/shot01.jpeg b/public/canvas/workflows/shot01.jpeg new file mode 100755 index 0000000..f5a6a58 Binary files /dev/null and b/public/canvas/workflows/shot01.jpeg differ diff --git a/public/canvas/workflows/workflow01.jpeg b/public/canvas/workflows/workflow01.jpeg new file mode 100755 index 0000000..57dac43 Binary files /dev/null and b/public/canvas/workflows/workflow01.jpeg differ diff --git a/public/canvas/workflows/workflow02.jpeg b/public/canvas/workflows/workflow02.jpeg new file mode 100755 index 0000000..f1a4f46 Binary files /dev/null and b/public/canvas/workflows/workflow02.jpeg differ diff --git a/src/app/api/admin/data-import/route.ts b/src/app/api/admin/data-import/route.ts index 0973569..d6711b6 100644 --- a/src/app/api/admin/data-import/route.ts +++ b/src/app/api/admin/data-import/route.ts @@ -75,6 +75,7 @@ type ImportContext = { apiKeyIdMap: Map; apiKeyOwnerIdMap: Map; columnCache: Map>; + defaultableColumnCache: Map>; }; export async function POST(request: NextRequest) { @@ -136,11 +137,15 @@ async function importRows( let skipped = 0; const errors: string[] = []; const existingColumns = await getExistingColumns(client, table, context); + const defaultableColumns = await getDefaultableColumns(client, table, context); const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col)); for (const rawRow of rows) { const row = await normalizeImportRow(table, rawRow as Record, context); - const cols = Object.keys(row).filter(col => effectiveAllowedColumns.includes(col)); + const cols = Object.keys(row).filter(col => ( + effectiveAllowedColumns.includes(col) + && !(row[col] == null && defaultableColumns.has(col)) + )); if (!cols.includes('id') || cols.length === 0) { skipped++; errors.push(`${table}: 缺少 id 或没有允许导入的字段`); @@ -235,7 +240,15 @@ async function buildImportContext( apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID()); } const ownerId = findImportedWorkUserId(row); - const ownerByEmail = findUserIdByEmail(row, { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() }); + const ownerByEmail = findUserIdByEmail(row, { + userIdMap, + workIdMap, + emailUserIdMap, + apiKeyIdMap, + apiKeyOwnerIdMap, + columnCache: new Map(), + defaultableColumnCache: new Map(), + }); const mappedOwnerId = ownerId ? (userIdMap.get(ownerId) || ownerId) : ownerByEmail; @@ -266,7 +279,15 @@ async function buildImportContext( } } - return { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() }; + return { + userIdMap, + workIdMap, + emailUserIdMap, + apiKeyIdMap, + apiKeyOwnerIdMap, + columnCache: new Map(), + defaultableColumnCache: new Map(), + }; } async function normalizeImportRow(table: string, row: Record, context: ImportContext): Promise> { @@ -331,6 +352,12 @@ async function normalizeImportRow(table: string, row: Record, c } if (table === 'user_api_keys') { + if (typeof next.note !== 'string' || next.note.trim() === '') { + next.note = '导入的 API Key'; + } + if (typeof next.type !== 'string' || next.type.trim() === '') { + next.type = 'image'; + } const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : ''; const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : ''; const secret = rawApiKey || rawEncrypted; @@ -519,6 +546,29 @@ async function getExistingColumns( return columns; } +async function getDefaultableColumns( + client: Awaited>, + table: string, + context: ImportContext, +): Promise> { + const cached = context.defaultableColumnCache.get(table); + if (cached) return cached; + + const [schemaName, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table]; + const result = await client.query( + `SELECT column_name + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name = $2 + AND is_nullable = 'NO' + AND column_default IS NOT NULL`, + [schemaName, tableName], + ); + const columns = new Set((result.rows || []).map((row: Record) => String(row.column_name))); + context.defaultableColumnCache.set(table, columns); + return columns; +} + function seedUuidMap(map: Map, value: unknown): void { if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) { map.set(value, crypto.randomUUID()); diff --git a/src/app/api/canvas/projects/[id]/route.ts b/src/app/api/canvas/projects/[id]/route.ts new file mode 100755 index 0000000..be5fcaa --- /dev/null +++ b/src/app/api/canvas/projects/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { deleteCanvasProject, getCanvasProject, updateCanvasProject } from '@/lib/canvas-store'; +import { getAuthenticatedUserId } from '@/lib/session-auth'; +import { normalizeCanvasState } from '@/lib/canvas-store'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + try { + const userId = await getAuthenticatedUserId(request); + if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 }); + + const { id } = await context.params; + const project = await getCanvasProject(userId, id); + if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 }); + + return NextResponse.json({ project }); + } catch (error) { + console.error('[canvas/projects/:id] GET error:', error); + return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest, context: RouteContext) { + try { + const userId = await getAuthenticatedUserId(request); + if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 }); + + const { id } = await context.params; + const body = await request.json().catch(() => ({})); + const project = await updateCanvasProject(userId, id, { + title: typeof body.title === 'string' ? body.title : undefined, + state: body.state ? normalizeCanvasState(body.state) : undefined, + }); + + if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 }); + return NextResponse.json({ project }); + } catch (error) { + console.error('[canvas/projects/:id] PUT error:', error); + return NextResponse.json({ error: '保存画布项目失败' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const userId = await getAuthenticatedUserId(request); + if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 }); + + const { id } = await context.params; + const deleted = await deleteCanvasProject(userId, id); + if (!deleted) return NextResponse.json({ error: '画布不存在' }, { status: 404 }); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('[canvas/projects/:id] DELETE error:', error); + return NextResponse.json({ error: '删除画布项目失败' }, { status: 500 }); + } +} diff --git a/src/app/api/canvas/projects/route.ts b/src/app/api/canvas/projects/route.ts new file mode 100755 index 0000000..ceadccc --- /dev/null +++ b/src/app/api/canvas/projects/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createCanvasProject, listCanvasProjects } from '@/lib/canvas-store'; +import { getAuthenticatedUserId } from '@/lib/session-auth'; + +export async function GET(request: NextRequest) { + try { + const userId = await getAuthenticatedUserId(request); + if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 }); + + const projects = await listCanvasProjects(userId); + return NextResponse.json({ projects }); + } catch (error) { + console.error('[canvas/projects] GET error:', error); + return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const userId = await getAuthenticatedUserId(request); + if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const title = typeof body.title === 'string' ? body.title : '未命名画布'; + const project = await createCanvasProject(userId, title); + return NextResponse.json({ project }, { status: 201 }); + } catch (error) { + console.error('[canvas/projects] POST error:', error); + return NextResponse.json({ error: '创建画布项目失败' }, { status: 500 }); + } +} diff --git a/src/app/canvas/page.tsx b/src/app/canvas/page.tsx new file mode 100755 index 0000000..519979a --- /dev/null +++ b/src/app/canvas/page.tsx @@ -0,0 +1,5 @@ +import { InfiniteCanvasWorkspace } from '@/components/canvas/infinite-canvas-workspace'; + +export default function CanvasPage() { + return ; +} diff --git a/src/components/canvas/infinite-canvas-workspace.tsx b/src/components/canvas/infinite-canvas-workspace.tsx new file mode 100755 index 0000000..7a3a3eb --- /dev/null +++ b/src/components/canvas/infinite-canvas-workspace.tsx @@ -0,0 +1,2137 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { + Brush, + BookOpen, + ChevronDown, + Copy, + Download, + FileImage, + FolderOpen, + Image as ImageIcon, + Layers, + Link2, + Loader2, + Maximize2, + MousePointer2, + Plus, + RotateCcw, + Save, + Sparkles, + StickyNote, + Trash2, + Type, + Undo2, + Upload, + Wand2, + Redo2, + RefreshCw, + Send, + ShoppingCart, + UserRound, + ZoomIn, + ZoomOut, + X, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { useAuth } from '@/lib/auth-store'; +import { useCustomApiKeys } from '@/lib/custom-api-store'; +import { useManagedSystemApis } from '@/lib/managed-model-store'; +import { + ASPECT_RATIOS, + IMG2IMG_ASPECT_RATIOS, + RESOLUTION_OPTIONS, + buildCustomModelId, + buildSystemModelId, + getCustomKeyId, + getSystemApiId, + isCustomModel, + isSystemModel, + resolveCustomApiImageSize, + resolveImageSize, + resolveImageSizeFromDimensions, +} from '@/lib/model-config'; +import { runGenerationJob } from '@/lib/generation-job-client'; +import { cn } from '@/lib/utils'; +import type { CanvasAsset, CanvasConnection, CanvasLayer, CanvasNode, CanvasNodeType, CanvasProject, CanvasProjectState, CanvasViewport } from '@/lib/canvas-types'; +import type { CanvasFlowControls } from './react-flow-canvas'; + +const ReactFlowCanvas = dynamic(() => import('./react-flow-canvas').then(mod => mod.ReactFlowCanvas), { + ssr: false, +}); + +type AddMenu = { + x: number; + y: number; + screenX: number; + screenY: number; +} | null; + +type WorkflowTemplateType = + | 'text2img' + | 'img2img' + | 'image-chain' + | 'layered' + | 'multi-angle-storyboard' + | 'product-ecommerce-full-set' + | 'drama-character-design' + | 'drama-scene-background' + | 'picture-book-generator'; + +type WorkflowTemplateMeta = { + id: WorkflowTemplateType; + name: string; + description: string; + badge: string; + icon: 'storyboard' | 'ecommerce' | 'character' | 'scene' | 'book'; + cover?: string; +}; + +const LOCAL_DRAFT_KEY = 'miaojing:canvas:last-project-id'; +const MIN_ZOOM = 0.25; +const MAX_ZOOM = 2.5; +const LAYER_CANVAS_SIZE = 1080; +const LAYER_COLORS: Record = { + background: '#f8fafc', + element: '#38bdf8', + icon: '#a78bfa', + text: '#111827', + effect: '#f59e0b', +}; + +const HUOBAO_WORKFLOW_TEMPLATES: WorkflowTemplateMeta[] = [ + { + id: 'multi-angle-storyboard', + name: '多角度分镜', + description: '生成角色正视、侧视、后视、俯视四组分镜图。', + badge: 'Storyboard', + icon: 'storyboard', + cover: '/canvas/workflows/workflow01.jpeg', + }, + { + id: 'product-ecommerce-full-set', + name: '通用产品全套电商图', + description: '根据产品信息和产品图生成模特图、展示图、拆解图。', + badge: 'E-commerce', + icon: 'ecommerce', + cover: '/canvas/workflows/workflow02.jpeg', + }, + { + id: 'drama-character-design', + name: '短剧角色设计', + description: '从角色描述生成基准角色图,并衍生多角度角色素材。', + badge: 'Drama', + icon: 'character', + cover: '/canvas/workflows/shot01.jpeg', + }, + { + id: 'drama-scene-background', + name: '多时段场景背景', + description: '为同一场景生成白天、黄昏、夜晚和雨天版本。', + badge: 'Scene', + icon: 'scene', + cover: '/canvas/workflows/scene01.jpeg', + }, + { + id: 'picture-book-generator', + name: '儿童绘本生成', + description: '把故事设定拆成连续页面提示词和插画生成节点。', + badge: 'Book', + icon: 'book', + }, +]; + +const QUICK_SUGGESTIONS = ['像个魔法森林', '三只不同的小猫', '生成多角度分镜', '夏日田野环绕漫步']; + +function nowIso() { + return new Date().toISOString(); +} + +function createId(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sameStringArray(a: string[], b: string[]) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(reader.error || new Error('文件读取失败')); + reader.readAsDataURL(file); + }); +} + +function getImageDimensions(src: string): Promise<{ width?: number; height?: number }> { + return new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve({ width: image.naturalWidth || image.width, height: image.naturalHeight || image.height }); + image.onerror = () => resolve({}); + image.src = src; + }); +} + +function createNode(type: CanvasNodeType, x: number, y: number, zIndex: number, patch: Partial = {}): CanvasNode { + const createdAt = nowIso(); + const base: CanvasNode = { + id: createId(type), + type, + x, + y, + width: type === 'text' ? 300 : type === 'frame' ? 640 : 360, + height: type === 'text' ? 190 : type === 'frame' ? 420 : 420, + zIndex, + title: type === 'text' + ? '文本' + : type === 'image' + ? '图片' + : type === 'text2img' + ? '文生图' + : type === 'img2img' + ? '图生图' + : type === 'frame' + ? '分组框' + : '图层图片', + status: 'idle', + params: { + aspectRatio: type === 'img2img' ? 'original' : '1:1', + resolution: '2K', + count: 1, + strength: 0.5, + }, + createdAt, + updatedAt: createdAt, + }; + if (type === 'text') base.text = '双击右侧属性面板编辑文本内容'; + if (type === 'frame') { + base.color = '#22c55e'; + base.text = '把相关模块拖到这个区域内,用来整理一组创作流程。'; + } + if (type === 'layeredImage') { + base.layers = [ + { id: createId('layer'), name: '背景层', type: 'background', visible: true, locked: false, color: '#f8fafc', opacity: 1, x: 0, y: 0, width: 1080, height: 1080 }, + { id: createId('layer'), name: '主体元素层', type: 'element', visible: true, locked: false, color: '#38bdf8', opacity: 0.9, x: 120, y: 180, width: 840, height: 650 }, + { id: createId('layer'), name: '文字层', type: 'text', visible: true, locked: false, color: '#111827', opacity: 1, text: '可编辑文字', x: 120, y: 80, width: 840, height: 120 }, + ]; + } + return { ...base, ...patch }; +} + +function getAuthHeaders(accessToken?: string | null) { + return { + 'Content-Type': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }; +} + +function getNodeImageUrl(node?: CanvasNode | null) { + if (!node) return ''; + if (node.type === 'image') return node.imageUrl || ''; + return node.selectedOutput || node.outputImages?.[0] || ''; +} + +function getNodeTextValue(node?: CanvasNode | null) { + if (!node || node.type !== 'text') return ''; + return node.text?.trim() || ''; +} + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 500); +} + +function downloadJson(value: unknown, filename: string) { + downloadBlob(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }), filename); +} + +function drawLayerPreview(ctx: CanvasRenderingContext2D, layer: CanvasLayer, scale = 1) { + if (!layer.visible) return; + const x = layer.x * scale; + const y = layer.y * scale; + const width = layer.width * scale; + const height = layer.height * scale; + ctx.save(); + ctx.globalAlpha = layer.opacity ?? 1; + if (layer.type === 'text') { + ctx.fillStyle = layer.color || LAYER_COLORS.text; + ctx.font = `${Math.max(18, Math.min(72, height * 0.45))}px sans-serif`; + ctx.textBaseline = 'middle'; + ctx.fillText(layer.text || layer.name, x + 12 * scale, y + height / 2, Math.max(24, width - 24 * scale)); + } else if (layer.assetUrl) { + ctx.fillStyle = layer.color || LAYER_COLORS[layer.type]; + ctx.fillRect(x, y, width, height); + } else if (layer.type === 'background') { + ctx.fillStyle = layer.color || LAYER_COLORS.background; + ctx.fillRect(x, y, width, height); + } else { + const radius = Math.min(24 * scale, width / 5, height / 5); + ctx.fillStyle = layer.color || LAYER_COLORS[layer.type]; + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + } + ctx.restore(); +} + +function renderLayeredNodeToCanvas(node: CanvasNode, size = LAYER_CANVAS_SIZE) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('当前浏览器不支持画布导出'); + ctx.clearRect(0, 0, size, size); + const scale = size / LAYER_CANVAS_SIZE; + (node.layers || []).forEach(layer => drawLayerPreview(ctx, layer, scale)); + return canvas; +} + +function getCanvasBounds(nodes: CanvasNode[]) { + if (nodes.length === 0) return null; + const left = Math.min(...nodes.map(node => node.x)); + const top = Math.min(...nodes.map(node => node.y)); + const right = Math.max(...nodes.map(node => node.x + node.width)); + const bottom = Math.max(...nodes.map(node => node.y + node.height)); + return { left, top, right, bottom, width: right - left, height: bottom - top }; +} + +function WorkflowTemplateIcon({ icon, className }: { icon: WorkflowTemplateMeta['icon']; className?: string }) { + const Icon = icon === 'ecommerce' + ? ShoppingCart + : icon === 'character' + ? UserRound + : icon === 'scene' + ? ImageIcon + : icon === 'book' + ? BookOpen + : Layers; + return ; +} + +export function InfiniteCanvasWorkspace() { + const { user, accessToken } = useAuth(); + const { imageKeys } = useCustomApiKeys(); + const managedSystemApis = useManagedSystemApis(); + const fileInputRef = useRef(null); + const importInputRef = useRef(null); + const canvasRef = useRef(null); + const flowControlsRef = useRef(null); + const historyRef = useRef<{ past: CanvasProjectState[]; future: CanvasProjectState[]; skip: boolean }>({ past: [], future: [], skip: false }); + const [projects, setProjects] = useState([]); + const [project, setProject] = useState(null); + const [state, setState] = useState({ nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } }); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedNodeIds, setSelectedNodeIds] = useState([]); + const [addMenu, setAddMenu] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [creatingProject, setCreatingProject] = useState(false); + const [connectingFromId, setConnectingFromId] = useState(null); + const [isDraggingFile, setIsDraggingFile] = useState(false); + const [mounted, setMounted] = useState(false); + const [showNodeMenu, setShowNodeMenu] = useState(false); + const [showWorkflowPanel, setShowWorkflowPanel] = useState(false); + const [workflowTab, setWorkflowTab] = useState<'public' | 'my'>('public'); + const [showProjectMenu, setShowProjectMenu] = useState(false); + const [chatInput, setChatInput] = useState(''); + const [autoExecute, setAutoExecute] = useState(false); + + const selectedNode = useMemo( + () => state.nodes.find(node => node.id === selectedNodeId) || null, + [selectedNodeId, state.nodes], + ); + + const selectedNodes = useMemo( + () => state.nodes.filter(node => selectedNodeIds.includes(node.id)), + [selectedNodeIds, state.nodes], + ); + + const selectedNodeConnections = useMemo( + () => selectedNode + ? state.connections.filter(connection => connection.sourceNodeId === selectedNode.id || connection.targetNodeId === selectedNode.id) + : [], + [selectedNode, state.connections], + ); + + const canvasBounds = useMemo(() => getCanvasBounds(state.nodes), [state.nodes]); + const selectedBounds = useMemo(() => getCanvasBounds(selectedNodes), [selectedNodes]); + + const systemImageApis = managedSystemApis.filter(api => api.type === 'image' && api.isActive); + const modelOptions = useMemo(() => [ + ...systemImageApis.map(api => ({ + id: buildSystemModelId(api.id), + label: `${api.name} (系统)`, + modelName: api.modelName, + source: 'system' as const, + })), + ...imageKeys.map(key => ({ + id: buildCustomModelId(key.id), + label: `${key.modelName || key.provider} (自定义)`, + modelName: key.modelName, + source: 'custom' as const, + })), + ], [systemImageApis, imageKeys]); + + useEffect(() => { + setMounted(true); + }, []); + + const mutateState = useCallback((updater: (current: CanvasProjectState) => CanvasProjectState, options?: { history?: boolean; dirty?: boolean }) => { + setState(current => { + const next = updater(current); + if (!historyRef.current.skip && options?.history !== false && next !== current) { + historyRef.current.past = [...historyRef.current.past.slice(-49), current]; + historyRef.current.future = []; + } + return next; + }); + if (options?.dirty !== false) { + setDirty(true); + } + }, []); + + const replaceCanvasState = useCallback((nextState: CanvasProjectState, options?: { dirty?: boolean }) => { + historyRef.current.skip = true; + setState(nextState); + window.setTimeout(() => { + historyRef.current.skip = false; + }, 0); + setDirty(options?.dirty ?? false); + }, []); + + const resetHistory = useCallback(() => { + historyRef.current = { past: [], future: [], skip: false }; + }, []); + + const screenToCanvasPoint = useCallback((clientX: number, clientY: number, viewport: CanvasProjectState['viewport'] = state.viewport) => { + const rect = canvasRef.current?.getBoundingClientRect(); + const left = rect?.left || 0; + const top = rect?.top || 0; + return { + x: (clientX - left - viewport.x) / viewport.zoom, + y: (clientY - top - viewport.y) / viewport.zoom, + }; + }, [state.viewport]); + + const getViewportCenterPoint = useCallback(() => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return { x: 160, y: 160 }; + return screenToCanvasPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + }, [screenToCanvasPoint]); + + const loadProjects = useCallback(async () => { + if (!accessToken) return; + setLoading(true); + try { + const res = await fetch('/api/canvas/projects', { headers: getAuthHeaders(accessToken) }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '读取画布项目失败'); + const nextProjects = Array.isArray(data.projects) ? data.projects as CanvasProject[] : []; + setProjects(nextProjects); + const lastId = window.localStorage.getItem(LOCAL_DRAFT_KEY); + const nextProject = nextProjects.find(item => item.id === lastId) || nextProjects[0] || null; + if (nextProject) { + setProject(nextProject); + replaceCanvasState({ ...nextProject.state, connections: nextProject.state.connections || [] }); + resetHistory(); + setSelectedNodeId(null); + setSelectedNodeIds([]); + setDirty(false); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '读取画布项目失败'); + } finally { + setLoading(false); + } + }, [accessToken, replaceCanvasState, resetHistory]); + + useEffect(() => { + void loadProjects(); + }, [loadProjects]); + + const openProject = useCallback(async (id: string) => { + if (!accessToken) return; + setLoading(true); + try { + const res = await fetch(`/api/canvas/projects/${encodeURIComponent(id)}`, { headers: getAuthHeaders(accessToken) }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '读取画布失败'); + setProject(data.project as CanvasProject); + replaceCanvasState({ + ...(data.project as CanvasProject).state, + connections: (data.project as CanvasProject).state.connections || [], + }); + resetHistory(); + setSelectedNodeId(null); + setSelectedNodeIds([]); + setDirty(false); + window.localStorage.setItem(LOCAL_DRAFT_KEY, id); + } catch (error) { + toast.error(error instanceof Error ? error.message : '读取画布失败'); + } finally { + setLoading(false); + } + }, [accessToken, replaceCanvasState, resetHistory]); + + const createProject = useCallback(async () => { + if (!accessToken) { + toast.error('请先登录后再使用无限画布'); + return; + } + setCreatingProject(true); + try { + const res = await fetch('/api/canvas/projects', { + method: 'POST', + headers: getAuthHeaders(accessToken), + body: JSON.stringify({ title: `创作画布 ${new Date().toLocaleDateString('zh-CN')}` }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '创建画布失败'); + const nextProject = data.project as CanvasProject; + setProjects(prev => [nextProject, ...prev]); + setProject(nextProject); + replaceCanvasState({ ...nextProject.state, connections: nextProject.state.connections || [] }); + resetHistory(); + setSelectedNodeId(null); + setSelectedNodeIds([]); + setDirty(false); + window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id); + toast.success('已创建画布'); + } catch (error) { + toast.error(error instanceof Error ? error.message : '创建画布失败'); + } finally { + setCreatingProject(false); + } + }, [accessToken, replaceCanvasState, resetHistory]); + + const saveProject = useCallback(async () => { + if (!project || !accessToken) return; + setSaving(true); + try { + const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, { + method: 'PUT', + headers: getAuthHeaders(accessToken), + body: JSON.stringify({ title: project.title, state }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '保存失败'); + const nextProject = data.project as CanvasProject; + setProject(nextProject); + setProjects(prev => prev.map(item => item.id === nextProject.id ? nextProject : item)); + setDirty(false); + toast.success('画布已保存'); + } catch (error) { + toast.error(error instanceof Error ? error.message : '保存失败'); + } finally { + setSaving(false); + } + }, [accessToken, project, state]); + + const deleteCurrentProject = useCallback(async () => { + if (!project || !accessToken) return; + const ok = window.confirm(`确定删除画布“${project.title}”吗?此操作不能撤销。`); + if (!ok) return; + try { + const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, { + method: 'DELETE', + headers: getAuthHeaders(accessToken), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '删除失败'); + const remaining = projects.filter(item => item.id !== project.id); + setProjects(remaining); + const nextProject = remaining[0] || null; + setProject(nextProject); + replaceCanvasState(nextProject ? { ...nextProject.state, connections: nextProject.state.connections || [] } : { nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } }); + resetHistory(); + setSelectedNodeId(null); + setSelectedNodeIds([]); + setDirty(false); + if (nextProject) { + window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id); + } else { + window.localStorage.removeItem(LOCAL_DRAFT_KEY); + } + toast.success('画布已删除'); + } catch (error) { + toast.error(error instanceof Error ? error.message : '删除失败'); + } + }, [accessToken, project, projects, replaceCanvasState, resetHistory]); + + const deleteProject = useCallback(async (target: CanvasProject) => { + if (!accessToken) return; + const ok = window.confirm(`确定删除工作流“${target.title}”吗?此操作不能撤销。`); + if (!ok) return; + try { + const res = await fetch(`/api/canvas/projects/${encodeURIComponent(target.id)}`, { + method: 'DELETE', + headers: getAuthHeaders(accessToken), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '删除失败'); + const remaining = projects.filter(item => item.id !== target.id); + setProjects(remaining); + + if (project?.id !== target.id) { + toast.success('工作流已删除'); + return; + } + + const nextProject = remaining[0] || null; + setProject(nextProject); + replaceCanvasState(nextProject ? { ...nextProject.state, connections: nextProject.state.connections || [] } : { nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } }); + resetHistory(); + setSelectedNodeId(null); + setSelectedNodeIds([]); + setDirty(false); + if (nextProject) { + window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id); + } else { + window.localStorage.removeItem(LOCAL_DRAFT_KEY); + } + toast.success('工作流已删除'); + } catch (error) { + toast.error(error instanceof Error ? error.message : '删除失败'); + } + }, [accessToken, project, projects, replaceCanvasState, resetHistory]); + + const exportProjectJson = useCallback(() => { + if (!project) return; + const safeTitle = project.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'canvas'; + downloadJson({ version: 1, title: project.title, state }, `${safeTitle}.miaojing-canvas.json`); + }, [project, state]); + + const importProjectJson = useCallback(async (file: File) => { + try { + const text = await file.text(); + const parsed = JSON.parse(text) as { title?: unknown; state?: unknown; nodes?: unknown }; + const importedState = parsed.state || parsed; + const nextState = { + ...(importedState as CanvasProjectState), + connections: Array.isArray((importedState as CanvasProjectState).connections) ? (importedState as CanvasProjectState).connections : [], + nodes: Array.isArray((importedState as CanvasProjectState).nodes) ? (importedState as CanvasProjectState).nodes : [], + assets: Array.isArray((importedState as CanvasProjectState).assets) ? (importedState as CanvasProjectState).assets : [], + viewport: (importedState as CanvasProjectState).viewport || { x: 0, y: 0, zoom: 1 }, + }; + replaceCanvasState(nextState, { dirty: true }); + resetHistory(); + if (project && typeof parsed.title === 'string' && parsed.title.trim()) { + setProject({ ...project, title: parsed.title.trim() }); + } + setSelectedNodeId(null); + setSelectedNodeIds([]); + toast.success('画布已导入'); + } catch (error) { + toast.error(error instanceof Error ? error.message : '导入失败'); + } + }, [project, replaceCanvasState, resetHistory]); + + const handleImportFileChange = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + void importProjectJson(file); + }, [importProjectJson]); + + useEffect(() => { + if (!dirty || !project || !accessToken) return; + const timer = window.setTimeout(() => { + void saveProject(); + }, 2500); + return () => window.clearTimeout(timer); + }, [accessToken, dirty, project, saveProject]); + + const updateNode = useCallback((id: string, patch: Partial) => { + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => node.id === id ? { ...node, ...patch, updatedAt: nowIso() } : node), + })); + }, [mutateState]); + + const updateLayer = useCallback((nodeId: string, layerId: string, patch: Partial) => { + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => { + if (node.id !== nodeId) return node; + return { + ...node, + updatedAt: nowIso(), + layers: (node.layers || []).map(layer => layer.id === layerId ? { ...layer, ...patch } : layer), + }; + }), + })); + }, [mutateState]); + + const addLayer = useCallback((nodeId: string, type: CanvasLayer['type']) => { + const layer: CanvasLayer = { + id: createId('layer'), + name: type === 'background' ? '背景层' : type === 'text' ? '文字层' : type === 'icon' ? '图标层' : type === 'effect' ? '效果层' : '元素层', + type, + visible: true, + locked: false, + color: LAYER_COLORS[type], + opacity: type === 'background' ? 1 : 0.9, + text: type === 'text' ? '双击编辑文字' : undefined, + x: type === 'background' ? 0 : 180, + y: type === 'background' ? 0 : 180, + width: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 720 : 360, + height: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 120 : 320, + }; + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: [...(node.layers || []), layer], updatedAt: nowIso() } : node), + })); + }, [mutateState]); + + const removeLayer = useCallback((nodeId: string, layerId: string) => { + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: (node.layers || []).filter(layer => layer.id !== layerId), updatedAt: nowIso() } : node), + })); + }, [mutateState]); + + const moveLayer = useCallback((nodeId: string, layerId: string, direction: -1 | 1) => { + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => { + if (node.id !== nodeId) return node; + const layers = [...(node.layers || [])]; + const index = layers.findIndex(layer => layer.id === layerId); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= layers.length) return node; + const [layer] = layers.splice(index, 1); + layers.splice(nextIndex, 0, layer); + return { ...node, layers, updatedAt: nowIso() }; + }), + })); + }, [mutateState]); + + const addNode = useCallback((type: CanvasNodeType, x: number, y: number, patch: Partial = {}) => { + const zIndex = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1; + const node = createNode(type, x, y, zIndex, patch); + mutateState(current => ({ ...current, nodes: [...current.nodes, node] })); + setSelectedNodeId(node.id); + setSelectedNodeIds([node.id]); + setAddMenu(null); + return node; + }, [mutateState, state.nodes]); + + const addWorkflowTemplate = useCallback((type: WorkflowTemplateType, origin?: { x: number; y: number }) => { + const startX = origin?.x ?? 120; + const startY = origin?.y ?? 120; + const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0); + const createdAt = nowIso(); + const nodes: CanvasNode[] = []; + const connections: CanvasConnection[] = []; + const pushNode = (nodeType: CanvasNodeType, x: number, y: number, patch: Partial = {}) => { + const node = createNode(nodeType, x, y, baseZ + nodes.length + 1, patch); + nodes.push(node); + return node; + }; + const connect = (source: CanvasNode, target: CanvasNode, label?: string) => { + connections.push({ + id: createId('connection'), + sourceNodeId: source.id, + targetNodeId: target.id, + label, + createdAt, + }); + }; + + if (type === 'text2img') { + const prompt = pushNode('text', startX, startY, { title: '提示词草稿', text: '在这里写画面主体、风格、光线、构图和细节要求。' }); + const generator = pushNode('text2img', startX + 380, startY, { title: '文生图生成', prompt: '高质量商业视觉,细节丰富,构图清晰' }); + const result = pushNode('image', startX + 800, startY, { title: '结果图', width: 380, height: 420 }); + connect(prompt, generator, '提示词'); + connect(generator, result, '生成结果'); + } else if (type === 'img2img') { + const reference = pushNode('image', startX, startY, { title: '参考图', width: 360, height: 360 }); + const prompt = pushNode('text', startX, startY + 420, { title: '改图要求', text: '描述保留哪些内容,以及需要替换的风格、背景、元素和细节。' }); + const generator = pushNode('img2img', startX + 420, startY + 120, { title: '图生图生成', prompt: '保留主体结构,提升质感和细节' }); + const result = pushNode('image', startX + 840, startY + 120, { title: '改图结果', width: 380, height: 420 }); + connect(reference, generator, '参考图'); + connect(prompt, generator, '提示词'); + connect(generator, result, '生成结果'); + } else if (type === 'image-chain') { + const prompt = pushNode('text', startX, startY, { title: '创意方向', text: '先生成主视觉,再把结果作为参考继续细化。' }); + const text2img = pushNode('text2img', startX + 380, startY, { title: '第一轮文生图', prompt: '主视觉概念图,清晰主体,完整构图' }); + const first = pushNode('image', startX + 800, startY, { title: '第一轮结果', width: 360, height: 380 }); + const img2img = pushNode('img2img', startX + 1220, startY, { title: '第二轮精修', prompt: '在保留构图基础上增强质感、材质、光影和细节' }); + const final = pushNode('image', startX + 1640, startY, { title: '最终结果', width: 380, height: 420 }); + connect(prompt, text2img, '提示词'); + connect(text2img, first, '生成结果'); + connect(first, img2img, '参考图'); + connect(img2img, final, '生成结果'); + } else { + const prompt = pushNode('text', startX, startY, { title: '图层设计说明', text: '描述背景、主体元素、图标、文字和效果层的编辑要求。' }); + const generator = pushNode('text2img', startX + 380, startY, { title: '视觉生成', prompt: '可拆分图层的海报设计,背景、主体、图标和文字层次清晰' }); + const layered = pushNode('layeredImage', startX + 800, startY, { title: '图层整理与 PSD 导出', width: 460, height: 500 }); + connect(prompt, generator, '提示词'); + connect(generator, layered, '图层整理'); + } + + mutateState(current => ({ + ...current, + nodes: [...current.nodes, ...nodes], + connections: [...current.connections, ...connections], + })); + setSelectedNodeId(nodes[0]?.id || null); + setSelectedNodeIds(nodes.map(node => node.id)); + setAddMenu(null); + toast.success('已添加工作流模板'); + }, [mutateState, state.nodes]); + + const removeSelectedNode = useCallback(() => { + const ids = selectedNodeIds.length > 0 ? selectedNodeIds : selectedNodeId ? [selectedNodeId] : []; + if (ids.length === 0) return; + const idSet = new Set(ids); + mutateState(current => ({ + ...current, + nodes: current.nodes.filter(node => !idSet.has(node.id)), + connections: current.connections.filter( + connection => !idSet.has(connection.sourceNodeId) && !idSet.has(connection.targetNodeId), + ), + })); + setConnectingFromId(current => current && idSet.has(current) ? null : current); + setSelectedNodeId(null); + setSelectedNodeIds([]); + }, [mutateState, selectedNodeId, selectedNodeIds]); + + const duplicateSelectedNode = useCallback(() => { + const sourceNodes = selectedNodes.length > 0 ? selectedNodes : selectedNode ? [selectedNode] : []; + if (sourceNodes.length === 0) return; + const createdAt = nowIso(); + const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0); + const duplicates = sourceNodes.map((source, index): CanvasNode => ({ + ...source, + id: createId(source.type), + title: `${source.title} 副本`, + x: source.x + 32, + y: source.y + 32, + zIndex: baseZ + index + 1, + createdAt, + updatedAt: createdAt, + layers: source.layers?.map(layer => ({ ...layer, id: createId('layer') })), + })); + mutateState(current => ({ ...current, nodes: [...current.nodes, ...duplicates] })); + setSelectedNodeId(duplicates[0]?.id || null); + setSelectedNodeIds(duplicates.map(node => node.id)); + }, [mutateState, selectedNode, selectedNodes, state.nodes]); + + const addConnection = useCallback((sourceNodeId: string, targetNodeId: string, options?: { silent?: boolean }) => { + if (sourceNodeId === targetNodeId) { + toast.info('不能连接到同一个模块'); + return; + } + mutateState(current => { + const exists = current.connections.some( + connection => connection.sourceNodeId === sourceNodeId && connection.targetNodeId === targetNodeId, + ); + if (exists) return current; + const connection: CanvasConnection = { + id: createId('connection'), + sourceNodeId, + targetNodeId, + createdAt: nowIso(), + }; + return { ...current, connections: [...current.connections, connection] }; + }); + const source = state.nodes.find(node => node.id === sourceNodeId); + const target = state.nodes.find(node => node.id === targetNodeId); + if (source?.type === 'image' && source.imageUrl && target?.type === 'img2img') { + updateNode(target.id, { referenceImage: source.imageUrl }); + if (!options?.silent) toast.success('已连接,并将图片设为图生图参考'); + } else if (!options?.silent) { + toast.success('已连接模块'); + } + }, [mutateState, state.nodes, updateNode]); + + const removeConnection = useCallback((connectionId: string) => { + mutateState(current => ({ + ...current, + connections: current.connections.filter(connection => connection.id !== connectionId), + })); + }, [mutateState]); + + const alignSelectedNodes = useCallback((mode: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => { + if (!selectedBounds || selectedNodes.length < 2) return; + const idSet = new Set(selectedNodes.map(node => node.id)); + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => { + if (!idSet.has(node.id)) return node; + if (mode === 'left') return { ...node, x: selectedBounds.left, updatedAt: nowIso() }; + if (mode === 'center') return { ...node, x: selectedBounds.left + selectedBounds.width / 2 - node.width / 2, updatedAt: nowIso() }; + if (mode === 'right') return { ...node, x: selectedBounds.right - node.width, updatedAt: nowIso() }; + if (mode === 'top') return { ...node, y: selectedBounds.top, updatedAt: nowIso() }; + if (mode === 'middle') return { ...node, y: selectedBounds.top + selectedBounds.height / 2 - node.height / 2, updatedAt: nowIso() }; + return { ...node, y: selectedBounds.bottom - node.height, updatedAt: nowIso() }; + }), + })); + }, [mutateState, selectedBounds, selectedNodes]); + + const distributeSelectedNodes = useCallback((axis: 'x' | 'y') => { + if (selectedNodes.length < 3) return; + const sorted = [...selectedNodes].sort((a, b) => axis === 'x' ? a.x - b.x : a.y - b.y); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + if (!first || !last) return; + const start = axis === 'x' ? first.x : first.y; + const end = axis === 'x' ? last.x : last.y; + const step = (end - start) / (sorted.length - 1); + const positions = new Map(sorted.map((node, index) => [node.id, start + step * index])); + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => { + const value = positions.get(node.id); + if (typeof value !== 'number') return node; + return axis === 'x' ? { ...node, x: value, updatedAt: nowIso() } : { ...node, y: value, updatedAt: nowIso() }; + }), + })); + }, [mutateState, selectedNodes]); + + const createFrameAroundSelection = useCallback(() => { + const targetNodes = selectedNodes.filter(node => node.type !== 'frame'); + const bounds = getCanvasBounds(targetNodes); + if (!bounds) { + toast.info('先选择需要整理到分组框里的模块'); + return; + } + const padding = 48; + const frame = addNode('frame', bounds.left - padding, bounds.top - padding, { + title: '分组框', + width: bounds.width + padding * 2, + height: bounds.height + padding * 2, + color: '#22c55e', + text: '流程分组', + zIndex: 0, + }); + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => node.id === frame.id ? { ...node, zIndex: 0 } : node), + })); + setSelectedNodeIds([frame.id, ...targetNodes.map(node => node.id)]); + setSelectedNodeId(frame.id); + }, [addNode, mutateState, selectedNodes]); + + const handleConnectorClick = useCallback((nodeId: string, role: 'input' | 'output') => { + if (role === 'output') { + setConnectingFromId(current => current === nodeId ? null : nodeId); + setSelectedNodeId(nodeId); + setSelectedNodeIds([nodeId]); + return; + } + if (!connectingFromId) { + toast.info('先点击一个模块右侧的输出端口'); + return; + } + addConnection(connectingFromId, nodeId); + setConnectingFromId(null); + setSelectedNodeId(nodeId); + setSelectedNodeIds([nodeId]); + }, [addConnection, connectingFromId]); + + const addImageAsset = useCallback(async (file: File, point?: { x: number; y: number }) => { + const dataUrl = await fileToDataUrl(file); + const dimensions = await getImageDimensions(dataUrl); + const asset: CanvasAsset = { + id: createId('asset'), + url: dataUrl, + name: file.name || '上传图片', + type: 'image', + createdAt: nowIso(), + }; + mutateState(current => ({ ...current, assets: [asset, ...current.assets] })); + addNode('image', point?.x ?? 120, point?.y ?? 120, { + title: file.name || '图片', + imageUrl: dataUrl, + width: Math.min(520, Math.max(260, (dimensions.width || 600) / 2)), + height: Math.min(520, Math.max(240, (dimensions.height || 420) / 2 + 80)), + }); + }, [addNode, mutateState]); + + const renderSingleLayerToCanvas = useCallback((layer: CanvasLayer) => { + const canvas = document.createElement('canvas'); + canvas.width = LAYER_CANVAS_SIZE; + canvas.height = LAYER_CANVAS_SIZE; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('当前浏览器不支持 PSD 图层导出'); + drawLayerPreview(ctx, { ...layer, visible: true }, 1); + return canvas; + }, []); + + const exportLayeredNode = useCallback(async (node: CanvasNode, format: 'png' | 'json' | 'psd') => { + try { + const safeTitle = node.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'layered-image'; + if (format === 'json') { + downloadBlob( + new Blob([JSON.stringify({ version: 1, title: node.title, layers: node.layers || [] }, null, 2)], { type: 'application/json' }), + `${safeTitle}.layers.json`, + ); + return; + } + const canvas = renderLayeredNodeToCanvas(node); + if (format === 'png') { + canvas.toBlob(blob => { + if (blob) downloadBlob(blob, `${safeTitle}.png`); + }, 'image/png'); + return; + } + const { writePsd } = await import('ag-psd'); + type AgPsdLayer = { + name: string; + top: number; + left: number; + bottom: number; + right: number; + hidden?: boolean; + opacity?: number; + canvas?: HTMLCanvasElement; + text?: { + text: string; + transform: [number, number, number, number, number, number]; + style: { + font: { name: string }; + fontSize: number; + fillColor: { r: number; g: number; b: number }; + }; + }; + }; + const children: AgPsdLayer[] = (node.layers || []).map(layer => { + const top = Math.max(0, Math.round(layer.y)); + const left = Math.max(0, Math.round(layer.x)); + const bottom = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.y + layer.height)); + const right = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.x + layer.width)); + const canvasLayer: AgPsdLayer = { + name: layer.name, + top, + left, + bottom, + right, + hidden: !layer.visible, + opacity: Math.round((layer.opacity ?? 1) * 255), + canvas: renderSingleLayerToCanvas(layer), + }; + if (layer.type === 'text') { + const color = layer.color || LAYER_COLORS.text; + const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); + canvasLayer.text = { + text: layer.text || layer.name, + transform: [1, 0, 0, 1, layer.x + 12, layer.y + Math.max(18, Math.min(72, layer.height * 0.45))], + style: { + font: { name: 'ArialMT' }, + fontSize: Math.max(18, Math.min(72, layer.height * 0.45)), + fillColor: rgb + ? { r: parseInt(rgb[1], 16), g: parseInt(rgb[2], 16), b: parseInt(rgb[3], 16) } + : { r: 17, g: 24, b: 39 }, + }, + }; + } + return canvasLayer; + }); + const buffer = writePsd({ + width: LAYER_CANVAS_SIZE, + height: LAYER_CANVAS_SIZE, + children, + canvas, + }, { noBackground: true, invalidateTextLayers: true, generateThumbnail: true }); + downloadBlob(new Blob([buffer], { type: 'image/vnd.adobe.photoshop' }), `${safeTitle}.psd`); + } catch (error) { + toast.error(error instanceof Error ? error.message : '导出失败'); + } + }, [renderSingleLayerToCanvas]); + + const handleFileChange = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + if (!file.type.startsWith('image/')) { + toast.error('请上传图片文件'); + return; + } + void addImageAsset(file, { x: 160, y: 160 }); + }, [addImageAsset]); + + const handlePasteImage = useCallback((event: ClipboardEvent) => { + if (isEditableTarget(event.target)) return; + const file = Array.from(event.clipboardData?.files || []).find(item => item.type.startsWith('image/')); + if (!file) return; + event.preventDefault(); + void addImageAsset(file, screenToCanvasPoint(window.innerWidth / 2, window.innerHeight / 2)); + }, [addImageAsset, screenToCanvasPoint]); + + useEffect(() => { + window.addEventListener('paste', handlePasteImage); + return () => window.removeEventListener('paste', handlePasteImage); + }, [handlePasteImage]); + + const buildGenerationPayload = useCallback((node: CanvasNode, canvasState: CanvasProjectState = state) => { + const incomingNodes = canvasState.connections + .filter(connection => connection.targetNodeId === node.id) + .map(connection => canvasState.nodes.find(item => item.id === connection.sourceNodeId)) + .filter((item): item is CanvasNode => !!item); + const incomingPromptText = incomingNodes.map(getNodeTextValue).filter(Boolean).join('\n'); + const incomingImageNode = incomingNodes.find(item => !!getNodeImageUrl(item)); + const referenceImage = node.referenceImage || getNodeImageUrl(incomingImageNode); + const basePrompt = node.prompt?.trim() || ''; + const prompt = incomingPromptText + ? [incomingPromptText, basePrompt].filter(Boolean).join('\n\n') + : basePrompt; + const selectedModel = node.params?.model || modelOptions[0]?.id || ''; + const aspectRatio = node.params?.aspectRatio || (node.type === 'img2img' ? 'original' : '1:1'); + const resolution = node.params?.resolution || '2K'; + const count = node.params?.count || 1; + const isExternal = isCustomModel(selectedModel) || isSystemModel(selectedModel); + const payload: Record = { + prompt, + negativePrompt: node.negativePrompt?.trim() || undefined, + model: selectedModel, + aspectRatio, + resolution, + quality: resolution, + count, + }; + + if (node.type === 'img2img') { + payload.image = referenceImage; + payload.strength = node.params?.strength ?? 0.5; + payload.size = aspectRatio === 'original' + ? resolveImageSizeFromDimensions(undefined, undefined, resolution) + : isExternal + ? resolveCustomApiImageSize(aspectRatio, resolution) + : resolveImageSize(aspectRatio, resolution); + } else { + payload.size = isExternal + ? resolveCustomApiImageSize(aspectRatio, resolution) + : resolveImageSize(aspectRatio, resolution); + } + + if (isCustomModel(selectedModel)) { + const key = imageKeys.find(item => item.id === getCustomKeyId(selectedModel)); + if (key) { + payload.model = key.modelName; + payload.customApiConfig = { customApiKeyId: key.id, modelName: key.modelName }; + } + } else if (isSystemModel(selectedModel)) { + const api = systemImageApis.find(item => item.id === getSystemApiId(selectedModel)); + if (api) { + payload.model = api.modelName; + payload.customApiConfig = { systemApiId: api.id, modelName: api.modelName }; + } + } + + return payload; + }, [imageKeys, modelOptions, state, systemImageApis]); + + const generateForNode = useCallback(async (node: CanvasNode, options?: { canvasState?: CanvasProjectState; selectOutput?: boolean }) => { + const canvasState = options?.canvasState || state; + if (!user) { + toast.error('请先登录后再生成'); + return null; + } + if (!node.prompt?.trim()) { + const hasIncomingText = canvasState.connections + .filter(connection => connection.targetNodeId === node.id) + .some(connection => { + const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId); + return !!getNodeTextValue(source); + }); + if (!hasIncomingText) { + toast.error('请输入创作描述,或连接文本模块作为提示词'); + return null; + } + } + const hasIncomingImage = canvasState.connections + .filter(connection => connection.targetNodeId === node.id) + .some(connection => { + const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId); + return !!getNodeImageUrl(source); + }); + if (node.type === 'img2img' && !node.referenceImage && !hasIncomingImage) { + toast.error('图生图模块需要先选择参考图'); + return null; + } + updateNode(node.id, { status: 'generating', error: undefined, outputImages: [] }); + try { + const payload = buildGenerationPayload(node, canvasState); + const normalizedModel = String(payload.model || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const timeoutMs = normalizedModel === 'image2' || normalizedModel === 'gptimage2' ? 1_200_000 : 300_000; + const data = await runGenerationJob<{ images?: string[]; referenceImage?: string; apiType?: 'stream' | 'sync'; error?: string }>( + 'image', + payload, + { timeoutMs }, + ); + if (!data.images || data.images.length === 0) { + throw new Error(data.error || '生成结果为空'); + } + const images = data.images; + const apiType = data.apiType === 'stream' ? 'stream' : 'sync'; + const updatedAt = nowIso(); + const sourcePatch: Partial = { + status: 'succeeded', + outputImages: images, + selectedOutput: images[0], + referenceImage: typeof data.referenceImage === 'string' ? data.referenceImage : node.referenceImage, + params: { ...node.params, apiType }, + }; + const existingOutputNode = canvasState.connections + .filter(connection => connection.sourceNodeId === node.id) + .map(connection => canvasState.nodes.find(item => item.id === connection.targetNodeId)) + .find((item): item is CanvasNode => item?.type === 'image'); + const generatedOutputNode = existingOutputNode + ? { + ...existingOutputNode, + imageUrl: images[0], + outputImages: images, + selectedOutput: images[0], + status: 'succeeded' as const, + updatedAt, + } + : createNode('image', node.x + node.width + 40, node.y, canvasState.nodes.reduce((max, item) => Math.max(max, item.zIndex), 0) + 1, { + title: `${node.title}结果`, + imageUrl: images[0], + outputImages: images, + selectedOutput: images[0], + width: 380, + height: 420, + status: 'succeeded', + }); + const outputConnection = existingOutputNode + ? null + : { + id: createId('connection'), + sourceNodeId: node.id, + targetNodeId: generatedOutputNode.id, + label: '生成结果', + createdAt: updatedAt, + }; + const nextCanvasState: CanvasProjectState = { + ...canvasState, + nodes: canvasState.nodes + .map(item => { + if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt }; + if (item.id === generatedOutputNode.id) return generatedOutputNode; + return item; + }) + .concat(existingOutputNode ? [] : [generatedOutputNode]), + connections: outputConnection ? [...canvasState.connections, outputConnection] : canvasState.connections, + }; + mutateState(current => ({ + ...current, + nodes: current.nodes + .map(item => { + if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt }; + if (item.id === generatedOutputNode.id) return generatedOutputNode; + return item; + }) + .concat(current.nodes.some(item => item.id === generatedOutputNode.id) ? [] : [generatedOutputNode]), + connections: outputConnection && !current.connections.some(item => item.sourceNodeId === outputConnection.sourceNodeId && item.targetNodeId === outputConnection.targetNodeId) + ? [...current.connections, outputConnection] + : current.connections, + })); + if (options?.selectOutput !== false) { + setSelectedNodeId(generatedOutputNode.id); + setSelectedNodeIds([generatedOutputNode.id]); + } + toast.success(`已生成 ${images.length} 张图片`); + return { images, outputNodeId: generatedOutputNode.id, canvasState: nextCanvasState }; + } catch (error) { + updateNode(node.id, { status: 'failed', error: error instanceof Error ? error.message : '生成失败' }); + toast.error(error instanceof Error ? error.message : '生成失败'); + return null; + } + }, [buildGenerationPayload, mutateState, state, updateNode, user]); + + const runDownstream = useCallback(async (node: CanvasNode) => { + let workingState = state; + const visited = new Set(); + const queue = [node.id]; + const runnable: CanvasNode[] = node.type === 'text2img' || node.type === 'img2img' ? [node] : []; + while (queue.length > 0) { + const currentId = queue.shift(); + if (!currentId || visited.has(currentId)) continue; + visited.add(currentId); + const outgoing = workingState.connections.filter(connection => connection.sourceNodeId === currentId); + for (const connection of outgoing) { + const target = workingState.nodes.find(item => item.id === connection.targetNodeId); + if (!target || visited.has(target.id)) continue; + if (target.type === 'text2img' || target.type === 'img2img') { + runnable.push(target); + } + queue.push(target.id); + } + } + const uniqueRunnable = runnable.filter((item, index, array) => array.findIndex(candidate => candidate.id === item.id) === index); + if (uniqueRunnable.length === 0) { + toast.info('下游没有可执行的生图模块'); + return; + } + for (const target of uniqueRunnable) { + const latest = workingState.nodes.find(item => item.id === target.id) || target; + const result = await generateForNode(latest, { canvasState: workingState, selectOutput: false }); + if (!result) return; + workingState = result.canvasState; + } + const finalNodeId = uniqueRunnable[uniqueRunnable.length - 1]?.id || node.id; + setSelectedNodeId(finalNodeId); + setSelectedNodeIds([finalNodeId]); + toast.success('下游流程已执行完成'); + }, [generateForNode, state]); + + const handleFlowNodeSelect = useCallback((nodeId: string, additive: boolean) => { + setAddMenu(null); + setConnectingFromId(null); + setSelectedNodeId(nodeId); + setSelectedNodeIds(current => { + if (!additive) return current.includes(nodeId) ? current : [nodeId]; + return current.includes(nodeId) ? current.filter(id => id !== nodeId) : [...current, nodeId]; + }); + }, []); + + const handleFlowNodesCommit = useCallback((positions: { id: string; x: number; y: number }[], options?: { history?: boolean; dirty?: boolean }) => { + if (positions.length === 0) return; + const positionMap = new Map(positions.map(position => [position.id, position])); + mutateState(current => ({ + ...current, + nodes: current.nodes.map(node => { + const position = positionMap.get(node.id); + if (!position || (node.x === position.x && node.y === position.y)) return node; + return { ...node, x: position.x, y: position.y, updatedAt: nowIso() }; + }), + }), { history: options?.history ?? true, dirty: options?.dirty ?? true }); + }, [mutateState]); + + const handleFlowConnect = useCallback((sourceId: string, targetId: string) => { + addConnection(sourceId, targetId); + setConnectingFromId(null); + setSelectedNodeId(targetId); + setSelectedNodeIds([targetId]); + }, [addConnection]); + + const handleFlowSelectionChange = useCallback((ids: string[]) => { + setSelectedNodeIds(current => sameStringArray(current, ids) ? current : ids); + setSelectedNodeId(current => current === (ids[0] || null) ? current : ids[0] || null); + }, []); + + const handleRemoveFlowConnections = useCallback((ids: string[]) => { + if (ids.length === 0) return; + mutateState(current => ({ + ...current, + connections: current.connections.filter(connection => !ids.includes(connection.id)), + })); + }, [mutateState]); + + const handleFlowStartConnect = useCallback((id: string) => { + handleConnectorClick(id, 'output'); + }, [handleConnectorClick]); + + const handleViewportCommit = useCallback((viewport: CanvasViewport) => { + const nextViewport = { + x: viewport.x, + y: viewport.y, + zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewport.zoom)), + }; + mutateState(current => ({ ...current, viewport: nextViewport }), { history: false, dirty: false }); + }, [mutateState]); + + const handlePaneClick = useCallback((point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => { + setConnectingFromId(null); + setSelectedNodeId(null); + setSelectedNodeIds([]); + setAddMenu(null); + if (event.detail !== 1) return; + setAddMenu({ x: point.x, y: point.y, screenX: event.clientX, screenY: event.clientY }); + }, []); + + const handlePaneDoubleClick = useCallback((point: { x: number; y: number }) => { + addNode('text2img', point.x, point.y); + }, [addNode]); + + const handleCanvasDragOver = useCallback((event: React.DragEvent) => { + if (!Array.from(event.dataTransfer?.items || []).some(item => item.kind === 'file' && item.type.startsWith('image/'))) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + setIsDraggingFile(true); + }, []); + + const handleCanvasDragLeave = useCallback((event: React.DragEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + setIsDraggingFile(false); + }, []); + + const handleCanvasDrop = useCallback((event: React.DragEvent) => { + const files = Array.from(event.dataTransfer?.files || []).filter(file => file.type.startsWith('image/')); + if (files.length === 0) return; + event.preventDefault(); + setIsDraggingFile(false); + const point = screenToCanvasPoint(event.clientX, event.clientY); + files.slice(0, 6).forEach((file, index) => { + void addImageAsset(file, { x: point.x + index * 28, y: point.y + index * 28 }); + }); + }, [addImageAsset, screenToCanvasPoint]); + + const setZoom = useCallback((zoom: number) => { + const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); + const controls = flowControlsRef.current; + if (controls) { + void controls.zoomTo(nextZoom).then(() => { + handleViewportCommit(controls.getViewport()); + }); + return; + } + mutateState(current => ({ + ...current, + viewport: { ...current.viewport, zoom: nextZoom }, + })); + }, [handleViewportCommit, mutateState]); + + const undoCanvas = useCallback(() => { + const previous = historyRef.current.past.pop(); + if (!previous) return; + historyRef.current.future = [state, ...historyRef.current.future.slice(0, 49)]; + replaceCanvasState(previous, { dirty: true }); + setSelectedNodeId(null); + setSelectedNodeIds([]); + }, [replaceCanvasState, state]); + + const redoCanvas = useCallback(() => { + const next = historyRef.current.future.shift(); + if (!next) return; + historyRef.current.past = [...historyRef.current.past.slice(-49), state]; + replaceCanvasState(next, { dirty: true }); + setSelectedNodeId(null); + setSelectedNodeIds([]); + }, [replaceCanvasState, state]); + + const resetView = useCallback(() => { + const nextViewport = { x: 0, y: 0, zoom: 1 }; + const controls = flowControlsRef.current; + if (controls) { + void controls.setViewport(nextViewport).then(() => { + handleViewportCommit(controls.getViewport()); + }); + return; + } + mutateState(current => ({ + ...current, + viewport: nextViewport, + })); + }, [handleViewportCommit, mutateState]); + + const fitToContent = useCallback(() => { + const bounds = getCanvasBounds(state.nodes); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!bounds || !rect) { + resetView(); + return; + } + const padding = 96; + const zoom = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, Math.min((rect.width - padding * 2) / Math.max(bounds.width, 1), (rect.height - padding * 2) / Math.max(bounds.height, 1))), + ); + const fitBounds = { + x: bounds.left - padding, + y: bounds.top - padding, + width: bounds.width + padding * 2, + height: bounds.height + padding * 2, + }; + const controls = flowControlsRef.current; + if (controls) { + void controls.fitBounds(fitBounds, { padding: 0, duration: 120 }).then(() => { + handleViewportCommit(controls.getViewport()); + }); + return; + } + mutateState(current => ({ + ...current, + viewport: { + zoom, + x: rect.width / 2 - (bounds.left + bounds.width / 2) * zoom, + y: rect.height / 2 - (bounds.top + bounds.height / 2) * zoom, + }, + })); + }, [handleViewportCommit, mutateState, resetView, state.nodes]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isEditableTarget(event.target)) return; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + void saveProject(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && event.shiftKey) { + event.preventDefault(); + redoCanvas(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { + event.preventDefault(); + undoCanvas(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') { + event.preventDefault(); + redoCanvas(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') { + event.preventDefault(); + duplicateSelectedNode(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault(); + const ids = state.nodes.map(node => node.id); + setSelectedNodeIds(ids); + setSelectedNodeId(ids[0] || null); + } else if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + removeSelectedNode(); + } else if (event.key === 'Escape') { + setAddMenu(null); + setConnectingFromId(null); + setSelectedNodeIds([]); + setSelectedNodeId(null); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [duplicateSelectedNode, redoCanvas, removeSelectedNode, saveProject, state.nodes, undoCanvas]); + + const activeModelValue = selectedNode?.params?.model || modelOptions[0]?.id || ''; + + if (!mounted) { + return ( +
+
+ +

正在打开无限画布

+
+
+ ); + } + + if (!user) { + return ( +
+
+ +

登录后使用无限画布

+

画布项目会保存到你的账号下,可用于多轮图像创作和后续编辑。

+
+
+ ); + } + + return ( +
+ + +
+
+
+ + { + if (!project) return; + setProject({ ...project, title: event.target.value }); + setDirty(true); + }} + disabled={!project} + /> + {dirty ? 未保存 : 已保存} +
+ +
+ + + + + + {Math.round(state.viewport.zoom * 100)}% + + + + + +
+
+ +
+ { + flowControlsRef.current = controls; + }} + onPaneClick={handlePaneClick} + onPaneDoubleClick={handlePaneDoubleClick} + /> + + {isDraggingFile ? ( +
+ 松开鼠标,把图片添加到画布 +
+ ) : null} +
+ 空白点击添加模块 · 双击添加文生图 · Ctrl+Z 撤销 · Ctrl+D 复制 · Delete 删除 +
+ {!project ? ( +
+ +
+ ) : null} + +
+ + + + + + + + {selectedNodes.length > 1 ? ( + <> + + + + ) : null} +
+ + {addMenu ? ( +
+ + + + + + +
+ + + + +
+ ) : null} +
+ +
+ +