Add canvas workflow and harden data import

This commit is contained in:
FengLee
2026-05-09 23:54:18 +08:00
parent 1a0607fe8d
commit 24be9c550b
15 changed files with 3257 additions and 4 deletions

View File

@@ -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",

166
pnpm-lock.yaml generated
View File

@@ -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: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

View File

@@ -75,6 +75,7 @@ type ImportContext = {
apiKeyIdMap: Map<string, string>;
apiKeyOwnerIdMap: Map<string, string>;
columnCache: Map<string, Set<string>>;
defaultableColumnCache: Map<string, Set<string>>;
};
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<string, unknown>, 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<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
@@ -331,6 +352,12 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, 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<ReturnType<typeof getDbClient>>,
table: string,
context: ImportContext,
): Promise<Set<string>> {
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, unknown>) => String(row.column_name)));
context.defaultableColumnCache.set(table, columns);
return columns;
}
function seedUuidMap(map: Map<string, string>, value: unknown): void {
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
map.set(value, crypto.randomUUID());

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

5
src/app/canvas/page.tsx Executable file
View File

@@ -0,0 +1,5 @@
import { InfiniteCanvasWorkspace } from '@/components/canvas/infinite-canvas-workspace';
export default function CanvasPage() {
return <InfiniteCanvasWorkspace />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,553 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Background,
ConnectionLineType,
Controls,
Handle,
MarkerType,
MiniMap,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Connection,
type Edge,
type EdgeChange,
type Node as FlowNode,
type NodeChange,
type NodeProps,
type Rect,
type ReactFlowInstance,
type Viewport as FlowViewport,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Brush, FileImage, Image as ImageIcon, Layers, Link2, Loader2, Maximize2, Move, Type } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CanvasConnection, CanvasLayer, CanvasNode, CanvasViewport } from '@/lib/canvas-types';
type NodePositionCommit = { id: string; x: number; y: number };
type CanvasFlowNodeData = {
node: CanvasNode;
selected: boolean;
connecting: boolean;
connections: CanvasConnection[];
allNodes: CanvasNode[];
layerCanvasSize: number;
layerColors: Record<CanvasLayer['type'], string>;
onSelect: (id: string, additive: boolean) => void;
onStartConnect: (id: string) => void;
onRemoveConnection: (id: string) => void;
};
type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
type ReactFlowCanvasProps = {
nodes: CanvasNode[];
connections: CanvasConnection[];
viewport: CanvasViewport;
selectedNodeIds: string[];
connectingFromId: string | null;
editable: boolean;
minZoom: number;
maxZoom: number;
layerCanvasSize: number;
layerColors: Record<CanvasLayer['type'], string>;
onSelectNode: (id: string, additive: boolean) => void;
onSelectionChange: (ids: string[]) => void;
onStartConnect: (id: string) => void;
onConnect: (sourceId: string, targetId: string) => void;
onRemoveConnection: (id: string) => void;
onRemoveConnections: (ids: string[]) => void;
onNodesCommit: (positions: NodePositionCommit[], options?: { history?: boolean; dirty?: boolean }) => void;
onViewportCommit: (viewport: CanvasViewport) => void;
onReady?: (controls: CanvasFlowControls | null) => void;
onPaneClick: (point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => void;
onPaneDoubleClick: (point: { x: number; y: number }) => void;
};
export type CanvasFlowControls = {
setViewport: (viewport: CanvasViewport) => Promise<boolean>;
zoomTo: (zoom: number) => Promise<boolean>;
fitBounds: (bounds: Rect, options?: { padding?: number; duration?: number }) => Promise<boolean>;
getViewport: () => CanvasViewport;
};
const CONNECTION_COLORS = ['#22c55e', '#06b6d4', '#8b5cf6', '#f59e0b', '#ef4444', '#14b8a6'];
const SNAP_GRID: [number, number] = [20, 20];
const MULTI_SELECTION_KEYS = ['Meta', 'Control', 'Shift'];
const FLOW_FIT_VIEW_OPTIONS = { padding: 0.2 };
const CANVAS_NODE_TYPES = { canvasNode: CanvasNodeCard };
function getNodeImageUrl(node?: CanvasNode | null) {
if (!node) return '';
if (node.type === 'image') return node.imageUrl || '';
return node.selectedOutput || node.outputImages?.[0] || '';
}
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
const { node, selected, connecting, connections, allNodes, layerCanvasSize, layerColors, onSelect, onStartConnect, onRemoveConnection } = data;
const incomingImage = allNodes.find(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id && !!getNodeImageUrl(item)));
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
return (
<div
data-canvas-node
data-node-id={node.id}
className={cn(
'group h-full overflow-visible rounded-lg border transition-shadow',
node.type === 'frame' ? 'bg-background/20 shadow-none' : 'bg-card shadow-sm',
selected ? 'border-primary ring-2 ring-primary/20' : connecting ? 'border-emerald-500 ring-2 ring-emerald-500/20' : 'border-border',
)}
style={{ borderColor: node.type === 'frame' && !selected ? node.color || '#22c55e' : undefined }}
onPointerDown={(event) => {
const target = event.target as HTMLElement;
if (target.closest('button,input,textarea,[role="combobox"],.nodrag')) return;
onSelect(node.id, event.ctrlKey || event.metaKey || event.shiftKey);
}}
>
<Handle type="target" position={Position.Left} id="input" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
<Handle type="source" position={Position.Right} id="output" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
<button
type="button"
className={cn(
'nodrag absolute right-0 top-1/2 z-20 flex h-7 w-7 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100',
connecting ? 'border-emerald-500 bg-emerald-500 text-white opacity-100' : '',
)}
title="从此模块开始连线"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onStartConnect(node.id);
}}
>
<Link2 className="h-3.5 w-3.5" />
</button>
<div className="h-full overflow-hidden rounded-lg">
<div className="flex h-10 items-center justify-between border-b bg-muted/40 px-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
{node.type === 'text' ? <Type className="h-4 w-4" /> : null}
{node.type === 'image' ? <ImageIcon className="h-4 w-4" /> : null}
{node.type === 'text2img' ? <Brush className="h-4 w-4" /> : null}
{node.type === 'img2img' ? <FileImage className="h-4 w-4" /> : null}
{node.type === 'layeredImage' ? <Layers className="h-4 w-4" /> : null}
{node.type === 'frame' ? <Maximize2 className="h-4 w-4" /> : null}
<span className="truncate">{node.title}</span>
</div>
{node.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin text-primary" /> : <Move className="h-4 w-4 text-muted-foreground" />}
</div>
<div className="h-[calc(100%-2.5rem)] p-3">
{node.type === 'text' ? (
<div className="h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-sm leading-6">{node.text}</div>
) : null}
{node.type === 'image' ? (
<div className="h-full overflow-hidden rounded-md bg-muted">
{node.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={node.imageUrl} alt={node.title} className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"></div>
)}
</div>
) : null}
{node.type === 'text2img' || node.type === 'img2img' ? (
<div className="flex h-full flex-col gap-3">
<div className="line-clamp-3 min-h-14 rounded-md bg-muted/30 p-3 text-sm text-muted-foreground">
{node.prompt || (connections.some(connection => connection.targetNodeId === node.id) ? '已连接上游内容,可继续补充描述' : '在右侧输入创作描述')}
</div>
{node.type === 'img2img' ? (
<div className="h-20 overflow-hidden rounded-md border bg-muted">
{node.referenceImage || getNodeImageUrl(incomingImage) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={node.referenceImage || getNodeImageUrl(incomingImage)} alt="参考图" className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground"></div>
)}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
{node.selectedOutput ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={node.selectedOutput} alt="生成结果" className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : '等待生成'}
</div>
)}
</div>
</div>
) : null}
{node.type === 'layeredImage' ? (
<div className="grid h-full grid-cols-[1fr_120px] gap-3 rounded-md border bg-muted/20 p-3">
<div className="relative overflow-hidden rounded-md border bg-background">
<div className="absolute inset-0 bg-[linear-gradient(45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted))_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted))_75%)] bg-[length:24px_24px] bg-[position:0_0,0_12px,12px_-12px,-12px_0px] opacity-35" />
{(node.layers || []).filter(layer => layer.visible).map(layer => (
<div
key={layer.id}
className={cn(
'absolute border border-white/60 shadow-sm',
layer.type === 'text' ? 'flex items-center px-2 text-xs font-semibold' : 'rounded-md',
layer.locked ? 'outline outline-1 outline-dashed outline-muted-foreground/70' : '',
)}
style={{
left: `${(layer.x / layerCanvasSize) * 100}%`,
top: `${(layer.y / layerCanvasSize) * 100}%`,
width: `${(layer.width / layerCanvasSize) * 100}%`,
height: `${(layer.height / layerCanvasSize) * 100}%`,
backgroundColor: layer.type === 'text' ? 'transparent' : layer.color || layerColors[layer.type],
color: layer.color || layerColors.text,
opacity: layer.opacity ?? 1,
}}
>
{layer.type === 'text' ? layer.text || layer.name : null}
</div>
))}
</div>
<div className="min-w-0 space-y-1 overflow-y-auto">
{(node.layers || []).slice().reverse().map(layer => (
<div key={layer.id} className="truncate rounded-md border bg-background px-2 py-1 text-[11px]">
<span className={layer.visible ? '' : 'text-muted-foreground line-through'}>{layer.name}</span>
</div>
))}
</div>
</div>
) : null}
{node.type === 'frame' ? (
<div className="flex h-full flex-col justify-end rounded-md border border-dashed bg-background/25 p-3 text-xs text-muted-foreground" style={{ borderColor: node.color || '#22c55e' }}>
<div className="rounded-md bg-background/80 px-3 py-2 shadow-sm">{node.text || '流程分组'}</div>
</div>
) : null}
</div>
</div>
{selected && nodeConnections.length > 0 ? (
<div className="nodrag absolute left-3 top-full z-30 mt-2 w-64 rounded-lg border bg-background/95 p-2 text-xs shadow-lg backdrop-blur">
<div className="mb-1 font-medium text-muted-foreground">线</div>
<div className="space-y-1">
{nodeConnections.slice(0, 4).map(connection => {
const source = allNodes.find(item => item.id === connection.sourceNodeId);
const target = allNodes.find(item => item.id === connection.targetNodeId);
return (
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border px-2 py-1">
<span className="min-w-0 truncate">{source?.title || '未知模块'} {target?.title || '未知模块'}</span>
<button className="text-destructive hover:underline" onClick={() => onRemoveConnection(connection.id)}></button>
</div>
);
})}
</div>
</div>
) : null}
</div>
);
}
function sameFlowNodePositions(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
if (a.length !== b.length) return false;
const bMap = new Map(b.map(node => [node.id, node]));
return a.every(node => {
const next = bMap.get(node.id);
return !!next && node.position.x === next.position.x && node.position.y === next.position.y;
});
}
function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
if (a.length !== b.length) return false;
const bMap = new Map(b.map(node => [node.id, node]));
return a.every(node => {
const next = bMap.get(node.id);
return !!next
&& node.position.x === next.position.x
&& node.position.y === next.position.y
&& node.selected === next.selected
&& node.draggable === next.draggable
&& node.selectable === next.selectable
&& node.width === next.width
&& node.height === next.height
&& node.data.node === next.data.node
&& node.data.selected === next.data.selected
&& node.data.connecting === next.data.connecting
&& node.data.connections === next.data.connections
&& node.data.allNodes === next.data.allNodes;
});
}
function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number): CanvasViewport {
return {
x: viewport.x,
y: viewport.y,
zoom: Math.min(maxZoom, Math.max(minZoom, viewport.zoom)),
};
}
function FlowCanvasInner({
nodes,
connections,
viewport,
selectedNodeIds,
connectingFromId,
editable,
minZoom,
maxZoom,
layerCanvasSize,
layerColors,
onSelectNode,
onSelectionChange,
onStartConnect,
onConnect,
onRemoveConnection,
onRemoveConnections,
onNodesCommit,
onViewportCommit,
onReady,
onPaneClick,
onPaneDoubleClick,
}: ReactFlowCanvasProps) {
const reactFlow = useReactFlow<CanvasFlowNode, Edge>();
const draggingRef = useRef(false);
const movingRef = useRef(false);
const paneClickTimerRef = useRef<number | null>(null);
const incomingNodes = useMemo<CanvasFlowNode[]>(() => nodes.map(node => ({
id: node.id,
type: 'canvasNode',
position: { x: node.x, y: node.y },
width: node.width,
height: node.height,
selected: selectedNodeIds.includes(node.id),
draggable: editable,
selectable: editable,
zIndex: node.type === 'frame' ? Math.min(node.zIndex, 0) : node.zIndex,
data: {
node,
selected: selectedNodeIds.includes(node.id),
connecting: connectingFromId === node.id,
connections,
allNodes: nodes,
layerCanvasSize,
layerColors,
onSelect: onSelectNode,
onStartConnect,
onRemoveConnection,
},
style: {
width: node.width,
height: node.height,
},
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, nodes, onRemoveConnection, onSelectNode, onStartConnect, selectedNodeIds]);
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);
const flowEdges = useMemo<Edge[]>(() => connections.map((connection, index) => ({
id: connection.id,
source: connection.sourceNodeId,
target: connection.targetNodeId,
sourceHandle: 'output',
targetHandle: 'input',
label: connection.label,
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed },
style: {
stroke: CONNECTION_COLORS[index % CONNECTION_COLORS.length],
strokeWidth: 2.5,
},
labelBgStyle: { fill: 'hsl(var(--background))', fillOpacity: 0.9 },
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 12 },
})), [connections]);
useEffect(() => {
setFlowNodes(current => {
if (draggingRef.current && sameFlowNodePositions(current, incomingNodes)) return current;
if (sameFlowNodes(current, incomingNodes)) return current;
return incomingNodes;
});
}, [incomingNodes]);
useEffect(() => () => {
if (paneClickTimerRef.current !== null) {
window.clearTimeout(paneClickTimerRef.current);
}
}, []);
useEffect(() => () => {
onReady?.(null);
}, [onReady]);
const commitCurrentNodePositions = useCallback((options?: { history?: boolean; dirty?: boolean }) => {
const positions = reactFlow.getNodes().map(node => ({
id: node.id,
x: node.position.x,
y: node.position.y,
}));
onNodesCommit(positions, options);
}, [onNodesCommit, reactFlow]);
const handleNodesChange = useCallback((changes: NodeChange<CanvasFlowNode>[]) => {
const relevantChanges = changes.filter(change => change.type === 'position' || change.type === 'select' || change.type === 'remove');
if (relevantChanges.length === 0) return;
setFlowNodes(current => {
const removedIds = new Set(
relevantChanges
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'remove' }> => change.type === 'remove')
.map(change => change.id),
);
let changed = removedIds.size > 0;
const nextNodes = current.map(node => {
if (removedIds.has(node.id)) return node;
const positionChange = relevantChanges.find(
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'position' }> => change.type === 'position' && change.id === node.id && !!change.position,
);
const selectChange = relevantChanges.find(
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.id === node.id,
);
const nextPosition = positionChange?.position || node.position;
const nextSelected = typeof selectChange?.selected === 'boolean' ? selectChange.selected : node.selected;
if (nextPosition === node.position && nextSelected === node.selected) return node;
changed = true;
return {
...node,
position: nextPosition,
selected: nextSelected,
};
}).filter(node => !removedIds.has(node.id));
return changed ? nextNodes : current;
});
const selected = relevantChanges
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.selected)
.map(change => change.id);
if (selected.length > 0) onSelectionChange(selected);
}, [onSelectionChange]);
const handleEdgesChange = useCallback((changes: EdgeChange<Edge>[]) => {
const removedIds = changes.filter(change => change.type === 'remove').map(change => change.id);
if (removedIds.length > 0) onRemoveConnections(removedIds);
}, [onRemoveConnections]);
const handleConnect = useCallback((connection: Connection) => {
if (!connection.source || !connection.target) return;
onConnect(connection.source, connection.target);
}, [onConnect]);
const handleMoveEnd = useCallback((_event: MouseEvent | TouchEvent | null, nextViewport: FlowViewport) => {
if (!movingRef.current) return;
movingRef.current = false;
const committedViewport = clampViewport(nextViewport, minZoom, maxZoom);
onViewportCommit(committedViewport);
}, [maxZoom, minZoom, onViewportCommit]);
const handleNodeDragStart = useCallback(() => {
draggingRef.current = true;
}, []);
const handleNodeDragStop = useCallback(() => {
draggingRef.current = false;
commitCurrentNodePositions({ history: true, dirty: true });
}, [commitCurrentNodePositions]);
const handleMoveStart = useCallback(() => {
movingRef.current = true;
}, []);
const handlePaneReactFlowClick = useCallback((event: React.MouseEvent) => {
const point = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
if (event.detail >= 2) {
if (paneClickTimerRef.current !== null) {
window.clearTimeout(paneClickTimerRef.current);
paneClickTimerRef.current = null;
}
onPaneDoubleClick(reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY }, { snapToGrid: true, snapGrid: SNAP_GRID }));
return;
}
if (paneClickTimerRef.current !== null) {
window.clearTimeout(paneClickTimerRef.current);
}
paneClickTimerRef.current = window.setTimeout(() => {
paneClickTimerRef.current = null;
onPaneClick(point, event);
}, 180);
}, [onPaneClick, onPaneDoubleClick, reactFlow]);
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: CanvasFlowNode[] }) => {
onSelectionChange(selectedNodes.map(node => node.id));
}, [onSelectionChange]);
const getMiniMapNodeColor = useCallback((node: FlowNode) => (
selectedNodeIds.includes(node.id) ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'
), [selectedNodeIds]);
const handleInit = useCallback((instance: ReactFlowInstance<CanvasFlowNode, Edge>) => {
onReady?.({
setViewport: (nextViewport) => instance.setViewport(nextViewport, { duration: 120 }),
zoomTo: (zoom) => instance.zoomTo(Math.min(maxZoom, Math.max(minZoom, zoom)), { duration: 120 }),
fitBounds: (bounds, options) => instance.fitBounds(bounds, options),
getViewport: () => clampViewport(instance.getViewport(), minZoom, maxZoom),
});
}, [maxZoom, minZoom, onReady]);
return (
<ReactFlow
className="canvas-flow"
nodes={flowNodes}
edges={flowEdges}
nodeTypes={CANVAS_NODE_TYPES}
minZoom={minZoom}
maxZoom={maxZoom}
snapToGrid
snapGrid={SNAP_GRID}
defaultViewport={viewport}
zoomOnScroll
zoomOnPinch
panOnScroll={false}
panOnDrag
selectionOnDrag={false}
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
deleteKeyCode={null}
zoomOnDoubleClick={false}
nodesDraggable={editable}
nodesConnectable={editable}
nodesFocusable={false}
edgesFocusable={false}
connectionLineType={ConnectionLineType.SmoothStep}
fitViewOptions={FLOW_FIT_VIEW_OPTIONS}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onNodeDragStart={handleNodeDragStart}
onNodeDragStop={handleNodeDragStop}
onMoveStart={handleMoveStart}
onMoveEnd={handleMoveEnd}
onPaneClick={handlePaneReactFlowClick}
onSelectionChange={handleSelectionChange}
onInit={handleInit}
>
<Background gap={20} size={1} color="hsl(var(--border))" />
<Background gap={100} size={1.2} color="hsl(var(--muted-foreground))" />
<Controls showInteractive={false} position="bottom-left" />
<MiniMap
position="bottom-right"
pannable
zoomable
nodeColor={getMiniMapNodeColor}
maskColor="hsl(var(--background) / 0.58)"
/>
</ReactFlow>
);
}
export function ReactFlowCanvas(props: ReactFlowCanvasProps) {
return (
<ReactFlowProvider>
<FlowCanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@@ -17,6 +17,7 @@ import {
Sparkles,
LogOut,
Shield,
PanelsTopLeft,
Moon,
Sun,
} from 'lucide-react';
@@ -26,6 +27,7 @@ const navItems = [
{ href: '/', label: '首页', icon: Sparkles },
{ href: '/create', label: '创作', icon: Brush },
{ href: '/gallery', label: '画廊', icon: LayoutGrid },
{ href: '/canvas', label: '画布', icon: PanelsTopLeft },
{ href: '/profile', label: '我的', icon: User },
];

150
src/lib/canvas-store.ts Executable file
View File

@@ -0,0 +1,150 @@
import { getDbClient } from '@/storage/database/local-db';
import { createEmptyCanvasState, type CanvasProject, type CanvasProjectState } from '@/lib/canvas-types';
import crypto from 'crypto';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function normalizeCanvasState(value: unknown): CanvasProjectState {
if (!isRecord(value)) return createEmptyCanvasState();
const fallback = createEmptyCanvasState();
const viewport = isRecord(value.viewport) ? value.viewport : fallback.viewport;
return {
nodes: Array.isArray(value.nodes) ? value.nodes as CanvasProjectState['nodes'] : [],
connections: Array.isArray(value.connections) ? value.connections as CanvasProjectState['connections'] : [],
assets: Array.isArray(value.assets) ? value.assets as CanvasProjectState['assets'] : [],
viewport: {
x: typeof viewport.x === 'number' ? viewport.x : 0,
y: typeof viewport.y === 'number' ? viewport.y : 0,
zoom: typeof viewport.zoom === 'number' ? Math.min(3, Math.max(0.2, viewport.zoom)) : 1,
},
};
}
export async function ensureCanvasSchema() {
const client = await getDbClient();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS canvas_projects (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
title text NOT NULL DEFAULT '未命名画布',
state jsonb NOT NULL DEFAULT '{"nodes":[],"assets":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await client.query('CREATE INDEX IF NOT EXISTS idx_canvas_projects_user_updated ON canvas_projects(user_id, updated_at DESC)');
} finally {
client.release();
}
}
function mapProject(row: Record<string, unknown>): CanvasProject {
return {
id: String(row.id || ''),
userId: String(row.user_id || ''),
title: String(row.title || '未命名画布'),
state: normalizeCanvasState(row.state),
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at || ''),
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at || ''),
};
}
export async function listCanvasProjects(userId: string): Promise<CanvasProject[]> {
await ensureCanvasSchema();
const client = await getDbClient();
try {
const result = await client.query(
`SELECT id, user_id, title, state, created_at, updated_at
FROM canvas_projects
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT 50`,
[userId],
);
return result.rows.map(mapProject);
} finally {
client.release();
}
}
export async function createCanvasProject(userId: string, title = '未命名画布'): Promise<CanvasProject> {
await ensureCanvasSchema();
const client = await getDbClient();
try {
const id = crypto.randomUUID();
const result = await client.query(
`INSERT INTO canvas_projects (id, user_id, title, state)
VALUES ($1, $2, $3, $4::jsonb)
RETURNING id, user_id, title, state, created_at, updated_at`,
[id, userId, title.trim() || '未命名画布', JSON.stringify(createEmptyCanvasState())],
);
return mapProject(result.rows[0]);
} finally {
client.release();
}
}
export async function getCanvasProject(userId: string, id: string): Promise<CanvasProject | null> {
await ensureCanvasSchema();
const client = await getDbClient();
try {
const result = await client.query(
`SELECT id, user_id, title, state, created_at, updated_at
FROM canvas_projects
WHERE id = $1 AND user_id = $2
LIMIT 1`,
[id, userId],
);
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
} finally {
client.release();
}
}
export async function updateCanvasProject(
userId: string,
id: string,
values: { title?: string; state?: CanvasProjectState },
): Promise<CanvasProject | null> {
await ensureCanvasSchema();
const client = await getDbClient();
try {
const current = await client.query(
'SELECT title, state FROM canvas_projects WHERE id = $1 AND user_id = $2 LIMIT 1',
[id, userId],
);
if (current.rows.length === 0) return null;
const title = typeof values.title === 'string' && values.title.trim()
? values.title.trim()
: current.rows[0].title;
const state = values.state ? normalizeCanvasState(values.state) : normalizeCanvasState(current.rows[0].state);
const result = await client.query(
`UPDATE canvas_projects
SET title = $3, state = $4::jsonb, updated_at = now()
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, title, state, created_at, updated_at`,
[id, userId, title, JSON.stringify(state)],
);
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
} finally {
client.release();
}
}
export async function deleteCanvasProject(userId: string, id: string): Promise<boolean> {
await ensureCanvasSchema();
const client = await getDbClient();
try {
const result = await client.query(
'DELETE FROM canvas_projects WHERE id = $1 AND user_id = $2',
[id, userId],
);
return (result.rowCount || 0) > 0;
} finally {
client.release();
}
}

97
src/lib/canvas-types.ts Executable file
View File

@@ -0,0 +1,97 @@
export type CanvasNodeType = 'text' | 'image' | 'text2img' | 'img2img' | 'layeredImage' | 'frame';
export type CanvasViewport = {
x: number;
y: number;
zoom: number;
};
export type CanvasAsset = {
id: string;
url: string;
name: string;
type: 'image';
createdAt: string;
};
export type CanvasConnection = {
id: string;
sourceNodeId: string;
targetNodeId: string;
label?: string;
createdAt: string;
};
export type CanvasLayer = {
id: string;
name: string;
type: 'background' | 'element' | 'icon' | 'text' | 'effect';
visible: boolean;
locked: boolean;
assetUrl?: string;
text?: string;
color?: string;
opacity?: number;
x: number;
y: number;
width: number;
height: number;
};
export type CanvasNode = {
id: string;
type: CanvasNodeType;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
title: string;
color?: string;
prompt?: string;
negativePrompt?: string;
text?: string;
imageUrl?: string;
referenceImage?: string;
outputImages?: string[];
selectedOutput?: string;
status?: 'idle' | 'generating' | 'failed' | 'succeeded';
error?: string;
params?: {
aspectRatio?: string;
resolution?: string;
count?: number;
strength?: number;
model?: string;
modelLabel?: string;
apiType?: 'stream' | 'sync';
};
layers?: CanvasLayer[];
createdAt: string;
updatedAt: string;
};
export type CanvasProjectState = {
nodes: CanvasNode[];
connections: CanvasConnection[];
assets: CanvasAsset[];
viewport: CanvasViewport;
};
export type CanvasProject = {
id: string;
userId: string;
title: string;
state: CanvasProjectState;
createdAt: string;
updatedAt: string;
};
export function createEmptyCanvasState(): CanvasProjectState {
return {
nodes: [],
connections: [],
assets: [],
viewport: { x: 0, y: 0, zoom: 1 },
};
}