Compare commits

...

77 Commits

Author SHA1 Message Date
WuYongJie
e31941a25d fix(ai-render): persist shared workbench data 2026-06-16 19:43:44 +08:00
WuYongJie
dbdf0d9cb5 feat(ai-render): add model-locked render workbench 2026-06-16 12:46:02 +08:00
WuYongJie
d88e6d4648 feat(intake): add barcode print supplier workflow 2026-06-16 11:06:07 +08:00
WuYongJie
778ea00e62 refactor(intake): complete primary domain split 2026-06-10 18:28:41 +08:00
WuYongJie
549c2752ea refactor(intake): split non-integrated workspace containers 2026-06-10 18:00:53 +08:00
WuYongJie
f09f6f42e9 refactor: split intake workspace structure 2026-06-10 16:28:06 +08:00
WuYongJie
17a93e31a7 refactor(intake): extract product patent domain 2026-06-09 15:51:25 +08:00
WuYongJie
742daca700 refactor(intake): extract review issue image manager 2026-06-09 15:20:56 +08:00
WuYongJie
93b9e7d585 refactor(intake): extract review issue image cell 2026-06-09 15:14:39 +08:00
WuYongJie
b13a59b6e4 refactor(intake): extract detection workspace 2026-06-09 15:07:45 +08:00
WuYongJie
527dda4bfa refactor(intake): extract installation workspace 2026-06-09 15:00:23 +08:00
WuYongJie
a2838e5afb refactor(intake): extract first quality workspace 2026-06-09 14:48:54 +08:00
WuYongJie
d3f9ae03ba refactor(intake): extract archive preview panel 2026-06-09 14:39:31 +08:00
WuYongJie
a9036521c7 refactor(intake): extract product limit workspace 2026-06-09 14:32:34 +08:00
WuYongJie
5e3e752b8c refactor(intake): extract product pricing workspace 2026-06-09 14:23:03 +08:00
WuYongJie
280b010550 refactor(intake): extract quality inspection workspace 2026-06-09 14:16:32 +08:00
WuYongJie
0fa3dbf005 refactor(intake): extract planning layout workspace 2026-06-09 14:09:36 +08:00
WuYongJie
0e9fab6cae refactor(intake): extract finished sample shipment workspace 2026-06-09 13:47:43 +08:00
WuYongJie
c8cf3b7bef refactor(intake): extract sample measurement workspace 2026-06-09 13:40:03 +08:00
WuYongJie
2b6ef354fb refactor(intake): extract patent application workspace 2026-06-09 13:33:32 +08:00
WuYongJie
69662a3df6 refactor(intake): extract quality standard workspace 2026-06-09 13:25:22 +08:00
WuYongJie
0ea6d6027a merge: sync intake refactor branch with main 2026-06-09 13:15:17 +08:00
WuYongJie
6f0c55d142 refactor(intake): extract attachment workspace 2026-06-09 12:23:10 +08:00
WuYongJie
49676923d5 refactor(intake): extract opening reference workspace 2026-06-09 12:16:48 +08:00
WuYongJie
e057b4746c refactor(intake): extract product data workspace 2026-06-09 12:04:03 +08:00
WuYongJie
6b16e5e7a3 refactor(intake): extract project review workspace 2026-06-09 11:55:15 +08:00
WuYongJie
fd0ebc6d4d refactor(intake): move basic info field config 2026-06-09 11:47:08 +08:00
WuYongJie
cd93abca0d refactor(intake): extract progress helpers 2026-06-09 11:37:02 +08:00
WuYongJie
a4b2653652 refactor(intake): move url download helper 2026-06-09 11:14:55 +08:00
WuYongJie
fe582bad4a refactor(intake): move blob fetch helper 2026-06-09 11:11:15 +08:00
WuYongJie
d01c5a9510 refactor(intake): move download error helper 2026-06-09 11:06:49 +08:00
WuYongJie
fc38f444e8 refactor(intake): move workspace zip filename helper 2026-06-09 11:02:36 +08:00
WuYongJie
17a4634318 refactor(intake): move download helpers 2026-06-09 10:58:36 +08:00
WuYongJie
36003c529d refactor(intake): move bom category helpers 2026-06-09 10:50:18 +08:00
WuYongJie
9c2311c3af refactor(intake): move product pricing system entry helper 2026-06-09 10:44:43 +08:00
WuYongJie
9ceeb837be refactor(intake): move regex escape helper 2026-06-09 10:39:15 +08:00
WuYongJie
12ed5f553d refactor(intake): extract workflow confirmation helpers 2026-06-09 10:34:05 +08:00
WuYongJie
0a5587ebc6 refactor(intake): extract product patent sync helper 2026-06-09 10:34:05 +08:00
WuYongJie
eb65bba6d8 refactor(intake): move image upload helpers 2026-06-09 10:34:04 +08:00
WuYongJie
c4989d9cf8 refactor(intake): move opening reference form helpers 2026-06-09 10:34:04 +08:00
WuYongJie
d268a14471 refactor(intake): extract product factory helpers 2026-06-09 10:34:04 +08:00
WuYongJie
613c5aa77d refactor(intake): extract bom value helpers 2026-06-09 10:34:04 +08:00
WuYongJie
d978ca4358 refactor(intake): extract bom detail key helpers 2026-06-09 10:34:04 +08:00
WuYongJie
751907d4fa refactor(intake): extract upload metadata helpers 2026-06-09 10:34:04 +08:00
WuYongJie
2d0349d7e5 refactor(intake): extract product form value helpers 2026-06-09 10:34:04 +08:00
WuYongJie
c9ef57cd96 refactor(intake): extract product workflow row helpers 2026-06-09 10:34:04 +08:00
WuYongJie
42ceb252f2 refactor(intake): move empty sku item helper 2026-06-09 10:34:04 +08:00
WuYongJie
2443811b52 refactor(intake): move empty style detail state 2026-06-09 10:34:03 +08:00
WuYongJie
1522259c9a refactor(intake): reuse sku preview node helper 2026-06-09 10:34:03 +08:00
WuYongJie
acddbf79de refactor(intake): extract sku preview node helpers 2026-06-09 10:34:03 +08:00
WuYongJie
15942404be refactor(intake): move document workspace initialization 2026-06-09 10:34:03 +08:00
WuYongJie
669d522cfa refactor(intake): extract product factory key helpers 2026-06-09 10:34:03 +08:00
WuYongJie
5bb042e16f refactor(intake): move sample shipment review helper 2026-06-09 10:34:03 +08:00
WuYongJie
cb591ca6c8 refactor(intake): move review content helpers 2026-06-09 10:34:03 +08:00
WuYongJie
e80e7f5286 refactor(intake): extract sku import batch helpers 2026-06-09 10:34:03 +08:00
WuYongJie
9f1ef015f6 refactor(intake): extract sku source helpers 2026-06-09 10:34:02 +08:00
WuYongJie
2735b6910c refactor(intake): move sku row clone helper 2026-06-09 10:34:02 +08:00
WuYongJie
83ecca86d0 refactor(intake): move detection workspace helpers 2026-06-09 10:34:02 +08:00
WuYongJie
c9c5989a86 refactor(intake): move project review owner helper 2026-06-09 10:34:02 +08:00
WuYongJie
0f58eeec03 refactor(intake): extract upload storage helper 2026-06-09 10:34:02 +08:00
WuYongJie
28a2767f0f refactor(intake): move document revision file helper 2026-06-09 10:34:02 +08:00
WuYongJie
6b31adbbdd refactor(intake): extract sku brand prefix helper 2026-06-09 10:34:02 +08:00
WuYongJie
7bcae029f7 refactor(intake): extract local asset detection helper 2026-06-09 10:34:02 +08:00
WuYongJie
3c3d551504 refactor(intake): extract first quality helpers 2026-06-09 10:34:02 +08:00
WuYongJie
e6dba9a855 refactor(intake): extract sample workflow helpers 2026-06-09 10:34:01 +08:00
WuYongJie
9ec8d2f978 refactor(intake): extract upload category helper 2026-06-09 10:34:01 +08:00
WuYongJie
02d90417fc refactor(intake): extract preview hole helpers 2026-06-09 10:34:01 +08:00
WuYongJie
bcec80f8f4 refactor(intake): extract component sku helpers 2026-06-09 10:34:01 +08:00
WuYongJie
5bf670679b refactor(intake): extract series component detail helpers 2026-06-09 10:34:01 +08:00
WuYongJie
0328966812 refactor(intake): extract asset sku content helpers 2026-06-09 10:34:01 +08:00
WuYongJie
eebf1aaf7a refactor(intake): move non-integrated sku normalization helpers 2026-06-09 10:34:01 +08:00
WuYongJie
9e665aa1bd refactor(intake): move non-integrated style detail initialization 2026-06-09 10:34:01 +08:00
WuYongJie
ce30eca2bb refactor(intake): extract non-integrated style detail state 2026-06-09 10:34:00 +08:00
WuYongJie
68d16eaae3 refactor(intake): extract non-integrated basic info config 2026-06-09 10:34:00 +08:00
WuYongJie
8a34ad3c00 refactor(intake): extract non-integrated quality inspection helpers 2026-06-09 10:34:00 +08:00
WuYongJie
1a5f9238f3 refactor(intake): extract non-integrated review sheet helpers 2026-06-09 10:34:00 +08:00
WuYongJie
a9aef3f018 refactor(intake): move packaging workspace helpers 2026-06-09 10:34:00 +08:00
422 changed files with 73529 additions and 49380 deletions

View File

@ -56,6 +56,14 @@ const connectSources = [
...(!isProduction ? ['ws:', 'http://localhost:*', 'http://127.0.0.1:*'] : []),
]
const lodopScriptSources = [
'http://127.0.0.1:8000',
'http://127.0.0.1:18000',
'http://localhost:8000',
'http://localhost:18000',
'https://localhost.lodop.net:8443',
]
const contentSecurityPolicy = [
"default-src 'self'",
"base-uri 'self'",
@ -65,7 +73,7 @@ const contentSecurityPolicy = [
"img-src 'self' data: blob: http: https:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
`script-src 'self' 'unsafe-inline'${isProduction ? '' : " 'unsafe-eval'"}`,
`script-src 'self' 'unsafe-inline' ${lodopScriptSources.join(' ')}${isProduction ? '' : " 'unsafe-eval'"}`,
`connect-src ${connectSources.join(' ')}`,
"worker-src 'self' blob:",
"media-src 'self' blob:",

View File

@ -20,6 +20,7 @@
"@tanstack/react-query": "^5.90.20",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/qrcode": "^1.5.6",
"autoprefixer": "^10.4.22",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
@ -29,6 +30,7 @@
"exceljs": "^4.4.0",
"framer-motion": "^12.29.2",
"gsap": "^3.12.5",
"html2canvas": "^1.4.1",
"ioredis": "^5.9.2",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
@ -41,9 +43,11 @@
"pg": "^8.17.2",
"pinyin-pro": "^3.28.1",
"postcss": "^8.5.6",
"print-js": "^1.6.0",
"puppeteer": "^24.40.0",
"qiniu": "^7.15.2",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.54.2",
@ -6242,6 +6246,15 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
@ -7785,6 +7798,15 @@
"bare-path": "^3.0.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
@ -8186,7 +8208,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -8751,6 +8772,15 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz",
@ -8896,6 +8926,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
@ -9154,6 +9193,12 @@
"node": ">= 8.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/docker-modem": {
"version": "5.0.6",
"resolved": "https://registry.npmmirror.com/docker-modem/-/docker-modem-5.0.6.tgz",
@ -11276,6 +11321,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@ -14926,7 +14984,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -15024,7 +15081,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -15540,6 +15596,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/print-js": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/print-js/-/print-js-1.6.0.tgz",
"integrity": "sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==",
"license": "MIT"
},
"node_modules/prisma": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.3.0.tgz",
@ -15826,6 +15888,182 @@
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@ -16119,6 +16357,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
@ -16395,6 +16639,12 @@
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
@ -17468,6 +17718,15 @@
"b4a": "^1.6.4"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
@ -18247,6 +18506,15 @@
"node": ">= 4"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
@ -18459,6 +18727,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.19.tgz",

View File

@ -90,6 +90,7 @@
"@tanstack/react-query": "^5.90.20",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/qrcode": "^1.5.6",
"autoprefixer": "^10.4.22",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
@ -99,6 +100,7 @@
"exceljs": "^4.4.0",
"framer-motion": "^12.29.2",
"gsap": "^3.12.5",
"html2canvas": "^1.4.1",
"ioredis": "^5.9.2",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
@ -111,9 +113,11 @@
"pg": "^8.17.2",
"pinyin-pro": "^3.28.1",
"postcss": "^8.5.6",
"print-js": "^1.6.0",
"puppeteer": "^24.40.0",
"qiniu": "^7.15.2",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.54.2",

View File

@ -0,0 +1,5 @@
ALTER TABLE "User"
ADD COLUMN "supplierCode" TEXT,
ADD COLUMN "supplierName" TEXT;
CREATE INDEX "User_supplierCode_idx" ON "User"("supplierCode");

View File

@ -0,0 +1,67 @@
CREATE TABLE "AiRenderWorkbenchSpu" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiRenderWorkbenchSpu_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AiRenderWorkbenchSku" (
"id" TEXT NOT NULL,
"spuId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"modelUrl" TEXT NOT NULL DEFAULT '',
"thumbnailUrl" TEXT,
"note" TEXT,
"materialColor" TEXT NOT NULL DEFAULT '#d8c6ad',
"materialTextureUrl" TEXT,
"nodeMaterials" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiRenderWorkbenchSku_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AssistedAssemblyAiRender"
ADD COLUMN "workbenchSpuId" TEXT,
ADD COLUMN "workbenchSkuId" TEXT;
CREATE INDEX "AiRenderWorkbenchSpu_createdBy_idx" ON "AiRenderWorkbenchSpu"("createdBy");
CREATE INDEX "AiRenderWorkbenchSpu_sortOrder_createdAt_idx" ON "AiRenderWorkbenchSpu"("sortOrder", "createdAt");
CREATE INDEX "AiRenderWorkbenchSku_spuId_sortOrder_createdAt_idx" ON "AiRenderWorkbenchSku"("spuId", "sortOrder", "createdAt");
CREATE INDEX "AiRenderWorkbenchSku_createdBy_idx" ON "AiRenderWorkbenchSku"("createdBy");
CREATE INDEX "AssistedAssemblyAiRender_workbenchSpuId_createdAt_idx" ON "AssistedAssemblyAiRender"("workbenchSpuId", "createdAt");
CREATE INDEX "AssistedAssemblyAiRender_workbenchSkuId_createdAt_idx" ON "AssistedAssemblyAiRender"("workbenchSkuId", "createdAt");
ALTER TABLE "AiRenderWorkbenchSpu"
ADD CONSTRAINT "AiRenderWorkbenchSpu_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AiRenderWorkbenchSku"
ADD CONSTRAINT "AiRenderWorkbenchSku_spuId_fkey"
FOREIGN KEY ("spuId") REFERENCES "AiRenderWorkbenchSpu"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AiRenderWorkbenchSku"
ADD CONSTRAINT "AiRenderWorkbenchSku_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_workbenchSpuId_fkey"
FOREIGN KEY ("workbenchSpuId") REFERENCES "AiRenderWorkbenchSpu"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_workbenchSkuId_fkey"
FOREIGN KEY ("workbenchSkuId") REFERENCES "AiRenderWorkbenchSku"("id")
ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1175,6 +1175,11 @@ model AssistedAssemblyAiRender {
createdBy String
creator User @relation("AssistedAssemblyAiRenderCreator", fields: [createdBy], references: [id], onDelete: Cascade)
workbenchSpuId String?
workbenchSpu AiRenderWorkbenchSpu? @relation(fields: [workbenchSpuId], references: [id], onDelete: SetNull)
workbenchSkuId String?
workbenchSku AiRenderWorkbenchSku? @relation(fields: [workbenchSkuId], references: [id], onDelete: SetNull)
prompt String @db.Text
negativePrompt String? @db.Text
params Json
@ -1195,9 +1200,60 @@ model AssistedAssemblyAiRender {
updatedAt DateTime @updatedAt
@@index([createdBy, createdAt])
@@index([workbenchSpuId, createdAt])
@@index([workbenchSkuId, createdAt])
@@index([status, createdAt])
}
model AiRenderWorkbenchSpu {
id String @id @default(cuid())
name String
description String @default("") @db.Text
sortOrder Int @default(0)
createdBy String
creator User @relation("AiRenderWorkbenchSpuCreator", fields: [createdBy], references: [id], onDelete: Cascade)
updatedBy String?
skus AiRenderWorkbenchSku[]
renders AssistedAssemblyAiRender[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdBy])
@@index([sortOrder, createdAt])
}
model AiRenderWorkbenchSku {
id String @id @default(cuid())
spuId String
spu AiRenderWorkbenchSpu @relation(fields: [spuId], references: [id], onDelete: Cascade)
name String
modelUrl String @default("") @db.Text
thumbnailUrl String? @db.Text
note String? @db.Text
materialColor String @default("#d8c6ad")
materialTextureUrl String? @db.Text
nodeMaterials Json?
sortOrder Int @default(0)
createdBy String
creator User @relation("AiRenderWorkbenchSkuCreator", fields: [createdBy], references: [id], onDelete: Cascade)
updatedBy String?
renders AssistedAssemblyAiRender[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([spuId, sortOrder, createdAt])
@@index([createdBy])
}
model PreSku {
id String @id @default(cuid())
draftGroupId String
@ -1543,8 +1599,10 @@ model User {
isFirstLogin Boolean @default(true) // 超级管理员首次登录需强制修改信息
// 其他信息
avatar String?
phone String?
avatar String?
phone String?
supplierCode String?
supplierName String?
// 角色与权限
role Role @default(USER)
@ -1585,6 +1643,8 @@ model User {
deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter")
createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator")
createdDraftGroups DraftGroup[] @relation("DraftGroupCreator")
createdAiRenderWorkbenchSpus AiRenderWorkbenchSpu[] @relation("AiRenderWorkbenchSpuCreator")
createdAiRenderWorkbenchSkus AiRenderWorkbenchSku[] @relation("AiRenderWorkbenchSkuCreator")
assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator")
collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser")
addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy")
@ -1607,6 +1667,7 @@ model User {
@@index([status])
@@index([loginSource])
@@index([employeeDirectoryId])
@@index([supplierCode])
}
model EmployeeLookupCache {

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,931 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AlertCircle,
Box,
ChevronDown,
ChevronRight,
Cuboid,
Download,
Image as ImageIcon,
ImagePlus,
Loader2,
Plus,
RotateCcw,
Search,
Sparkles,
Trash2,
UploadCloud,
Wand2,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import type { AiRenderSku, AiRenderSpu, AiRenderTaskState, LocalRenderRecord } from './types'
interface RenderInspectorProps {
spus: AiRenderSpu[]
selectedSpuId: string | null
selectedSkuId: string | null
uploadingSkuId: string | null
spu: AiRenderSpu | null
sku: AiRenderSku | null
prompt: string
negativePrompt: string
aspectRatio: string
imageCount: number
generating: boolean
renderTask: AiRenderTaskState | null
optimizing: boolean
optimExplanation: string
message: string | null
loading: boolean
canWriteConfig: boolean
canGenerate: boolean
canDeleteImages: boolean
records: LocalRenderRecord[]
onSelectSpu: (spuId: string) => void
onCreateSpu: () => void
onRenameSpu: (spuId: string, name: string) => void
onDeleteSpu: (spuId: string) => void
onSelectSku: (skuId: string) => void
onCreateSku: () => void
onUpdateSkuById: (skuId: string, patch: Partial<AiRenderSku>) => void
onDeleteSku: (skuId: string) => void
onUploadSkuModel: (skuId: string, file: File) => void
onPromptChange: (value: string) => void
onNegativePromptChange: (value: string) => void
onAspectRatioChange: (value: string) => void
onImageCountChange: (value: number) => void
onCaptureWhiteBackground: () => void
onOptimizePrompt: () => void
onGenerateScene: () => void
onDeleteRecord: (recordId: string) => void
onRefreshRecords: () => void
}
type InspectorTab = 'product' | 'ai'
interface AiTagOption {
label: string
promptKeyword: string
previewImage: string
}
interface AiTagGroup {
group: string
options: AiTagOption[]
}
const AI_OPTIM_TAG_GROUPS: AiTagGroup[] = [
{
group: '场景',
options: [
{ label: '现代简约', promptKeyword: 'modern minimalist interior', previewImage: '/tag-previews/scene-modern-minimalist.png' },
{ label: '北欧风', promptKeyword: 'Scandinavian style interior', previewImage: '/tag-previews/scene-scandinavian.png' },
{ label: '日式侘寂', promptKeyword: 'Japanese wabi-sabi interior', previewImage: '/tag-previews/scene-wabi-sabi.png' },
{ label: '新中式', promptKeyword: 'contemporary Chinese interior', previewImage: '/tag-previews/scene-chinese.png' },
{ label: '工业风', promptKeyword: 'industrial loft style', previewImage: '/tag-previews/scene-industrial.png' },
{ label: '轻奢', promptKeyword: 'light luxury interior', previewImage: '/tag-previews/scene-luxury.png' },
{ label: '法式', promptKeyword: 'French Parisian style', previewImage: '/tag-previews/scene-french.png' },
{ label: '美式', promptKeyword: 'American classic style', previewImage: '/tag-previews/scene-american.png' },
],
},
{
group: '时间段',
options: [
{ label: '清晨柔光', promptKeyword: 'soft early morning light', previewImage: '/tag-previews/time-morning.png' },
{ label: '正午明亮', promptKeyword: 'bright midday daylight', previewImage: '/tag-previews/time-noon.png' },
{ label: '黄昏暖光', promptKeyword: 'golden hour warm glow', previewImage: '/tag-previews/time-golden.png' },
{ label: '夜晚氛围', promptKeyword: 'evening ambient lighting', previewImage: '/tag-previews/time-evening.png' },
{ label: '阴天漫射', promptKeyword: 'overcast diffused light', previewImage: '/tag-previews/time-overcast.png' },
],
},
{
group: '风格',
options: [
{ label: '真实摄影', promptKeyword: 'photorealistic photography', previewImage: '/tag-previews/style-photo.png' },
{ label: '电影质感', promptKeyword: 'cinematic film still quality', previewImage: '/tag-previews/style-cinema.png' },
{ label: '产品目录', promptKeyword: 'high-end catalog photography', previewImage: '/tag-previews/style-catalog.png' },
{ label: '编辑级', promptKeyword: 'editorial interior photography', previewImage: '/tag-previews/style-editorial.png' },
{ label: '建筑可视化', promptKeyword: 'architectural visualization render', previewImage: '/tag-previews/style-archviz.png' },
],
},
{
group: '艺术化',
options: [
{ label: '写实', promptKeyword: 'hyperrealistic rendering', previewImage: '/tag-previews/artistic-realistic.png' },
{ label: '略带艺术感', promptKeyword: 'subtle artistic treatment', previewImage: '/tag-previews/artistic-subtle.png' },
{ label: '印象派色调', promptKeyword: 'impressionist color palette', previewImage: '/tag-previews/artistic-impressionist.png' },
{ label: '极简主义', promptKeyword: 'minimalist aesthetic', previewImage: '/tag-previews/artistic-minimal.png' },
{ label: '戏剧光影', promptKeyword: 'dramatic chiaroscuro lighting', previewImage: '/tag-previews/artistic-dramatic.png' },
],
},
{
group: '画面感',
options: [
{ label: '高对比', promptKeyword: 'high contrast composition', previewImage: '/tag-previews/visual-highcontrast.png' },
{ label: '低饱和', promptKeyword: 'desaturated muted tones', previewImage: '/tag-previews/visual-desaturated.png' },
{ label: '暖色调', promptKeyword: 'warm color temperature', previewImage: '/tag-previews/visual-warm.png' },
{ label: '冷色调', promptKeyword: 'cool color temperature', previewImage: '/tag-previews/visual-cool.png' },
{ label: '柔焦朦胧', promptKeyword: 'soft focus dreamy atmosphere', previewImage: '/tag-previews/visual-softfocus.png' },
],
},
]
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function formatRecordTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function getRenderTaskModeLabel(mode: LocalRenderRecord['mode']) {
return mode === 'white-background' ? '白底图' : '场景效果图'
}
export function RenderInspector({
spus,
selectedSpuId,
selectedSkuId,
uploadingSkuId,
spu,
sku,
prompt,
negativePrompt,
aspectRatio,
imageCount,
generating,
renderTask,
optimizing,
optimExplanation,
message,
loading,
canWriteConfig,
canGenerate,
canDeleteImages,
records,
onSelectSpu,
onCreateSpu,
onRenameSpu,
onDeleteSpu,
onSelectSku,
onCreateSku,
onUpdateSkuById,
onDeleteSku,
onUploadSkuModel,
onPromptChange,
onNegativePromptChange,
onAspectRatioChange,
onImageCountChange,
onCaptureWhiteBackground,
onOptimizePrompt,
onGenerateScene,
onDeleteRecord,
onRefreshRecords,
}: RenderInspectorProps) {
const [activeTab, setActiveTab] = useState<InspectorTab>('product')
const [promptCollapsed, setPromptCollapsed] = useState(false)
const [negativeCollapsed, setNegativeCollapsed] = useState(true)
const [optimExplanationCollapsed, setOptimExplanationCollapsed] = useState(false)
const [selectedTags, setSelectedTags] = useState<Record<string, AiTagOption>>({})
const [hoveredTagGroup, setHoveredTagGroup] = useState<string | null>(null)
const [tagPopupPos, setTagPopupPos] = useState<{ top: number; left: number } | null>(null)
const tagBtnRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const tagCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null)
const [productQuery, setProductQuery] = useState('')
const normalizedProductQuery = productQuery.trim().toLowerCase()
const filteredSpus = useMemo(() => {
if (!normalizedProductQuery) {
return spus.map((spuItem) => ({ spu: spuItem, skus: spuItem.skus }))
}
return spus
.map((spuItem) => {
const spuMatched = spuItem.name.toLowerCase().includes(normalizedProductQuery)
const skus = spuMatched
? spuItem.skus
: spuItem.skus.filter((skuItem) => skuItem.name.toLowerCase().includes(normalizedProductQuery))
return { spu: spuItem, skus, matched: spuMatched || skus.length > 0 }
})
.filter((item) => item.matched)
}, [normalizedProductQuery, spus])
const filteredSkuCount = filteredSpus.reduce((total, item) => total + item.skus.length, 0)
useEffect(() => {
return () => {
if (tagCloseTimer.current) {
clearTimeout(tagCloseTimer.current)
}
}
}, [])
function handleTagToggle(group: string, option: AiTagOption) {
setSelectedTags((current) => {
const previous = current[group]
const isDeselecting = previous?.label === option.label
const removeKeyword = (value: string, keyword: string) => {
const regex = new RegExp(`(?:,\s*|\s+)${escapeRegExp(keyword)}(?=,|$)`, 'i')
return value
.replace(regex, '')
.replace(/,\s*,/g, ',')
.replace(/^,\s*/, '')
.replace(/\s*,\s*$/, '')
.trim()
}
let nextPrompt = prompt
if (previous) {
nextPrompt = removeKeyword(nextPrompt, previous.promptKeyword)
}
if (isDeselecting) {
const { [group]: _removed, ...rest } = current
onPromptChange(nextPrompt)
return rest
}
const normalizedPrompt = nextPrompt.trim()
onPromptChange(
normalizedPrompt
? normalizedPrompt.endsWith(',')
? `${normalizedPrompt} ${option.promptKeyword}`
: `${normalizedPrompt}, ${option.promptKeyword}`
: option.promptKeyword,
)
return { ...current, [group]: option }
})
}
return (
<aside className="flex h-full w-full shrink-0 flex-col bg-white shadow-[-10px_0_40px_rgba(0,0,0,0.03)] lg:w-[480px]">
<div className="min-h-0 flex-1 overflow-y-auto border-b border-black/[0.03]">
<div className="space-y-5 border-b border-black/[0.03] p-5">
<div className="grid grid-cols-2 gap-2 rounded-[8px] bg-black/[0.025] p-1 ring-1 ring-black/[0.03]">
{[
{ key: 'product' as const, label: '产品信息' },
{ key: 'ai' as const, label: 'AI 生图' },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
'h-8 rounded-[6px] text-[11px] font-black transition',
activeTab === tab.key
? 'bg-white text-[#2d3335] shadow-sm ring-1 ring-black/[0.04]'
: 'text-[#5a6062]/55 hover:bg-white/70 hover:text-[#2d3335]',
)}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'product' ? (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex h-9 items-center gap-2 rounded-[6px] bg-white px-2.5 ring-1 ring-black/[0.06] transition focus-within:ring-[#47646b]/18">
<Search className="h-3.5 w-3.5 shrink-0 text-[#5a6062]/45" />
<input
value={productQuery}
onChange={(event) => setProductQuery(event.target.value)}
placeholder="查询 SPU / SKU"
className="h-full min-w-0 flex-1 bg-transparent text-[11px] font-bold text-[#2d3335] outline-none placeholder:text-[#5a6062]/35"
aria-label="查询 SPU 或 SKU"
/>
{productQuery ? (
<button
type="button"
onClick={() => setProductQuery('')}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] text-[#5a6062]/45 transition hover:bg-black/[0.04] hover:text-[#2d3335]"
aria-label="清空查询"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{normalizedProductQuery ? (
<p className="px-1 text-[10px] font-bold text-[#5a6062]/50">
{filteredSpus.length} 个 SPU / {filteredSkuCount} 个 SKU
</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
{canWriteConfig ? (
<>
<button
type="button"
onClick={() => {
setProductQuery('')
onCreateSpu()
}}
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[11px] font-black text-white transition hover:opacity-90"
aria-label="新建 SPU"
>
<Plus className="h-3.5 w-3.5" />
新建 SPU
</button>
<button
type="button"
onClick={() => {
setProductQuery('')
onCreateSku()
}}
disabled={!spu}
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-white text-[11px] font-black text-[#2d3335] ring-1 ring-black/[0.06] transition hover:bg-black/[0.03] disabled:opacity-40"
aria-label="新增 SKU"
>
<Plus className="h-3.5 w-3.5" />
新增 SKU
</button>
</>
) : (
<div className="col-span-2 rounded-[6px] bg-black/[0.03] px-3 py-2 text-[10px] font-bold text-[#5a6062]/50">
当前账号可查看和生成,但不能修改产品配置
</div>
)}
</div>
<div className="space-y-2">
{filteredSpus.length === 0 ? (
<div className="rounded-[7px] bg-white px-3 py-5 text-center text-[11px] font-bold text-[#5a6062]/45 ring-1 ring-black/[0.04]">
{loading ? '正在加载共享工作台...' : '没有匹配的 SPU / SKU'}
</div>
) : filteredSpus.map(({ spu: spuItem, skus: visibleSkus }) => {
const isSpuSelected = spuItem.id === selectedSpuId
const showSkuList = isSpuSelected || Boolean(normalizedProductQuery)
return (
<article
key={spuItem.id}
className={cn(
'overflow-hidden rounded-[7px] bg-white ring-1 transition',
isSpuSelected ? 'ring-[#47646b]/18' : 'ring-black/[0.04] hover:ring-black/[0.08]',
)}
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => onSelectSpu(spuItem.id)}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-[5px] text-[#5a6062]/45 transition hover:bg-black/[0.035] hover:text-[#2d3335]"
aria-label={`选择 SPU ${spuItem.name || '未命名 SPU'}`}
>
<ChevronRight
className={cn('h-3.5 w-3.5 transition-transform', isSpuSelected && 'rotate-90')}
/>
</button>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-black/[0.035] text-[#5a6062]/65">
<Box className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<input
value={spuItem.name}
onFocus={() => onSelectSpu(spuItem.id)}
onChange={(event) => onRenameSpu(spuItem.id, event.target.value)}
readOnly={!canWriteConfig}
placeholder="填写 SPU 名称"
className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35 read-only:cursor-default"
aria-label="SPU 名称"
/>
<p className="truncate text-[9px] font-bold text-[#5a6062]/45">
{normalizedProductQuery
? `${visibleSkus.length}/${spuItem.skus.length} 个 SKU`
: `${spuItem.skus.length} 个 SKU`}
</p>
</div>
{canWriteConfig ? (
<button
type="button"
onClick={() => onDeleteSpu(spuItem.id)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[5px] text-[#9f4f4f]/65 transition hover:bg-[#b75b5b]/8 hover:text-[#9f4f4f]"
aria-label="删除 SPU"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{showSkuList ? (
<div className="space-y-2 border-t border-black/[0.04] px-3 pb-3 pt-2">
<div className="space-y-1.5">
{visibleSkus.map((skuItem) => {
const isSkuSelected = skuItem.id === selectedSkuId
return (
<div
key={skuItem.id}
className={cn(
'rounded-[6px] bg-black/[0.018] px-2 py-2 ring-1 transition',
isSkuSelected
? 'ring-[#47646b]/18'
: 'ring-transparent hover:bg-black/[0.026]',
)}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onSelectSku(skuItem.id)}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-[5px] text-[#5a6062]/45 transition hover:bg-white hover:text-[#2d3335]"
aria-label={`选择 SKU ${skuItem.name || '未命名 SKU'}`}
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 transition-transform',
isSkuSelected && 'rotate-90',
)}
/>
</button>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] text-white shadow-sm"
style={{ backgroundColor: skuItem.materialColor || '#8b9092' }}
>
<Cuboid className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<input
value={skuItem.name}
onFocus={() => onSelectSku(skuItem.id)}
onChange={(event) => onUpdateSkuById(skuItem.id, { name: event.target.value })}
readOnly={!canWriteConfig}
placeholder="填写 SKU 名称"
className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35 read-only:cursor-default"
aria-label="SKU 名称"
/>
<p className="truncate text-[9px] font-bold text-[#5a6062]/45">
{skuItem.modelUrl ? '已配置 SKU 模型' : '还没有 SKU 模型'}
</p>
</div>
{canWriteConfig ? (
<button
type="button"
onClick={() => onDeleteSku(skuItem.id)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[5px] text-[#9f4f4f]/65 transition hover:bg-[#b75b5b]/8 hover:text-[#9f4f4f]"
aria-label="删除 SKU"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{isSkuSelected && canWriteConfig ? (
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 pl-8">
<input
value={skuItem.modelUrl}
onChange={(event) => onUpdateSkuById(skuItem.id, { modelUrl: event.target.value })}
placeholder="粘贴 SKU 模型 URL,或上传 ZIP / 模型"
className="h-8 min-w-0 rounded-[5px] bg-white/70 px-2 text-[11px] font-medium text-[#5a6062] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/18"
aria-label="SKU 模型 URL"
/>
<label className="flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[5px] bg-[#2d3335] px-2.5 text-[10px] font-bold text-white transition hover:opacity-90">
<UploadCloud className="h-3.5 w-3.5" />
{uploadingSkuId === skuItem.id ? '上传中' : '上传 ZIP / 模型'}
<input
type="file"
accept=".zip,.glb,.gltf,.obj,application/zip,model/gltf-binary,model/gltf+json,model/obj"
className="hidden"
disabled={uploadingSkuId === skuItem.id}
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) {
onUploadSkuModel(skuItem.id, file)
}
}}
/>
</label>
</div>
) : null}
</div>
)
})}
</div>
</div>
) : null}
</article>
)
})}
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="space-y-2 rounded-[8px] bg-black/[0.025] p-3 ring-1 ring-black/[0.03]">
{[
{ label: 'SPU', value: spu?.name || '未选择' },
{ label: '当前 SKU', value: sku?.name || '未选择' },
{ label: 'SKU 模型', value: sku?.modelUrl ? '已配置' : '未配置' },
].map((item) => (
<div key={item.label} className="flex items-center justify-between gap-3 text-[11px]">
<span className="font-bold text-[#5a6062]/50">{item.label}</span>
<span className="truncate font-apple-medium text-[#2d3335]">{item.value}</span>
</div>
))}
</div>
{optimExplanation ? (
<div className="rounded-[6px] bg-[#47646b]/5 ring-1 ring-[#47646b]/8">
<button
type="button"
onClick={() => setOptimExplanationCollapsed((value) => !value)}
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-bold text-[#47646b]"
>
<span>优化说明</span>
<ChevronDown className={cn('h-3 w-3 transition-transform', optimExplanationCollapsed && '-rotate-90')} />
</button>
{!optimExplanationCollapsed ? (
<div className="relative px-2.5 pb-2">
<div className="max-h-[100px] overflow-y-auto text-[10px] leading-[16px] text-[#2d3335]/70">
{optimExplanation}
{optimizing ? <span className="ml-0.5 inline-block h-3 w-[2px] animate-pulse bg-[#47646b]" /> : null}
</div>
</div>
) : null}
</div>
) : null}
<div className="rounded-[6px] bg-white/50 ring-1 ring-black/[0.04]">
<button
type="button"
onClick={() => setPromptCollapsed((value) => !value)}
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-bold text-[#5a6062]/70"
>
<span>生图提示词</span>
<ChevronDown className={cn('h-3 w-3 transition-transform', promptCollapsed && '-rotate-90')} />
</button>
{!promptCollapsed ? (
<div className="px-2.5 pb-2">
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="描述你想要的效果,如:保留产品结构,生成真实白底产品摄影或真实室内场景,材质自然,灯光柔和"
className="min-h-[60px] w-full resize-none rounded-[4px] bg-white/70 px-2.5 py-1.5 text-[11px] font-medium leading-[18px] text-[#2d3335] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-50"
/>
</div>
) : null}
</div>
{/* Options row:张数 + 比例 + 标签按钮 + 刷新 */}
<div className="flex items-center gap-1.5">
<select
value={imageCount}
onChange={(event) => onImageCountChange(Number(event.target.value))}
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
>
{[1, 2, 3, 4].map((c) => <option key={c} value={c}>{c} 张</option>)}
</select>
<select
value={aspectRatio}
onChange={(event) => onAspectRatioChange(event.target.value)}
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
>
{['16:9', '4:3', '1:1', '3:4'].map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<div className="relative mx-0.5 h-4 w-px bg-black/[0.06]" />
{AI_OPTIM_TAG_GROUPS.map((tg) => {
const selectedOpt = selectedTags[tg.group]
const isHovered = hoveredTagGroup === tg.group
return (
<div
key={tg.group}
ref={(el) => { if (el) tagBtnRefs.current.set(tg.group, el); else tagBtnRefs.current.delete(tg.group) }}
onMouseEnter={(e) => {
if (tagCloseTimer.current) { clearTimeout(tagCloseTimer.current); tagCloseTimer.current = null }
setHoveredTagGroup(tg.group)
const rect = e.currentTarget.getBoundingClientRect()
setTagPopupPos({ top: rect.bottom + 4, left: rect.left })
}}
onMouseLeave={() => {
tagCloseTimer.current = setTimeout(() => { setHoveredTagGroup(null); setTagPopupPos(null) }, 150)
}}
>
<button
type="button"
disabled={optimizing}
className={cn(
'flex h-7 shrink-0 items-center gap-0.5 rounded-[5px] px-1.5 text-[9px] font-bold transition ring-1',
selectedOpt
? 'bg-[#47646b]/12 text-[#47646b] ring-[#47646b]/25'
: 'bg-white/60 text-[#5a6062]/50 ring-black/[0.04] hover:bg-white hover:text-[#2d3335] hover:ring-black/[0.08]',
)}
>
<span className="max-w-[36px] truncate">{selectedOpt ? selectedOpt.label : tg.group}</span>
<ChevronDown className={cn('h-2.5 w-2.5 shrink-0 transition-transform', isHovered && 'rotate-180')} />
</button>
</div>
)
})}
<div className="flex-1" />
<button
type="button"
onClick={onRefreshRecords}
className="flex h-7 shrink-0 items-center rounded-[5px] bg-white/70 px-1.5 text-[#5a6062]/55 ring-1 ring-black/[0.04] transition hover:text-[#2d3335]"
>
<RotateCcw className="h-3 w-3" />
</button>
</div>
{/* 标签弹出面板 — fixed 定位,不受父容器裁切 */}
{hoveredTagGroup && tagPopupPos && (() => {
const tg = AI_OPTIM_TAG_GROUPS.find((g) => g.group === hoveredTagGroup)
if (!tg) return null
const selectedOpt = selectedTags[tg.group]
// 弹框宽度 480,clamp 到屏幕内
const popupW = 560
const clampedLeft = Math.max(8, Math.min(tagPopupPos.left, window.innerWidth - popupW - 8))
return (
<div
className="fixed z-[9999] w-[560px] animate-[tagPopup_0.18s_ease-out] rounded-[10px] bg-white p-3 shadow-xl ring-1 ring-black/[0.08]"
style={{ top: tagPopupPos.top, left: clampedLeft }}
onMouseEnter={() => {
if (tagCloseTimer.current) { clearTimeout(tagCloseTimer.current); tagCloseTimer.current = null }
}}
onMouseLeave={() => {
tagCloseTimer.current = setTimeout(() => { setHoveredTagGroup(null); setTagPopupPos(null) }, 150)
}}
>
<div className="grid grid-cols-3 gap-2.5">
{tg.options.map((opt) => {
const selected = selectedOpt?.label === opt.label
return (
<button
key={opt.label}
type="button"
onClick={() => {
handleTagToggle(tg.group, opt)
setHoveredTagGroup(null)
setTagPopupPos(null)
}}
className={cn(
'group relative overflow-hidden rounded-[6px] transition ring-1',
selected
? 'ring-[#47646b]/40'
: 'ring-black/[0.04] hover:ring-black/[0.12]',
)}
title={opt.promptKeyword}
>
<div className="aspect-square w-full bg-[#f0f2f3]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={opt.previewImage} alt={opt.label} className="h-full w-full object-cover" loading="lazy" />
</div>
<span className={cn(
'block truncate px-1 py-0.5 text-center text-[10px] font-bold',
selected ? 'bg-[#47646b]/10 text-[#47646b]' : 'text-[#5a6062]/60',
)}>
{opt.label}
</span>
{selected && (
<div className="absolute right-0.5 top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#47646b] text-white">
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
)}
</button>
)
})}
</div>
</div>
)
})()}
<div className="rounded-[6px] bg-white/50 ring-1 ring-black/[0.04]">
<button
type="button"
onClick={() => setNegativeCollapsed((value) => !value)}
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-bold text-[#5a6062]/70"
>
<span>反向提示词</span>
<ChevronDown className={cn('h-3 w-3 transition-transform', negativeCollapsed && '-rotate-90')} />
</button>
{!negativeCollapsed ? (
<div className="px-2.5 pb-2">
<textarea
value={negativePrompt}
onChange={(event) => onNegativePromptChange(event.target.value)}
rows={2}
placeholder="不想出现的内容,可选"
className="w-full resize-none rounded-[4px] bg-white/70 px-2.5 py-1.5 text-[11px] font-medium leading-[18px] text-[#5a6062] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-50"
/>
</div>
) : null}
</div>
{message ? (
<div className="flex gap-1.5 rounded-[6px] bg-[#47646b]/6 px-2.5 py-1.5 text-[10px] font-medium text-[#47646b]">
<AlertCircle className="mt-px h-3 w-3 shrink-0" />
<span className="line-clamp-2">{message}</span>
</div>
) : null}
{renderTask ? (
<div
className={cn(
'rounded-[8px] px-3 py-2.5 ring-1',
renderTask.status === 'failed'
? 'bg-[#b75b5b]/7 ring-[#b75b5b]/12'
: 'bg-[#47646b]/6 ring-[#47646b]/10',
)}
>
<div className="flex items-center gap-2">
<Loader2
className={cn(
'h-3.5 w-3.5 text-[#47646b]',
renderTask.status === 'running' && 'animate-spin',
renderTask.status === 'failed' && 'text-[#9f4f4f]',
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-black text-[#2d3335]">
<span>{getRenderTaskModeLabel(renderTask.mode)}</span>
<span className="rounded-[4px] bg-white/60 px-1.5 py-0.5 text-[8px] text-[#5a6062]/60">
{renderTask.imageCount} 张
</span>
</div>
<div className="mt-0.5 truncate text-[10px] font-medium text-[#5a6062]/70">
{renderTask.skuName}
</div>
</div>
<span className="font-mono text-[11px] font-black text-[#47646b]">
{Math.round(renderTask.progress)}%
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/70">
<div
className={cn(
'h-full rounded-full transition-[width] duration-500 ease-out',
renderTask.status === 'failed' ? 'bg-[#9f4f4f]' : 'bg-[#47646b]',
)}
style={{ width: `${renderTask.progress}%` }}
/>
</div>
<div className="mt-1.5 text-[10px] font-medium text-[#5a6062]/70">
{renderTask.stage}
</div>
</div>
) : null}
<div className="flex gap-2">
<button
type="button"
onClick={onOptimizePrompt}
disabled={optimizing || !prompt.trim() || !canGenerate}
className={cn(
'flex h-9 shrink-0 items-center gap-1.5 rounded-[6px] px-3 text-[11px] font-bold transition ring-1',
optimizing
? 'bg-[#47646b]/10 text-[#47646b] ring-[#47646b]/15'
: 'bg-gradient-to-b from-[#e8f4f0] to-[#d4ede6] text-[#2d6b5a] ring-[#47646b]/15 hover:from-[#d4ede6] hover:to-[#c0e5db]',
)}
>
<Wand2 className={cn('h-3.5 w-3.5', optimizing && 'animate-pulse')} />
{optimizing ? '优化中...' : '优化提示词'}
</button>
<Button
variant="filled"
size="small"
onClick={onGenerateScene}
disabled={generating || !sku?.modelUrl || !prompt.trim() || !canGenerate}
className="h-9 flex-1 rounded-[6px] text-[11px]"
>
{generating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="mr-1.5 h-3.5 w-3.5" />
)}
{generating ? '后台生成中' : '生成图片'}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mb-2 text-[12px] font-black text-[#2d3335]">当前 SKU 生图记录</div>
{records.length === 0 ? (
<div className="py-8 text-center text-[10px] font-medium text-[#5a6062]/35">
暂无记录
</div>
) : (
<div className="space-y-2.5">
{records.map((record) => (
<div
key={record.id}
className="group overflow-hidden rounded-[7px] bg-white/60 ring-1 ring-black/[0.04]"
>
<button
type="button"
onClick={() => setLightboxUrl(record.imageUrl)}
className="relative block w-full cursor-zoom-in bg-[#eef2f2]"
aria-label="打开大图"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={record.imageUrl} alt={record.skuName} className="aspect-[16/10] w-full object-cover" />
</button>
<div className="px-2.5 py-2">
<div className="flex items-center gap-2">
<span
className={cn(
'shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-black',
record.status === 'SUCCEEDED'
? 'bg-[#286c34]/8 text-[#286c34]'
: 'bg-[#b75b5b]/8 text-[#9f4f4f]',
)}
>
{record.status === 'SUCCEEDED' ? '已完成' : '失败'}
</span>
<span className="shrink-0 rounded-[4px] bg-black/[0.035] px-1.5 py-0.5 text-[8px] font-black text-[#5a6062]/55">
{record.mode === 'white-background' ? '白底图' : '场景图'}
</span>
<span className="text-[8px] text-[#5a6062]/35">
{formatRecordTime(record.createdAt)}
</span>
<div className="flex-1" />
<a
href={record.imageUrl}
target="_blank"
rel="noreferrer"
className="flex h-6 w-6 items-center justify-center rounded-[5px] text-[#5a6062]/35 transition hover:bg-black/[0.035] hover:text-[#2d3335]"
aria-label="下载图片"
>
<Download className="h-3.5 w-3.5" />
</a>
{canDeleteImages ? (
<button
type="button"
onClick={() => onDeleteRecord(record.id)}
className="flex h-6 w-6 items-center justify-center rounded-[5px] text-[#9f4f4f]/55 transition hover:bg-[#b75b5b]/8 hover:text-[#9f4f4f]"
aria-label="删除记录"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<p className="mt-1 line-clamp-1 text-[10px] font-medium text-[#2d3335]/70">
{record.skuName}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="shrink-0 bg-white">
<div className="space-y-4 px-5 py-5">
{!sku?.modelUrl ? (
<div className="flex gap-2 rounded-[8px] border border-[#9a7b3f]/12 bg-[#9a7b3f]/7 px-3 py-2 text-[11px] leading-5 text-[#7a612e]">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{canWriteConfig ? '当前 SKU 还没有模型文件,先在产品信息里上传 ZIP / 模型,或粘贴 SKU 模型 URL。' : '当前 SKU 还没有模型文件,请联系设计师配置。'}</span>
</div>
) : null}
<div className="grid grid-cols-[1fr_1.25fr] gap-2">
<button
type="button"
disabled={!sku?.modelUrl || generating || !canGenerate}
onClick={onCaptureWhiteBackground}
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-black/[0.035] text-[11px] font-black text-[#2d3335] transition hover:bg-black/[0.06] disabled:opacity-40"
>
<ImageIcon className="h-3.5 w-3.5" />
白底图
</button>
<button
type="button"
disabled={!sku?.modelUrl || generating || !canGenerate}
onClick={onGenerateScene}
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[11px] font-black text-white transition hover:opacity-90 disabled:opacity-40"
>
<Sparkles className="h-3.5 w-3.5" />
{generating ? '后台生成中' : '场景效果图'}
</button>
</div>
</div>
</div>
{lightboxUrl ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-8"
onClick={() => setLightboxUrl(null)}
>
<button
type="button"
onClick={() => setLightboxUrl(null)}
className="absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="关闭大图"
>
<X className="h-5 w-5" />
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={lightboxUrl} alt="AI 生图大图" className="max-h-full max-w-full rounded-[10px] object-contain shadow-2xl" />
</div>
) : null}
</aside>
)
}

View File

@ -0,0 +1,20 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { getAiRenderWorkbenchAccess } from '@/lib/ai-render-workbench-permissions'
import { materialService } from '@/server/services'
import AiRenderWorkbenchClient from './AiRenderWorkbenchClient'
export const metadata = {
title: 'AI 出图工作台',
}
export default async function AiRenderWorkbenchPage() {
const { authUser } = await requireSession('/login')
const access = getAiRenderWorkbenchAccess(authUser)
if (!access.canView) {
redirect('/intake')
}
const finishes = await materialService.findAll({ limit: 200 })
return <AiRenderWorkbenchClient finishes={finishes} initialAccess={access} />
}

View File

@ -0,0 +1,37 @@
'use client'
import type { AiRenderSpu } from './types'
export const AI_RENDER_SAMPLE_SPUS: AiRenderSpu[] = [
{
id: 'spu-cj0001',
name: 'CJ0001',
description: 'SPU 名称可以直接改,下面的 SKU 名称也可以直接改。',
skus: [
{
id: 'sku-cj000101',
name: 'CJ000101',
modelUrl: '',
thumbnailUrl: null,
materialColor: '#d8c6ad',
note: '白底图主推款',
},
{
id: 'sku-cj000102',
name: 'CJ000102',
modelUrl: '',
thumbnailUrl: null,
materialColor: '#9c6f55',
note: '场景效果图备选款',
},
{
id: 'sku-cj000103',
name: 'CJ000103',
modelUrl: '',
thumbnailUrl: null,
materialColor: '#64706c',
note: '深色材质款',
},
],
},
]

View File

@ -0,0 +1,93 @@
'use client'
import type { MaterialAxisUvConfigMap, MaterialConfig } from '@/lib/3d/material-manager'
import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-config'
export interface AiRenderSku {
id: string
name: string
modelUrl: string
thumbnailUrl?: string | null
note?: string
materialColor: string
materialTextureUrl?: string
nodeMaterials?: Record<string, AiRenderNodeMaterial>
}
export interface AiRenderNodeMaterial {
color: string
materialId?: string
materialName?: string
categoryId?: string
categoryName?: string
thumbnailUrl?: string | null
textureUrl?: string
normalMapUrl?: string | null
roughnessMapUrl?: string | null
metalnessMapUrl?: string | null
aoMapUrl?: string | null
metalness?: number
roughness?: number
opacity?: number
transmission?: number
ior?: number
thickness?: number
clearcoat?: number
clearcoatRoughness?: number
envMapIntensity?: number
textureRepeat?: number
textureRepeatU?: number
textureRepeatV?: number
textureRotation?: number
textureSizeMm?: number
textureProjectionAxis?: AssetVariantUvProjectionAxis
textureAxisConfigs?: MaterialAxisUvConfigMap | null
}
export interface AiRenderSpu {
id: string
name: string
description: string
skus: AiRenderSku[]
}
export type AiRenderOutputMode = 'white-background' | 'scene'
export interface LocalRenderRecord {
id: string
serverRenderId?: string
imageIndex?: number
skuId: string
skuName: string
mode: AiRenderOutputMode
imageUrl: string
prompt: string
status: 'SUCCEEDED' | 'FAILED'
createdAt: string
}
export interface AiRenderTaskState {
id: string
skuId: string
skuName: string
mode: AiRenderOutputMode
imageCount: number
progress: number
stage: string
status: 'running' | 'succeeded' | 'failed'
startedAt: string
}
export interface AiRenderSkuMaterialAssignment {
nodeName: string
material: MaterialConfig
}
export interface AiRenderUvEditorFormState {
textureRepeat: string
textureRepeatU: string
textureRepeatV: string
textureRotation: string
textureSizeMm: string
textureProjectionAxis: '' | AssetVariantUvProjectionAxis
}

View File

@ -0,0 +1,107 @@
import {
buildSkuMaterialAssignments,
createAiRenderUvEditorFormState,
getDefaultSkuName,
getDefaultSpuName,
mergeNodeMaterialUvConfig,
} from './utils'
describe('AI 出图工作台默认名称', () => {
it('新增 SPU 只生成可编辑临时名称,不按 CJ 编码', () => {
expect(getDefaultSpuName(2)).toBe('新 SPU 2')
})
it('新增 SKU 只生成可编辑临时名称,不根据 SPU 名称拼编号', () => {
expect(getDefaultSkuName(4)).toBe('新 SKU 4')
})
it('选中部件的颜色和贴图优先使用局部材质', () => {
const assignments = buildSkuMaterialAssignments(
{
id: 'sku-1',
name: 'CJ000101',
modelUrl: '/model.glb',
materialColor: '#d8c6ad',
materialTextureUrl: '/default.png',
nodeMaterials: {
'leg-left': {
color: '#111111',
textureUrl: '/leg.png',
},
},
},
['top', 'leg-left'],
)
expect(assignments).toHaveLength(2)
expect(assignments.find((item) => item.nodeName === 'top')?.material.color).toBe('#d8c6ad')
expect(assignments.find((item) => item.nodeName === 'top')?.material.textureUrl).toBe('/default.png')
expect(assignments.find((item) => item.nodeName === 'leg-left')?.material.color).toBe('#111111')
expect(assignments.find((item) => item.nodeName === 'leg-left')?.material.textureUrl).toBe('/leg.png')
})
it('渲染参数按 X/Y/Z 轴分别保存,不互相覆盖', () => {
const baseMaterial = {
color: '#d8c6ad',
textureUrl: '/wood.png',
textureRepeatU: 1,
textureRepeatV: 1,
textureRotation: 0,
textureSizeMm: 600,
}
const xConfigured = mergeNodeMaterialUvConfig(
baseMaterial,
{
textureProjectionAxis: 'x',
textureRepeatU: 2,
textureRepeatV: 0.5,
textureRotation: Math.PI / 2,
},
'x',
)
const yConfigured = mergeNodeMaterialUvConfig(
xConfigured,
{
textureProjectionAxis: 'y',
textureRepeatU: 3,
textureRepeatV: 0.8,
},
'y',
)
expect(yConfigured.textureAxisConfigs?.x?.textureRepeatU).toBe(2)
expect(yConfigured.textureAxisConfigs?.x?.textureRepeatV).toBe(0.5)
expect(yConfigured.textureAxisConfigs?.x?.textureRotation).toBe(Math.PI / 2)
expect(yConfigured.textureAxisConfigs?.y?.textureRepeatU).toBe(3)
expect(yConfigured.textureAxisConfigs?.y?.textureRepeatV).toBe(0.8)
expect(createAiRenderUvEditorFormState(yConfigured, 'x')).toEqual(
expect.objectContaining({ textureProjectionAxis: 'x', textureRepeatU: '2', textureRepeatV: '0.5', textureRotation: '90' }),
)
expect(createAiRenderUvEditorFormState(yConfigured, 'y')).toEqual(
expect.objectContaining({ textureProjectionAxis: 'y', textureRepeatU: '3', textureRepeatV: '0.8' }),
)
})
it('整体参数调整会保留已有分轴参数', () => {
const materialWithAxis = mergeNodeMaterialUvConfig(
{
color: '#d8c6ad',
textureUrl: '/wood.png',
},
{ textureProjectionAxis: 'z', textureRepeatU: 4 },
'z',
)
const baseConfigured = mergeNodeMaterialUvConfig(
materialWithAxis,
{ textureRepeat: 1.5, textureSizeMm: 450 },
null,
)
expect(baseConfigured.textureRepeat).toBe(1.5)
expect(baseConfigured.textureSizeMm).toBe(450)
expect(baseConfigured.textureAxisConfigs?.z?.textureRepeatU).toBe(4)
})
})

View File

@ -0,0 +1,386 @@
'use client'
import type { MaterialAxisUvConfig, MaterialAxisUvConfigMap, MaterialConfig } from '@/lib/3d/material-manager'
import { toMaterialConfig } from '@/lib/material-config'
import type {
AssetVariantUvProjectionAxis,
AssetVariantUvSurfaceConfig,
} from '@/lib/asset-variant-render-config'
import type { MaterialDTO } from '@/server/services'
import type {
AiRenderNodeMaterial,
AiRenderSku,
AiRenderSkuMaterialAssignment,
AiRenderSpu,
AiRenderUvEditorFormState,
} from './types'
export function createId(prefix: string) {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return `${prefix}-${crypto.randomUUID()}`
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
export function getDefaultSpuName(index: number) {
return `新 SPU ${index}`
}
export function getDefaultSkuName(index: number) {
return `新 SKU ${index}`
}
export function getProductStats(spus: AiRenderSpu[]) {
const skuCount = spus.reduce((sum, spu) => sum + spu.skus.length, 0)
const configuredSkuCount = spus.reduce(
(sum, spu) => sum + spu.skus.filter((sku) => Boolean(sku.modelUrl.trim())).length,
0,
)
return {
spuCount: spus.length,
skuCount,
configuredSkuCount,
}
}
export function formatUvNumber(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''
}
export function formatUvRotationDegrees(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return ''
}
const degrees = value * 180 / Math.PI
return Number.isInteger(degrees) ? String(degrees) : String(Number(degrees.toFixed(2)))
}
function isUvProjectionAxis(value: unknown): value is AssetVariantUvProjectionAxis {
return value === 'x' || value === 'y' || value === 'z'
}
function normalizeUvSurfaceConfig(
config: AssetVariantUvSurfaceConfig | null | undefined,
): AssetVariantUvSurfaceConfig | null {
if (!config) return null
const next: AssetVariantUvSurfaceConfig = {}
if (typeof config.textureRepeat === 'number' && Number.isFinite(config.textureRepeat)) {
next.textureRepeat = config.textureRepeat
}
if (typeof config.textureRepeatU === 'number' && Number.isFinite(config.textureRepeatU)) {
next.textureRepeatU = config.textureRepeatU
}
if (typeof config.textureRepeatV === 'number' && Number.isFinite(config.textureRepeatV)) {
next.textureRepeatV = config.textureRepeatV
}
if (typeof config.textureRotation === 'number' && Number.isFinite(config.textureRotation)) {
next.textureRotation = config.textureRotation
}
if (typeof config.textureSizeMm === 'number' && Number.isFinite(config.textureSizeMm)) {
next.textureSizeMm = config.textureSizeMm
}
if (isUvProjectionAxis(config.textureProjectionAxis)) {
next.textureProjectionAxis = config.textureProjectionAxis
}
return Object.keys(next).length > 0 ? next : null
}
function stripUvProjectionAxis(
config: AssetVariantUvSurfaceConfig | null | undefined,
): MaterialAxisUvConfig | null {
const normalized = normalizeUvSurfaceConfig(config)
if (!normalized) return null
const next: MaterialAxisUvConfig = {}
if (typeof normalized.textureRepeat === 'number') next.textureRepeat = normalized.textureRepeat
if (typeof normalized.textureRepeatU === 'number') next.textureRepeatU = normalized.textureRepeatU
if (typeof normalized.textureRepeatV === 'number') next.textureRepeatV = normalized.textureRepeatV
if (typeof normalized.textureRotation === 'number') next.textureRotation = normalized.textureRotation
if (typeof normalized.textureSizeMm === 'number') next.textureSizeMm = normalized.textureSizeMm
return Object.keys(next).length > 0 ? next : null
}
export function getAiRenderUvSurfaceConfig(
material: AiRenderNodeMaterial | null | undefined,
projectionAxis?: '' | AssetVariantUvProjectionAxis | null,
): AssetVariantUvSurfaceConfig | null {
if (!material) {
return isUvProjectionAxis(projectionAxis) ? { textureProjectionAxis: projectionAxis } : null
}
if (projectionAxis === undefined && isUvProjectionAxis(material.textureProjectionAxis)) {
const activeAxisConfig = material.textureAxisConfigs?.[material.textureProjectionAxis]
if (activeAxisConfig) {
return normalizeUvSurfaceConfig({
textureProjectionAxis: material.textureProjectionAxis,
...activeAxisConfig,
})
}
}
if (isUvProjectionAxis(projectionAxis)) {
return normalizeUvSurfaceConfig({
textureProjectionAxis: projectionAxis,
...(material.textureAxisConfigs?.[projectionAxis] ?? {}),
}) ?? { textureProjectionAxis: projectionAxis }
}
if (projectionAxis === null || projectionAxis === '') {
return normalizeUvSurfaceConfig({
textureRepeat: material.textureRepeat,
textureRepeatU: material.textureRepeatU,
textureRepeatV: material.textureRepeatV,
textureRotation: material.textureRotation,
textureSizeMm: material.textureSizeMm,
})
}
return normalizeUvSurfaceConfig({
textureRepeat: material.textureRepeat,
textureRepeatU: material.textureRepeatU,
textureRepeatV: material.textureRepeatV,
textureRotation: material.textureRotation,
textureSizeMm: material.textureSizeMm,
textureProjectionAxis: material.textureProjectionAxis,
})
}
export function createAiRenderUvEditorFormState(
material: AiRenderNodeMaterial | null | undefined,
projectionAxis?: '' | AssetVariantUvProjectionAxis | null,
): AiRenderUvEditorFormState {
const config = getAiRenderUvSurfaceConfig(material, projectionAxis)
return {
textureRepeat: formatUvNumber(config?.textureRepeat),
textureRepeatU: formatUvNumber(config?.textureRepeatU),
textureRepeatV: formatUvNumber(config?.textureRepeatV),
textureRotation: formatUvRotationDegrees(config?.textureRotation),
textureSizeMm: formatUvNumber(config?.textureSizeMm),
textureProjectionAxis: config?.textureProjectionAxis ?? '',
}
}
export function parseAiRenderUvEditorFormState(
state: AiRenderUvEditorFormState,
): AssetVariantUvSurfaceConfig | null {
const parseField = (value: string) => {
const normalized = value.trim()
if (!normalized) return undefined
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : undefined
}
const textureRepeat = parseField(state.textureRepeat)
const textureRepeatU = parseField(state.textureRepeatU)
const textureRepeatV = parseField(state.textureRepeatV)
const textureRotationDegrees = parseField(state.textureRotation)
const textureRotation =
textureRotationDegrees === undefined ? undefined : textureRotationDegrees * Math.PI / 180
const textureSizeMm = parseField(state.textureSizeMm)
const textureProjectionAxis =
state.textureProjectionAxis === 'x' ||
state.textureProjectionAxis === 'y' ||
state.textureProjectionAxis === 'z'
? state.textureProjectionAxis
: undefined
if (
textureRepeat === undefined &&
textureRepeatU === undefined &&
textureRepeatV === undefined &&
textureRotation === undefined &&
textureSizeMm === undefined &&
textureProjectionAxis === undefined
) {
return null
}
return {
...(textureRepeat !== undefined ? { textureRepeat } : {}),
...(textureRepeatU !== undefined ? { textureRepeatU } : {}),
...(textureRepeatV !== undefined ? { textureRepeatV } : {}),
...(textureRotation !== undefined ? { textureRotation } : {}),
...(textureSizeMm !== undefined ? { textureSizeMm } : {}),
...(textureProjectionAxis !== undefined ? { textureProjectionAxis } : {}),
}
}
export function createNodeMaterialFromMaterialDTO(material: MaterialDTO): AiRenderNodeMaterial {
const materialConfig = toMaterialConfig(material)
return {
color: materialConfig?.color || material.color || '#d8c6ad',
materialId: material.id,
materialName: material.name,
categoryId: material.categoryId,
categoryName: material.categoryName,
thumbnailUrl: materialConfig?.thumbnailUrl ?? material.thumbnailUrl ?? material.previewUrl ?? null,
textureUrl: materialConfig?.textureUrl ?? material.textureUrl ?? undefined,
normalMapUrl: materialConfig?.normalMapUrl ?? material.normalMapUrl ?? null,
roughnessMapUrl: materialConfig?.roughnessMapUrl ?? material.roughnessMapUrl ?? null,
metalnessMapUrl: materialConfig?.metalnessMapUrl ?? material.metalnessMapUrl ?? null,
aoMapUrl: materialConfig?.aoMapUrl ?? material.aoMapUrl ?? null,
metalness: materialConfig?.metalness ?? material.metalness,
roughness: materialConfig?.roughness ?? material.roughness,
opacity: materialConfig?.opacity ?? material.opacity,
transmission: materialConfig?.transmission ?? material.transmission,
ior: materialConfig?.ior ?? material.ior,
thickness: materialConfig?.thickness ?? material.thickness,
clearcoat: materialConfig?.clearcoat ?? material.clearcoat,
clearcoatRoughness: materialConfig?.clearcoatRoughness ?? material.clearcoatRoughness,
envMapIntensity: materialConfig?.envMapIntensity ?? material.envMapIntensity,
textureRepeat: materialConfig?.textureRepeat ?? material.textureRepeat,
textureRotation: materialConfig?.textureRotation ?? material.textureRotation,
textureSizeMm: materialConfig?.textureSizeMm ?? material.textureSizeMm,
}
}
export function mergeNodeMaterialUvConfig(
material: AiRenderNodeMaterial,
uvConfig: AssetVariantUvSurfaceConfig | null,
projectionAxis?: '' | AssetVariantUvProjectionAxis | null,
): AiRenderNodeMaterial {
if (isUvProjectionAxis(projectionAxis)) {
const axisConfig = stripUvProjectionAxis(uvConfig)
const nextAxisConfigs: MaterialAxisUvConfigMap = { ...(material.textureAxisConfigs ?? {}) }
if (axisConfig) {
nextAxisConfigs[projectionAxis] = axisConfig
} else {
delete nextAxisConfigs[projectionAxis]
}
const hasAxisConfigs = Object.keys(nextAxisConfigs).length > 0
return {
...material,
textureProjectionAxis: hasAxisConfigs ? projectionAxis : undefined,
textureAxisConfigs: hasAxisConfigs ? nextAxisConfigs : undefined,
}
}
const nextUvConfig = normalizeUvSurfaceConfig(uvConfig)
const nextProjectionAxis = nextUvConfig?.textureProjectionAxis
return {
...material,
textureRepeat: nextUvConfig?.textureRepeat ?? undefined,
textureRepeatU: nextUvConfig?.textureRepeatU ?? undefined,
textureRepeatV: nextUvConfig?.textureRepeatV ?? undefined,
textureRotation: nextUvConfig?.textureRotation ?? undefined,
textureSizeMm: nextUvConfig?.textureSizeMm ?? undefined,
textureProjectionAxis: isUvProjectionAxis(nextProjectionAxis) ? nextProjectionAxis : undefined,
textureAxisConfigs: material.textureAxisConfigs,
}
}
export function createSkuMaterial(sku: AiRenderSku, nodeMaterial?: AiRenderNodeMaterial | null): MaterialConfig {
return {
id: `ai-render-${sku.id}${nodeMaterial ? `-${nodeMaterial.materialId ?? nodeMaterial.color}-${nodeMaterial.textureUrl ?? ''}-${nodeMaterial.textureProjectionAxis ?? ''}-${nodeMaterial.textureRepeat ?? ''}-${nodeMaterial.textureRepeatU ?? ''}-${nodeMaterial.textureRepeatV ?? ''}-${nodeMaterial.textureRotation ?? ''}-${nodeMaterial.textureSizeMm ?? ''}-${JSON.stringify(nodeMaterial.textureAxisConfigs ?? {})}` : ''}`,
name: nodeMaterial?.materialName || (nodeMaterial ? `${sku.name} 局部材质` : sku.name),
categoryId: nodeMaterial?.categoryId || 'ai-render-workbench',
categoryName: nodeMaterial?.categoryName || 'AI 出图材质',
thumbnailUrl: nodeMaterial?.thumbnailUrl ?? null,
textureUrl: nodeMaterial?.textureUrl?.trim() || sku.materialTextureUrl?.trim() || null,
normalMapUrl: nodeMaterial?.normalMapUrl ?? null,
roughnessMapUrl: nodeMaterial?.roughnessMapUrl ?? null,
metalnessMapUrl: nodeMaterial?.metalnessMapUrl ?? null,
aoMapUrl: nodeMaterial?.aoMapUrl ?? null,
color: nodeMaterial?.color || sku.materialColor || '#d8c6ad',
metalness: nodeMaterial?.metalness ?? 0.05,
roughness: nodeMaterial?.roughness ?? 0.55,
opacity: nodeMaterial?.opacity ?? 1,
transmission: nodeMaterial?.transmission,
ior: nodeMaterial?.ior,
thickness: nodeMaterial?.thickness,
clearcoat: nodeMaterial?.clearcoat,
clearcoatRoughness: nodeMaterial?.clearcoatRoughness,
envMapIntensity: nodeMaterial?.envMapIntensity,
textureRepeat: nodeMaterial?.textureRepeat ?? 1,
textureRepeatU: nodeMaterial?.textureRepeatU,
textureRepeatV: nodeMaterial?.textureRepeatV,
textureRotation: nodeMaterial?.textureRotation ?? 0,
textureSizeMm: nodeMaterial?.textureSizeMm ?? 600,
textureProjectionAxis: nodeMaterial?.textureProjectionAxis,
textureAxisConfigs: nodeMaterial?.textureAxisConfigs,
}
}
export function buildSkuMaterialAssignments(
sku: AiRenderSku | null,
nodeNames: string[],
): AiRenderSkuMaterialAssignment[] {
if (!sku || nodeNames.length === 0) {
return []
}
return nodeNames.map((nodeName) => ({
nodeName,
material: createSkuMaterial(sku, sku.nodeMaterials?.[nodeName] ?? null),
}))
}
export function buildAiRenderAssetSnapshot(params: {
spu: AiRenderSpu | null
sku: AiRenderSku | null
nodeNames: string[]
}) {
const { spu, sku, nodeNames } = params
if (!sku) {
return {
type: 'ai-render-workbench',
spu: spu ? { id: spu.id, name: spu.name } : null,
nodes: [],
}
}
return {
type: 'ai-render-workbench',
spu: spu ? { id: spu.id, name: spu.name } : null,
sku: {
id: sku.id,
name: sku.name,
modelUrl: sku.modelUrl,
thumbnailUrl: sku.thumbnailUrl ?? null,
},
nodes:
nodeNames.length > 0
? nodeNames.map((nodeName) => ({
name: nodeName,
materialConfig: createSkuMaterial(sku, sku.nodeMaterials?.[nodeName] ?? null),
}))
: [
{
name: sku.name,
materialConfig: createSkuMaterial(sku),
},
],
}
}
export function buildAiRenderWorkspaceSnapshot(params: {
spu: AiRenderSpu | null
sku: AiRenderSku | null
prompt: string
mode: string
}) {
const { spu, sku, prompt, mode } = params
return {
source: 'ai-render-workbench',
mode,
prompt,
spu: spu ? { id: spu.id, name: spu.name, description: spu.description } : null,
sku: sku
? {
id: sku.id,
name: sku.name,
modelUrl: sku.modelUrl,
materialColor: sku.materialColor,
materialTextureUrl: sku.materialTextureUrl ?? null,
nodeMaterials: sku.nodeMaterials ?? {},
}
: null,
}
}

View File

@ -0,0 +1,39 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { aiRenderWorkbenchService } from '@/server/services'
const importLocalSchema = z.object({
spus: z.unknown().optional(),
records: z.unknown().optional(),
})
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = importLocalSchema.safeParse(body ?? {})
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? '本地草稿数据不正确',
400,
ctx.requestId,
)
}
try {
const workbench = await aiRenderWorkbenchService.importLocalDraft(parsed.data, ctx.user)
return jsonSuccess(workbench, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,22 @@
import { NextRequest } from 'next/server'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { aiRenderWorkbenchService } from '@/server/services'
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
try {
const workbench = await aiRenderWorkbenchService.list(ctx.user)
return jsonSuccess(workbench, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,70 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { aiRenderWorkbenchService } from '@/server/services'
const updateSkuSchema = z.object({
name: z.string().trim().max(80).optional().nullable(),
modelUrl: z.string().trim().max(2000).optional().nullable(),
thumbnailUrl: z.string().trim().max(2000).optional().nullable(),
note: z.string().trim().max(1000).optional().nullable(),
materialColor: z.string().trim().max(32).optional().nullable(),
materialTextureUrl: z.string().trim().max(2000).optional().nullable(),
nodeMaterials: z.unknown().optional(),
sortOrder: z.number().int().min(0).max(99999).optional().nullable(),
})
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
const body = await request.json().catch(() => null)
const parsed = updateSkuSchema.safeParse(body ?? {})
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? 'SKU 参数不正确',
400,
ctx.requestId,
)
}
try {
const workbench = await aiRenderWorkbenchService.updateSku(id, parsed.data, ctx.user)
return jsonSuccess(workbench, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
try {
const workbench = await aiRenderWorkbenchService.deleteSku(id, ctx.user)
return jsonSuccess(workbench, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,39 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { aiRenderWorkbenchService } from '@/server/services'
const createSkuSchema = z.object({
spuId: z.string().trim().min(1, '缺少 SPU ID').max(160),
name: z.string().trim().max(80).optional().nullable(),
})
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = createSkuSchema.safeParse(body ?? {})
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? 'SKU 参数不正确',
400,
ctx.requestId,
)
}
try {
const workbench = await aiRenderWorkbenchService.createSku(parsed.data, ctx.user)
return jsonSuccess(workbench, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,65 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { aiRenderWorkbenchService } from '@/server/services'
const updateSpuSchema = z.object({
name: z.string().trim().max(80).optional().nullable(),
description: z.string().trim().max(1000).optional().nullable(),
sortOrder: z.number().int().min(0).max(99999).optional().nullable(),
})
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
const body = await request.json().catch(() => null)
const parsed = updateSpuSchema.safeParse(body ?? {})
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? 'SPU 参数不正确',
400,
ctx.requestId,
)
}
try {
const workbench = await aiRenderWorkbenchService.updateSpu(id, parsed.data, ctx.user)
return jsonSuccess(workbench, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
try {
const workbench = await aiRenderWorkbenchService.deleteSpu(id, ctx.user)
return jsonSuccess(workbench, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,39 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { aiRenderWorkbenchService } from '@/server/services'
const createSpuSchema = z.object({
name: z.string().trim().max(80).optional().nullable(),
description: z.string().trim().max(1000).optional().nullable(),
})
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = createSpuSchema.safeParse(body ?? {})
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? 'SPU 参数不正确',
400,
ctx.requestId,
)
}
try {
const workbench = await aiRenderWorkbenchService.createSpu(parsed.data, ctx.user)
return jsonSuccess(workbench, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -83,12 +83,13 @@ describe('GET /api/assisted-assembly/ai-renders/[id]', () => {
it('DELETE 只删除当前用户自己的记录', async () => {
mockDeleteForUser.mockResolvedValue(undefined)
const response = await DELETE({} as any, {
const request = { nextUrl: new URL('http://localhost/api/assisted-assembly/ai-renders/render-1') } as any
const response = await DELETE(request, {
params: Promise.resolve({ id: 'render-1' }),
})
expect(mockHandleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), { requireAuth: true })
expect(mockDeleteForUser).toHaveBeenCalledWith('render-1', expect.objectContaining({ id: 'user-1' }))
expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true })
expect(mockDeleteForUser).toHaveBeenCalledWith('render-1', expect.objectContaining({ id: 'user-1' }), { imageIndex: null })
expect(response).toEqual(
expect.objectContaining({
success: true,

View File

@ -34,9 +34,13 @@ export async function DELETE(
request,
async (ctx) => {
const { id } = await params
const rawImageIndex = request.nextUrl.searchParams.get('imageIndex')
const imageIndex = rawImageIndex == null ? null : Number(rawImageIndex)
try {
await assistedAssemblyAiRenderService.deleteForUser(id, ctx.user)
await assistedAssemblyAiRenderService.deleteForUser(id, ctx.user, {
imageIndex: Number.isInteger(imageIndex) ? imageIndex : null,
})
return jsonSuccess({ deleted: true }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {

View File

@ -99,7 +99,7 @@ describe('GET/POST /api/assisted-assembly/ai-renders', () => {
expect(mockHandleRequest).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
{ requireAuth: true, requireDesignWorkspaceWrite: true },
{ requireAuth: true },
)
expect(mockCreate).toHaveBeenCalledWith(body, expect.objectContaining({ id: 'user-1' }))
expect(response).toEqual(

View File

@ -3,6 +3,7 @@ import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assistedAssemblyAiRenderService } from '@/server/services'
import { canOperateDesignWorkspace } from '@/lib/role-scope'
const createAiRenderSchema = z.object({
prompt: z.string().trim().min(1, '请填写效果图提示词').max(1200, '提示词不能超过 1200 个字符'),
@ -10,6 +11,8 @@ const createAiRenderSchema = z.object({
imageCount: z.number().int().min(1).max(8).optional().nullable(),
aspectRatio: z.string().trim().max(32).optional().nullable(),
imageSize: z.string().trim().max(32).optional().nullable(),
mode: z.string().trim().max(32).optional().nullable(),
workbenchSkuId: z.string().trim().max(160).optional().nullable(),
referenceImageDataUrl: z.string().min(1, '缺少当前 3D 预览截图'),
workspaceSnapshot: z.unknown(),
assetSnapshot: z.unknown(),
@ -26,6 +29,16 @@ export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const workbenchSkuId = request.nextUrl.searchParams.get('workbenchSkuId')?.trim()
if (workbenchSkuId) {
const renders = await assistedAssemblyAiRenderService.listForWorkbenchSku(
workbenchSkuId,
ctx.user,
{ limit: readLimit(request) },
)
return jsonSuccess({ renders }, 200, ctx.requestId)
}
const renders = await assistedAssemblyAiRenderService.listForUser(ctx.user, {
limit: readLimit(request),
})
@ -50,6 +63,15 @@ export async function POST(request: NextRequest) {
)
}
if (!parsed.data.workbenchSkuId && !canOperateDesignWorkspace(ctx.user)) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有辅助装配 AI 生图权限',
403,
ctx.requestId,
)
}
try {
const render = await assistedAssemblyAiRenderService.create(parsed.data, ctx.user)
return jsonSuccess({ render }, 201, ctx.requestId)
@ -60,6 +82,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ requireAuth: true, requireDesignWorkspaceWrite: true },
{ requireAuth: true },
)
}

View File

@ -0,0 +1,75 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
const mockHandleRequest: any = jest.fn()
const mockJsonSuccess: any = jest.fn()
const mockListForUser: any = jest.fn()
const mockListForWorkbenchSku: any = jest.fn()
const mockCreate: any = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonError: jest.fn(),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
}))
jest.mock('@/server/services', () => ({
assistedAssemblyAiRenderService: {
listForUser: (...args: unknown[]) => mockListForUser(...args),
listForWorkbenchSku: (...args: unknown[]) => mockListForWorkbenchSku(...args),
create: (...args: unknown[]) => mockCreate(...args),
},
}))
const { GET } = require('./route')
function createRequest(url: string) {
return {
nextUrl: new URL(url),
json: jest.fn(),
} as any
}
describe('GET /api/assisted-assembly/ai-renders for AI render workbench', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({
requestId: 'req-ai-render-workbench',
user: {
id: 'user-1',
role: 'USER',
businessRoles: ['OPERATIONS'],
},
}),
)
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
success: true,
data,
status,
requestId,
}))
})
it('reads shared records by selected workbench SKU', async () => {
mockListForWorkbenchSku.mockResolvedValue([{ id: 'render-workbench-1' }])
const request = createRequest('http://localhost/api/assisted-assembly/ai-renders?workbenchSkuId=sku-1&limit=12')
const response = await GET(request)
expect(mockListForUser).not.toHaveBeenCalled()
expect(mockListForWorkbenchSku).toHaveBeenCalledWith(
'sku-1',
expect.objectContaining({ id: 'user-1' }),
{ limit: 12 },
)
expect(response).toEqual(
expect.objectContaining({
success: true,
status: 200,
data: { renders: [{ id: 'render-workbench-1' }] },
}),
)
})
})

View File

@ -105,6 +105,39 @@ describe('POST /api/auth/login', () => {
)
})
it('供应商登录后强制回到条码打印入口', async () => {
mockLogin.mockResolvedValueOnce({
token: 'supplier-token',
user: {
id: 'supplier-user-1',
email: 'supplier-n1280@supplier.local',
name: '临沂美诗家居',
role: 'USER',
businessRoles: ['SUPPLIER'],
status: 'ACTIVE',
isFirstLogin: false,
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
department: null,
team: null,
avatar: null,
lastLoginAt: null,
},
isFirstLogin: false,
})
mockResolvePostLoginRedirect.mockResolvedValueOnce('/warehouse')
const response = await POST(
createMockRequest({
identifier: '临沂美诗家居',
password: 'Supplier123',
redirectPath: '/warehouse',
}),
)
expect(response.data.redirectPath).toBe('/intake')
})
it('认证失败时应返回统一错误,避免暴露账号是否存在', async () => {
mockLogin.mockRejectedValue(
new AppError(ErrorCode.AUTH_INVALID_CREDENTIALS, '账号或密码错误'),

View File

@ -9,8 +9,10 @@ const mockCreateUser: any = jest.fn()
const mockFindByEmployeeIdentity: any = jest.fn()
const mockCreateLog: any = jest.fn()
const mockMatchByJobNumber: any = jest.fn()
const mockMatchEnabledUniqueSupplierByName: any = jest.fn()
const mockResolveDepartmentIdByExternalId: any = jest.fn()
const mockFindDepartmentById: any = jest.fn()
const mockGetByName: any = jest.fn()
const GENERIC_REGISTER_REVIEW_MESSAGE = '注册信息需人工核验,请确认信息无误后提交或联系管理员'
jest.mock('@/server/core/response', () => ({
@ -20,6 +22,7 @@ jest.mock('@/server/core/response', () => ({
jest.mock('@/server/services/user.service', () => ({
userService: {
getByName: (...args: unknown[]) => mockGetByName(...args),
create: (...args: unknown[]) => mockCreateUser(...args),
findByEmployeeIdentity: (...args: unknown[]) => mockFindByEmployeeIdentity(...args),
},
@ -38,6 +41,13 @@ jest.mock('@/server/services/external-employee-directory.service', () => ({
},
}))
jest.mock('@/server/services/external-supplier-directory.service', () => ({
externalSupplierDirectoryService: {
matchEnabledUniqueSupplierByName: (...args: unknown[]) =>
mockMatchEnabledUniqueSupplierByName(...args),
},
}))
jest.mock('@/server/services/department.service', () => ({
departmentService: {
findById: (...args: unknown[]) => mockFindDepartmentById(...args),
@ -64,10 +74,132 @@ describe('POST /api/auth/register', () => {
status,
requestId,
}))
mockMatchEnabledUniqueSupplierByName.mockResolvedValue({ status: 'not_found' })
mockGetByName.mockResolvedValue(null)
mockFindByEmployeeIdentity.mockResolvedValue(null)
mockFindDepartmentById.mockResolvedValue(null)
})
it('未填写工号时按启用且唯一的供应商名称创建可直接登录的供应商账号', async () => {
mockMatchEnabledUniqueSupplierByName.mockResolvedValue({
status: 'matched',
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
})
mockCreateUser.mockResolvedValue({
id: 'supplier-user-1',
email: 'supplier-n1280@supplier.local',
name: '临沂美诗家居',
role: 'USER',
businessRoles: ['SUPPLIER'],
status: 'ACTIVE',
isFirstLogin: false,
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
})
const response = await POST(createRequest({
email: 'ignored@example.com',
name: ' 临沂美诗家居 ',
password: 'abc12345',
}))
expect(mockMatchByJobNumber).not.toHaveBeenCalled()
expect(mockMatchEnabledUniqueSupplierByName).toHaveBeenCalledWith('临沂美诗家居')
expect(mockGetByName).toHaveBeenCalledWith('临沂美诗家居')
expect(mockCreateUser).toHaveBeenCalledWith(
expect.objectContaining({
email: 'supplier-n1280@supplier.local',
name: '临沂美诗家居',
role: 'USER',
businessRoles: ['SUPPLIER'],
status: 'ACTIVE',
isFirstLogin: false,
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
nameValidationMode: 'supplier',
}),
)
expect(response).toEqual(expect.objectContaining({ success: true, status: 201 }))
})
it('供应商名称未匹配时拒绝自助注册', async () => {
mockMatchEnabledUniqueSupplierByName.mockResolvedValue({ status: 'not_found' })
await expect(POST(createRequest({
name: '不存在供应商',
password: 'abc12345',
}))).rejects.toEqual(
expect.objectContaining({
code: ErrorCode.VALIDATION_FAILED,
}) as AppError,
)
expect(mockCreateUser).not.toHaveBeenCalled()
})
it('供应商停用时拒绝自助注册', async () => {
mockMatchEnabledUniqueSupplierByName.mockResolvedValue({
status: 'disabled',
supplierName: '停用供应商',
})
await expect(POST(createRequest({
name: '停用供应商',
password: 'abc12345',
}))).rejects.toEqual(
expect.objectContaining({
code: ErrorCode.VALIDATION_FAILED,
message: '该供应商已停用,暂不支持自助注册,请联系管理员',
}) as AppError,
)
expect(mockCreateUser).not.toHaveBeenCalled()
})
it('供应商名称重复时拒绝自助注册并提示联系管理员绑定', async () => {
mockMatchEnabledUniqueSupplierByName.mockResolvedValue({
status: 'duplicate',
supplierName: '重名供应商',
})
await expect(POST(createRequest({
name: '重名供应商',
password: 'abc12345',
}))).rejects.toEqual(
expect.objectContaining({
code: ErrorCode.VALIDATION_FAILED,
message: '该供应商名称存在重复主数据,暂不支持自助注册,请联系管理员绑定',
}) as AppError,
)
expect(mockCreateUser).not.toHaveBeenCalled()
})
it('同名供应商账号已存在时不重复创建', async () => {
mockMatchEnabledUniqueSupplierByName.mockResolvedValue({
status: 'matched',
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
})
mockGetByName.mockResolvedValue({
id: 'existing-supplier-user',
name: '临沂美诗家居',
})
await expect(POST(createRequest({
name: '临沂美诗家居',
password: 'abc12345',
}))).rejects.toEqual(
expect.objectContaining({
code: ErrorCode.VALIDATION_FAILED,
message: '该供应商账号已存在,请直接登录或联系管理员',
}) as AppError,
)
expect(mockCreateUser).not.toHaveBeenCalled()
})
it('工号匹配到内部在职员工后创建已激活账号', async () => {
mockMatchByJobNumber.mockResolvedValue({
id: 'emp-1',

View File

@ -11,6 +11,7 @@ import { AppError, ErrorCode } from '@/server/core/errors'
import { userService } from '@/server/services/user.service'
import { logService } from '@/server/services/log.service'
import { externalEmployeeDirectoryService } from '@/server/services/external-employee-directory.service'
import { externalSupplierDirectoryService } from '@/server/services/external-supplier-directory.service'
import { departmentService } from '@/server/services/department.service'
import { resolveEmployeeAccountProvisioning } from '@/lib/hr-sync/employee-account-provisioning'
@ -18,10 +19,10 @@ import { resolveEmployeeAccountProvisioning } from '@/lib/hr-sync/employee-accou
* 注册请求体
*/
interface RegisterRequest {
email: string
email?: string
name: string
password: string
jobNumber: string
jobNumber?: string
// role 参数被忽略,注册只允许创建 USER 角色
departmentId?: string
teamId?: string
@ -30,17 +31,108 @@ interface RegisterRequest {
// 注册允许的默认角色(防止权限提升)
const REGISTER_DEFAULT_ROLE = 'USER' as const
const GENERIC_REGISTER_REVIEW_MESSAGE = '注册信息需人工核验,请确认信息无误后提交或联系管理员'
const GENERIC_SUPPLIER_REGISTER_MESSAGE =
'供应商名称未匹配或不可自助注册,请确认名称与供应商主数据一致,或联系管理员绑定'
function normalizePersonName(name: string | null | undefined): string {
return String(name ?? '').trim()
}
function normalizeEmail(email: string | null | undefined): string {
return String(email ?? '').trim().toLowerCase()
}
function buildSupplierPlaceholderEmail(supplierCode: string) {
return `supplier-${supplierCode.trim().toLowerCase()}@supplier.local`
}
function buildEmployeePlaceholderEmail(jobNumber: string) {
return `employee-${jobNumber.trim().toLowerCase()}@employee.local`
}
function getSupplierRegisterErrorMessage(
status: 'not_found' | 'disabled' | 'duplicate',
) {
if (status === 'duplicate') {
return '该供应商名称存在重复主数据,暂不支持自助注册,请联系管理员绑定'
}
if (status === 'disabled') {
return '该供应商已停用,暂不支持自助注册,请联系管理员'
}
return GENERIC_SUPPLIER_REGISTER_MESSAGE
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body: RegisterRequest = await request.json()
const { email, name, password, jobNumber } = body
const submittedEmail = normalizeEmail(body.email)
const submittedName = normalizePersonName(body.name)
const password = body.password
const jobNumber = String(body.jobNumber ?? '').trim()
if (!jobNumber) {
const supplierMatch =
await externalSupplierDirectoryService.matchEnabledUniqueSupplierByName(
submittedName,
)
if (supplierMatch.status !== 'matched') {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
getSupplierRegisterErrorMessage(supplierMatch.status),
)
}
const email = buildSupplierPlaceholderEmail(supplierMatch.supplierCode)
const existingSupplierUser = await userService.getByName(
supplierMatch.supplierName,
)
if (existingSupplierUser) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'该供应商账号已存在,请直接登录或联系管理员',
)
}
const user = await userService.create({
email,
name: supplierMatch.supplierName,
password,
role: REGISTER_DEFAULT_ROLE,
businessRoles: ['SUPPLIER'],
status: 'ACTIVE',
isFirstLogin: false,
supplierCode: supplierMatch.supplierCode,
supplierName: supplierMatch.supplierName,
nameValidationMode: 'supplier',
})
await logService.create({
userId: user.id,
type: 'USER_REGISTER',
action: '供应商自助注册',
metadata: {
email: user.email,
name: user.name,
role: user.role,
businessRoles: user.businessRoles,
supplierCode: user.supplierCode,
supplierName: user.supplierName,
},
})
return jsonSuccess(
{
message: '供应商注册成功,请使用供应商名称登录',
user,
},
201,
ctx.requestId,
)
}
const employee = await externalEmployeeDirectoryService.matchByJobNumber(jobNumber)
if (!employee || employee.sourceStatus !== 'EMPLOYED') {
@ -50,7 +142,6 @@ export async function POST(request: NextRequest) {
)
}
const submittedName = normalizePersonName(name)
const employeeName = normalizePersonName(employee.name)
if (submittedName !== employeeName) {
throw new AppError(
@ -85,7 +176,7 @@ export async function POST(request: NextRequest) {
// 调用服务层创建用户
// 强制使用 USER 角色,忽略用户传入的 role 参数,防止权限提升攻击
const user = await userService.create({
email,
email: submittedEmail || buildEmployeePlaceholderEmail(jobNumber),
name: employeeName,
password,
role: REGISTER_DEFAULT_ROLE,

View File

@ -8,6 +8,7 @@ import {
normalizeProductFactoryContractType,
} from "@/server/services/intake-contract-docx.service";
import { isIntakeNonIntegratedSupplyChainUser } from "@/lib/non-integrated-permissions";
import { isSupplierAccount } from "@/lib/supplier-account";
import type { AuthUser } from "@/server/core/types";
export const runtime = "nodejs";
@ -18,6 +19,7 @@ type RouteContext = {
function canDownloadProductFactoryContract(user: AuthUser) {
if (user.role === "ADMIN" || user.role === "SUPER_ADMIN") return true;
if (isSupplierAccount(user)) return true;
return isIntakeNonIntegratedSupplyChainUser({
id: user.id,

View File

@ -7,6 +7,7 @@ import {
normalizeProductFactoryOrderSheetBatch,
} from "@/server/services/intake-order-sheet-xlsx.service";
import { isIntakeNonIntegratedSupplyChainUser } from "@/lib/non-integrated-permissions";
import { isSupplierAccount } from "@/lib/supplier-account";
import type { AuthUser } from "@/server/core/types";
export const runtime = "nodejs";
@ -17,6 +18,7 @@ type RouteContext = {
function canDownloadProductFactoryOrderSheet(user: AuthUser) {
if (user.role === "ADMIN" || user.role === "SUPER_ADMIN") return true;
if (isSupplierAccount(user)) return true;
return isIntakeNonIntegratedSupplyChainUser({
id: user.id,

View File

@ -58,6 +58,6 @@ export async function POST(request: NextRequest) {
return jsonSuccess({ url }, 201, requestId)
},
{ requireAuth: true },
{ requireAuth: true, requireDesignWorkspaceWrite: true },
)
}

View File

@ -41,7 +41,7 @@ export async function PUT(
return handleRequest(request, async (ctx) => {
const { id } = await params
const body = await request.json()
const { email, name, password, departmentId, phone, status } = body as {
const { email, name, password, departmentId, phone, status, supplierCode, supplierName } = body as {
email?: string
name?: string
password?: string
@ -49,6 +49,8 @@ export async function PUT(
departmentId?: string | null
phone?: string | null
status?: UserStatus
supplierCode?: string | null
supplierName?: string | null
}
const role = parseUserRoleInput(body.role)
const businessRoles = 'businessRoles' in body
@ -58,7 +60,19 @@ export async function PUT(
try {
const updatedUser = await userManagementService.updateUser(
id,
{ email, name, password, role, businessRoles, departmentId, teamId: null, phone, status },
{
email,
name,
password,
role,
businessRoles,
departmentId,
teamId: null,
phone,
status,
supplierCode,
supplierName,
},
ctx.user
)

View File

@ -197,4 +197,49 @@ describe('GET/POST /api/users', () => {
}),
)
})
it('POST creates supplier accounts with a backend placeholder email', async () => {
mockCreateUser.mockResolvedValue({
id: 'supplier-user-1',
email: 'supplier-n1280@supplier.local',
name: '临沂美诗家居',
role: 'USER',
businessRoles: ['SUPPLIER'],
status: 'ACTIVE',
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
})
const response = await POST(
createPostRequest({
name: '临沂美诗家居',
password: 'abc12345',
role: 'USER',
businessRoles: ['SUPPLIER'],
supplierCode: ' n1280 ',
supplierName: '临沂美诗家居',
}),
)
expect(mockCreateUser).toHaveBeenCalledWith(
expect.objectContaining({
email: 'supplier-n1280@supplier.local',
name: '临沂美诗家居',
password: 'abc12345',
role: 'USER',
businessRoles: ['SUPPLIER'],
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
}),
expect.objectContaining({
id: 'admin-1',
}),
)
expect(response).toEqual(
expect.objectContaining({
success: true,
status: 201,
}),
)
})
})

View File

@ -19,6 +19,10 @@ import {
const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
function buildSupplierPlaceholderEmail(supplierCode: string) {
return `supplier-${supplierCode.trim().toLowerCase()}@supplier.local`
}
export async function GET(request: NextRequest) {
return handleRequest(
request,
@ -56,14 +60,33 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json()
const { email, name, password, departmentId, phone } = body
const { email, name, password, departmentId, phone, supplierCode, supplierName } = body
const role = parseUserRoleInput(body.role)
const jobNumber = typeof body.jobNumber === 'string' ? body.jobNumber : undefined
const businessRoles = 'businessRoles' in body
? parseBusinessRolesInput(body.businessRoles, { optional: true })
: undefined
if (!email || !name || !password) {
const normalizedSupplierCode =
typeof supplierCode === 'string' ? supplierCode.trim().toUpperCase() : ''
const isSupplierUser = businessRoles?.includes('SUPPLIER') ?? false
const effectiveEmail =
typeof email === 'string' && email.trim()
? email
: isSupplierUser && normalizedSupplierCode
? buildSupplierPlaceholderEmail(normalizedSupplierCode)
: ''
if (isSupplierUser && !normalizedSupplierCode) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
'供应商账号必须绑定供应商编码',
400,
ctx.requestId
)
}
if (!effectiveEmail || !name || !password) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
'邮箱、姓名和密码为必填项',
@ -73,7 +96,16 @@ export async function POST(request: NextRequest) {
}
try {
const createInput = { email, name, password, role, departmentId, phone }
const createInput = {
email: effectiveEmail,
name,
password,
role,
departmentId,
phone,
supplierCode: isSupplierUser ? normalizedSupplierCode : supplierCode,
supplierName,
}
const normalizedCreateInput = {
...createInput,
...(businessRoles ? { businessRoles } : {}),

View File

@ -15,7 +15,7 @@ export default async function DraftGroupsPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>
} = {}) {
}) {
const { authUser } = await requireSession('/login')
const params = searchParams ? await searchParams : {}
const draftGroupId = readSingleSearchParam(params, 'draftGroupId')

View File

@ -0,0 +1,226 @@
"use client";
import type { ReactNode } from "react";
import { BarcodePrintWorkspace } from "@/app/intake/barcode-print/BarcodePrintWorkspace";
import {
BomLibraryWorkspaceContent,
isBomLibraryWorkspaceTab,
} from "@/app/intake/bom-library/BomLibraryWorkspaceContent";
import type {
IntakeEntryTab,
IntakeWorkspaceTab,
ProductDevelopmentKind,
WorkbenchSku,
WorkspaceProductDetailState,
WorkspaceProductMode,
} from "@/app/intake/intakeWorkspaceTypes";
import { AdminNodePermissionWorkspace } from "@/app/intake/node-permission/AdminNodePermissionWorkspace";
import type {
NonIntegratedProductRecord,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
import {
isProductDevelopmentWorkspaceTab,
ProductDevelopmentWorkspaceContent,
} from "@/app/intake/product-development/ProductDevelopmentWorkspaceContent";
import {
type ProductPatentLedgerTarget,
} from "@/app/intake/product-patent/ProductPatentDomain";
import {
isProductPatentWorkspaceTab,
ProductPatentWorkspaceContent,
} from "@/app/intake/product-patent/ProductPatentWorkspaceContent";
import {
isRawMaterialLibraryWorkspaceTab,
RawMaterialLibraryWorkspaceContent,
} from "@/app/intake/raw-material-library/RawMaterialLibraryWorkspaceContent";
import {
isReferenceEstimatorWorkspaceTab,
ReferenceEstimatorWorkspaceContent,
} from "@/app/intake/reference-estimator/ReferenceEstimatorWorkspaceContent";
import type {
MaterialCategoryDTO,
MaterialDTO,
SubstrateDictionaryBundleDTO,
SubstrateDTO,
} from "@/server/services";
type IntakeWorkspaceUser = {
id: string;
name: string;
role: string;
businessRoles?: string[];
department?: { name?: string | null } | null;
departmentPaths?: string[];
};
type IntakeActiveWorkspaceContentProps = {
activeWorkspaceTab: IntakeWorkspaceTab | null;
fallback: ReactNode;
currentUser: IntakeWorkspaceUser;
canManageSpuRuleLibrary: boolean;
canManageNodePermissionSettings: boolean;
canEditBomLibrary: boolean;
canViewProductPatentLedger: boolean;
canEditProductPatentLedger: boolean;
canViewProductPatentDashboard: boolean;
activeProductWorkspaceNeedsFullLoad: boolean;
activeProductWorkspaceLoadError: string | null;
seriesNameOptions: string[];
existingSeriesNames: string[];
existingSeriesComponentDetailNames: string[];
seriesDraftSkuOptions: WorkbenchSku[];
materialLibrarySubstrates: SubstrateDTO[];
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
selectedRawMaterialName?: string;
nonIntegratedProducts: NonIntegratedProductRecord[];
onRetryProductWorkspaceLoad: (tabId: string) => void;
onProductSaved: (product: NonIntegratedProductRecord) => void;
onProductWorkspaceDetailStateChange: (
detailState: WorkspaceProductDetailState,
) => void;
onOpenProductPatentLedger: (target: ProductPatentLedgerTarget) => void;
onOpenProductWorkspace: (
product: NonIntegratedProductRecord,
mode: WorkspaceProductMode,
) => void;
onOpenRawMaterialProcessConfig: (materialName: string) => void;
onActivatePendingEntryListWorkspace: (
productDevelopmentKind?: ProductDevelopmentKind,
entryTabOverride?: IntakeEntryTab,
) => void;
onCloseNewProductWorkspace: (tabId: string) => void;
onMaterialLibraryFinishesChange: (finishes: MaterialDTO[]) => void;
};
export function IntakeActiveWorkspaceContent({
activeWorkspaceTab,
fallback,
currentUser,
canManageSpuRuleLibrary,
canManageNodePermissionSettings,
canEditBomLibrary,
canViewProductPatentLedger,
canEditProductPatentLedger,
canViewProductPatentDashboard,
activeProductWorkspaceNeedsFullLoad,
activeProductWorkspaceLoadError,
seriesNameOptions,
existingSeriesNames,
existingSeriesComponentDetailNames,
seriesDraftSkuOptions,
materialLibrarySubstrates,
materialLibraryFinishes,
materialLibraryCategories,
materialLibraryDictionaries,
selectedRawMaterialName,
nonIntegratedProducts,
onRetryProductWorkspaceLoad,
onProductSaved,
onProductWorkspaceDetailStateChange,
onOpenProductPatentLedger,
onOpenProductWorkspace,
onOpenRawMaterialProcessConfig,
onActivatePendingEntryListWorkspace,
onCloseNewProductWorkspace,
onMaterialLibraryFinishesChange,
}: IntakeActiveWorkspaceContentProps) {
if (!activeWorkspaceTab) return <>{fallback}</>;
if (isProductDevelopmentWorkspaceTab(activeWorkspaceTab)) {
return (
<ProductDevelopmentWorkspaceContent
activeWorkspaceTab={activeWorkspaceTab}
fallback={fallback}
currentUser={currentUser}
canManageSpuRuleLibrary={canManageSpuRuleLibrary}
activeProductWorkspaceNeedsFullLoad={activeProductWorkspaceNeedsFullLoad}
activeProductWorkspaceLoadError={activeProductWorkspaceLoadError}
seriesNameOptions={seriesNameOptions}
existingSeriesNames={existingSeriesNames}
existingSeriesComponentDetailNames={existingSeriesComponentDetailNames}
seriesDraftSkuOptions={seriesDraftSkuOptions}
materialLibraryFinishes={materialLibraryFinishes}
onRetryProductWorkspaceLoad={onRetryProductWorkspaceLoad}
onProductSaved={onProductSaved}
onProductWorkspaceDetailStateChange={
onProductWorkspaceDetailStateChange
}
onOpenProductPatentLedger={onOpenProductPatentLedger}
onOpenProductWorkspace={onOpenProductWorkspace}
onOpenRawMaterialProcessConfig={onOpenRawMaterialProcessConfig}
onActivatePendingEntryListWorkspace={
onActivatePendingEntryListWorkspace
}
onCloseNewProductWorkspace={onCloseNewProductWorkspace}
/>
);
}
if (activeWorkspaceTab.kind === "adminNodePermission") {
return canManageNodePermissionSettings ? (
<AdminNodePermissionWorkspace />
) : (
<>{fallback}</>
);
}
if (isBomLibraryWorkspaceTab(activeWorkspaceTab)) {
return (
<BomLibraryWorkspaceContent
activeWorkspaceTab={activeWorkspaceTab}
currentUser={currentUser}
canEditBomLibrary={canEditBomLibrary}
materialLibrarySubstrates={materialLibrarySubstrates}
materialLibraryFinishes={materialLibraryFinishes}
materialLibraryCategories={materialLibraryCategories}
materialLibraryDictionaries={materialLibraryDictionaries}
onMaterialLibraryFinishesChange={onMaterialLibraryFinishesChange}
/>
);
}
if (isRawMaterialLibraryWorkspaceTab(activeWorkspaceTab)) {
return (
<RawMaterialLibraryWorkspaceContent
activeWorkspaceTab={activeWorkspaceTab}
selectedRawMaterialName={selectedRawMaterialName}
/>
);
}
if (isReferenceEstimatorWorkspaceTab(activeWorkspaceTab)) {
return (
<ReferenceEstimatorWorkspaceContent
activeWorkspaceTab={activeWorkspaceTab}
materialLibrarySubstrates={materialLibrarySubstrates}
/>
);
}
if (isProductPatentWorkspaceTab(activeWorkspaceTab)) {
return (
<ProductPatentWorkspaceContent
activeWorkspaceTab={activeWorkspaceTab}
fallback={fallback}
nonIntegratedProducts={nonIntegratedProducts}
currentUser={currentUser}
canViewProductPatentLedger={canViewProductPatentLedger}
canEditProductPatentLedger={canEditProductPatentLedger}
canViewProductPatentDashboard={canViewProductPatentDashboard}
/>
);
}
if (activeWorkspaceTab.kind === "barcodePrint") {
return (
<BarcodePrintWorkspace
products={nonIntegratedProducts}
user={currentUser}
/>
);
}
return <>{fallback}</>;
}

View File

@ -1,6 +1,6 @@
'use client'
import { render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import IntakeClient from './IntakeClient'
@ -23,7 +23,7 @@ import {
clearProductPatentRecordsRuntimeCache,
listProductPatentRecords,
mergeProductPatentRecordsWithProducts,
} from './productPatentStorage'
} from './product-patent/productPatentStorage'
const push = jest.fn()
const mockAoaToSheet = jest.fn((rows: unknown[][]) => ({ rows }))
@ -575,8 +575,36 @@ function createApprovedWorkflowStateBefore(targetCategory: string) {
}
}
function mockFileReaderDataUrl(
resultDataUrl = 'data:image/png;base64,reference-image',
) {
class MockFileReader {
result = resultDataUrl
onload: (() => void) | null = null
readAsDataURL() {
this.onload?.()
}
}
Object.defineProperty(globalThis, 'FileReader', {
configurable: true,
writable: true,
value: MockFileReader,
})
Object.defineProperty(window, 'FileReader', {
configurable: true,
writable: true,
value: MockFileReader,
})
}
describe('IntakeClient', () => {
beforeEach(() => {
cleanup()
document.body
.querySelectorAll('a[download]')
.forEach((node) => node.parentElement?.removeChild(node))
jest.useRealTimers()
clearNonIntegratedProductsRuntimeCache()
clearProductPatentRecordsRuntimeCache()
@ -1857,27 +1885,38 @@ describe('IntakeClient', () => {
it('estimates board material cost from reference image recognition', async () => {
const actor = userEvent.setup()
const referenceImageEstimatePayload = {
success: true,
data: {
boardPieces: [
{ label: '柜体侧板', lengthMm: 1060, widthMm: 240, quantity: 2 },
{ label: '层板/顶底板', lengthMm: 730, widthMm: 240, quantity: 4 },
{ label: '前后拉板', lengthMm: 730, widthMm: 50, quantity: 2 },
{ label: '三合板背板', lengthMm: 1060, widthMm: 830, quantity: 1 },
],
hardwareItems: [{ id: 'wrench', name: '扳手', quantity: 1 }],
summary: '图片识别完成',
},
}
let resolveReferenceImageEstimateJson:
| ((value: typeof referenceImageEstimatePayload) => void)
| null = null
const referenceImageEstimateJson = jest.fn(
() =>
new Promise<typeof referenceImageEstimatePayload>((resolve) => {
resolveReferenceImageEstimateJson = resolve
}),
)
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
data: {
boardPieces: [
{ label: '柜体侧板', lengthMm: 1060, widthMm: 240, quantity: 2 },
{ label: '层板/顶底板', lengthMm: 730, widthMm: 240, quantity: 4 },
{ label: '前后拉板', lengthMm: 730, widthMm: 50, quantity: 2 },
{ label: '三合板背板', lengthMm: 1060, widthMm: 830, quantity: 1 },
],
hardwareItems: [{ id: 'wrench', name: '扳手', quantity: 1 }],
summary: '图片识别完成',
},
}),
json: referenceImageEstimateJson,
} as Response)
Object.defineProperty(global, 'fetch', {
value: fetchMock,
configurable: true,
writable: true,
})
mockFileReaderDataUrl()
renderSelector({
eligibleSkuList: [],
@ -1961,6 +2000,28 @@ describe('IntakeClient', () => {
new File(['reference-image'], 'cabinet.png', { type: 'image/png' }),
)
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/intake/reference-image-estimate',
expect.objectContaining({ method: 'POST' }),
)
})
await waitFor(() => {
expect(referenceImageEstimateJson).toHaveBeenCalled()
})
await act(async () => {
resolveReferenceImageEstimateJson?.(referenceImageEstimatePayload)
await Promise.resolve()
await Promise.resolve()
})
expect(fetchMock).toHaveBeenCalledTimes(1)
await waitFor(
() => {
expect(screen.queryByText('图片识别中...')).not.toBeInTheDocument()
},
{ timeout: 5000 },
)
expect(screen.getByRole('heading', { name: '样包模板明细' })).toBeInTheDocument()
expect(screen.getByText('分类')).toBeInTheDocument()
expect(screen.getByText('封边米数')).toBeInTheDocument()
@ -2008,7 +2069,7 @@ describe('IntakeClient', () => {
expect(screen.getAllByText('15松木').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('同色的三合板')).toBeInTheDocument()
})
}, 10000)
it('defaults to the non-integrated intake view with the pending section expanded', () => {
renderSelector({ eligibleSkuList: [] })

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
"use client";
import type { ComponentProps } from "react";
import { IntakeMainLayout } from "@/app/intake/IntakeMainLayout";
import { IntakePrimaryDomainContent } from "@/app/intake/IntakePrimaryDomainContent";
import {
IntakeRightWorkbenchPanel,
isFullWidthIntakeWorkspace,
} from "@/app/intake/IntakeRightWorkbenchPanel";
import { IntakeWorkspaceContentPanel } from "@/app/intake/IntakeWorkspaceContentPanel";
import type { PlatformMetricItem } from "@/components/platform/types";
export type IntakeClientWorkspaceViewProps = {
metrics: PlatformMetricItem[];
primaryDomain: ComponentProps<typeof IntakePrimaryDomainContent>;
workspace: Omit<
ComponentProps<typeof IntakeWorkspaceContentPanel>,
"mainContent"
>;
rightWorkbench: ComponentProps<typeof IntakeRightWorkbenchPanel>;
};
export function IntakeClientWorkspaceView({
metrics,
primaryDomain,
workspace,
rightWorkbench,
}: IntakeClientWorkspaceViewProps) {
const mainContent = <IntakePrimaryDomainContent {...primaryDomain} />;
const rightContent = isFullWidthIntakeWorkspace(
rightWorkbench.activeWorkspaceTabKind,
) ? null : (
<IntakeRightWorkbenchPanel {...rightWorkbench} />
);
return (
<IntakeMainLayout
metrics={metrics}
leftContent={
<IntakeWorkspaceContentPanel
{...workspace}
mainContent={mainContent}
/>
}
rightContent={rightContent}
/>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import type { ReactNode } from "react";
import { DetailPanel } from "@/app/intake/components/IntakeSharedPanels";
import { PlatformSplitLayout } from "@/components/platform/PlatformSplitLayout";
import type { PlatformMetricItem } from "@/components/platform/types";
export function IntakeEmptyDetail() {
return (
<DetailPanel title="" description="">
<div className="h-full rounded-apple-lg bg-apple-bg-primary" />
</DetailPanel>
);
}
export function IntakeMainLayout({
metrics,
leftContent,
rightContent,
}: {
metrics: PlatformMetricItem[];
leftContent: ReactNode;
rightContent: ReactNode;
}) {
return (
<PlatformSplitLayout
title={undefined}
metrics={metrics}
fitParentHeight
className="!min-h-0 [&>main]:!pl-4 [&>main]:!pr-0 [&>main>section]:!mx-0 [&>main>section]:!h-full [&>main>section]:!max-w-none"
rightPanelClassName="!w-[224px] !min-w-[224px] !max-w-[224px] !overflow-hidden p-3"
leftContent={leftContent}
rightContent={rightContent}
/>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { BarcodePrintDomain } from "@/app/intake/barcode-print/BarcodePrintDomain";
import { BomLibraryDomain } from "@/app/intake/bom-library/BomLibraryDomain";
import type { IntakeSection } from "@/app/intake/intakeWorkspaceTypes";
import { ProductDevelopmentDomain } from "@/app/intake/product-development/ProductDevelopmentDomain";
import { ProductPatentDashboard } from "@/app/intake/product-patent/ProductPatentDomain";
import { RawMaterialLibraryDomain } from "@/app/intake/raw-material-library/RawMaterialLibraryDomain";
import { ReferenceEstimatorDomain } from "@/app/intake/reference-estimator/ReferenceEstimatorDomain";
type ProductDevelopmentDomainProps = ComponentProps<
typeof ProductDevelopmentDomain
>;
export type IntakePrimaryDomainContentProps = {
activeSection: IntakeSection;
fallback: ReactNode;
productDevelopment: Omit<ProductDevelopmentDomainProps, "activeSection">;
canViewBomLibrary: boolean;
bomLibrary: ComponentProps<typeof BomLibraryDomain>;
canViewRawMaterialLibrary: boolean;
rawMaterialLibrary: ComponentProps<typeof RawMaterialLibraryDomain>;
canViewReferenceEstimator: boolean;
referenceEstimator: ComponentProps<typeof ReferenceEstimatorDomain>;
canViewProductPatentLedger: boolean;
productPatent: ComponentProps<typeof ProductPatentDashboard>;
canViewBarcodePrint: boolean;
barcodePrint: ComponentProps<typeof BarcodePrintDomain>;
};
export function IntakePrimaryDomainContent({
activeSection,
fallback,
productDevelopment,
canViewBomLibrary,
bomLibrary,
canViewRawMaterialLibrary,
rawMaterialLibrary,
canViewReferenceEstimator,
referenceEstimator,
canViewProductPatentLedger,
productPatent,
canViewBarcodePrint,
barcodePrint,
}: IntakePrimaryDomainContentProps) {
if (activeSection === "pending" || activeSection === "progress") {
return (
<ProductDevelopmentDomain
activeSection={activeSection}
{...productDevelopment}
/>
);
}
if (activeSection === "bom" && canViewBomLibrary) {
return <BomLibraryDomain {...bomLibrary} />;
}
if (activeSection === "rawMaterialLibrary" && canViewRawMaterialLibrary) {
return <RawMaterialLibraryDomain {...rawMaterialLibrary} />;
}
if (activeSection === "referenceEstimator" && canViewReferenceEstimator) {
return <ReferenceEstimatorDomain {...referenceEstimator} />;
}
if (activeSection === "patents" && canViewProductPatentLedger) {
return <ProductPatentDashboard {...productPatent} />;
}
if (activeSection === "barcodePrint" && canViewBarcodePrint) {
return <BarcodePrintDomain {...barcodePrint} />;
}
return <>{fallback}</>;
}

View File

@ -0,0 +1,39 @@
"use client";
import type { ComponentProps } from "react";
import { FormalIntakeWorkbench } from "@/app/intake/components/FormalIntakeWorkbench";
import type { IntakeWorkspaceTab } from "@/app/intake/intakeWorkspaceTypes";
export type IntakeRightWorkbenchPanelProps = {
activeWorkspaceTabKind: IntakeWorkspaceTab["kind"] | null;
workbench: ComponentProps<typeof FormalIntakeWorkbench>;
};
const FULL_WIDTH_WORKSPACE_KINDS = new Set<IntakeWorkspaceTab["kind"]>([
"nonIntegratedProduct",
"progressDashboard",
"spuRuleLibrary",
"productPatentLedger",
"productPatentDashboard",
"barcodePrint",
]);
export function isFullWidthIntakeWorkspace(
activeWorkspaceTabKind: IntakeWorkspaceTab["kind"] | null,
) {
return (
activeWorkspaceTabKind !== null &&
FULL_WIDTH_WORKSPACE_KINDS.has(activeWorkspaceTabKind)
);
}
export function IntakeRightWorkbenchPanel({
activeWorkspaceTabKind,
workbench,
}: IntakeRightWorkbenchPanelProps) {
if (isFullWidthIntakeWorkspace(activeWorkspaceTabKind)) {
return null;
}
return <FormalIntakeWorkbench {...workbench} />;
}

View File

@ -0,0 +1,40 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { IntakeActiveWorkspaceContent } from "@/app/intake/IntakeActiveWorkspaceContent";
import { IntakeWorkspaceShell } from "@/app/intake/components/IntakeWorkspaceShell";
export type IntakeWorkspaceContentPanelProps = Omit<
ComponentProps<typeof IntakeActiveWorkspaceContent>,
"fallback"
> & {
tabs: Array<{ id: string; label: string }>;
activeTabId: string | null;
mainContent: ReactNode;
onActivate: (tabId: string) => void;
onClose: (tabId: string) => void;
};
export function IntakeWorkspaceContentPanel({
tabs,
activeTabId,
mainContent,
onActivate,
onClose,
...activeContentProps
}: IntakeWorkspaceContentPanelProps) {
return (
<IntakeWorkspaceShell
tabs={tabs}
activeTabId={activeTabId}
activeContent={
<IntakeActiveWorkspaceContent
{...activeContentProps}
fallback={mainContent}
/>
}
onActivate={onActivate}
onClose={onClose}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BarcodePrintCertificateEditDialog } from './BarcodePrintCertificateEditDialog'
describe('BarcodePrintCertificateEditDialog', () => {
it('shows Sofia carton editable fields before printing', async () => {
const actor = userEvent.setup()
const onPrint = jest.fn()
render(
<BarcodePrintCertificateEditDialog
isOpen
editMode="sofiaCarton"
templateTitle="索菲亚外箱贴纸(10cm*7cm)"
initialSofiaCartonForm={{
color: '奶油白',
barcode: 'WPH-SZ015713N1170V01',
productCategory: '餐椅',
lineArtUrl: '',
JZ: '',
MZ: '',
WXTJ: '',
WXCC: '',
XH: '',
ZXSL: '',
CD: '',
}}
onClose={jest.fn()}
onPrint={onPrint}
/>,
)
expect(screen.getByRole('dialog')).toHaveAccessibleName('索菲亚外箱贴纸预览')
expect(screen.getByRole('button', { name: '上传线稿图' })).toBeInTheDocument()
await actor.type(screen.getByLabelText('净重'), '10kg')
await actor.type(screen.getByLabelText('毛重'), '12kg')
await actor.type(screen.getByLabelText('外箱体积'), '0.5')
await actor.type(screen.getByLabelText('外箱尺寸'), '100*70*40')
await actor.type(screen.getByLabelText('箱号'), '1/2')
await actor.type(screen.getByLabelText('装箱数量'), '2')
await actor.type(screen.getByLabelText('产地'), '中国')
await actor.click(screen.getByRole('button', { name: '打印' }))
expect(onPrint).toHaveBeenCalledWith({
sofiaCartonForm: expect.objectContaining({
JZ: '10kg',
MZ: '12kg',
WXTJ: '0.5',
WXCC: '100*70*40',
XH: '1/2',
ZXSL: '2',
CD: '中国',
}),
})
})
it('keeps maker address and phone hidden for common template without supplier', () => {
render(
<BarcodePrintCertificateEditDialog
isOpen
editMode="commonWithoutMaker"
templateTitle="通用横版贴纸-不带供应商(10cm*7cm)"
initialCommonForm={{
productName: '餐椅-奶油白',
barcode: 'WPH-SZ015713N1170V01',
size: '100*70*40',
hangtagMaterial: '科技布',
maker: '常州朴美家居有限公司',
executionStandard: 'GB/T 3324',
level: '合格',
auther: 'B',
address: '常州经济开发区横林镇红赵路62号',
phone: '19941684327',
}}
onClose={jest.fn()}
onPrint={jest.fn()}
/>,
)
expect(screen.getByRole('dialog')).toHaveAccessibleName('通用外箱贴纸预览')
expect(screen.getByLabelText('产品名称')).toHaveValue('餐椅-奶油白')
expect(screen.queryByLabelText('制造商')).not.toBeInTheDocument()
expect(screen.queryByLabelText('地址')).not.toBeInTheDocument()
expect(screen.queryByLabelText('联系电话')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,418 @@
"use client";
import { Printer, Upload, X } from "lucide-react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type {
CertificatePrintOptions,
CertificateSpecialEditMode,
CommonCertificateForm,
SofiaCartonCertificateForm,
} from "./barcodePrintCertificate";
const TEMPLATE_BASE = "/barcode-print/templates";
type BarcodePrintCertificateEditDialogProps = {
isOpen: boolean;
editMode: CertificateSpecialEditMode;
templateTitle: string;
initialSofiaCartonForm?: SofiaCartonCertificateForm;
initialCommonForm?: CommonCertificateForm;
onClose: () => void;
onPrint: (options: CertificatePrintOptions) => void | Promise<void>;
};
function miniInputStyle({
left,
top,
width,
}: {
left: number;
top: number;
width: number;
}) {
return {
position: "absolute",
left,
top,
width,
height: 20,
padding: "0 6px",
border: "1px solid #dcdfe6",
borderRadius: 4,
color: "#606266",
fontSize: 10,
lineHeight: "20px",
outline: "none",
background: "#ffffff",
} satisfies CSSProperties;
}
function commonInputStyle({
left,
top,
}: {
left: number;
top: number;
}) {
return miniInputStyle({ left, top, width: 170 });
}
function titleStyle(top: number) {
return {
position: "absolute",
left: 250,
top,
color: "#000000",
fontSize: 20,
fontWeight: 700,
lineHeight: 1,
} satisfies CSSProperties;
}
function textValue(value: string) {
return value.trim();
}
export function BarcodePrintCertificateEditDialog({
isOpen,
editMode,
templateTitle,
initialSofiaCartonForm,
initialCommonForm,
onClose,
onPrint,
}: BarcodePrintCertificateEditDialogProps) {
const lineArtInputRef = useRef<HTMLInputElement | null>(null);
const ownedLineArtUrlRef = useRef("");
const [sofiaCartonForm, setSofiaCartonForm] = useState(
initialSofiaCartonForm,
);
const [commonForm, setCommonForm] = useState(initialCommonForm);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
useEffect(
() => () => {
if (ownedLineArtUrlRef.current) {
URL.revokeObjectURL(ownedLineArtUrlRef.current);
}
},
[],
);
if (!isOpen) return null;
function updateSofiaField<K extends keyof SofiaCartonCertificateForm>(
key: K,
value: SofiaCartonCertificateForm[K],
) {
setSofiaCartonForm((current) =>
current ? { ...current, [key]: value } : current,
);
}
function updateCommonField<K extends keyof CommonCertificateForm>(
key: K,
value: CommonCertificateForm[K],
) {
setCommonForm((current) => (current ? { ...current, [key]: value } : current));
}
function handleLineArtFile(file: File | null) {
if (!file) return;
if (ownedLineArtUrlRef.current) {
URL.revokeObjectURL(ownedLineArtUrlRef.current);
}
const nextUrl = URL.createObjectURL(file);
ownedLineArtUrlRef.current = nextUrl;
updateSofiaField("lineArtUrl", nextUrl);
}
function submitPrint() {
if (editMode === "sofiaCarton" && sofiaCartonForm) {
void onPrint({ sofiaCartonForm });
return;
}
if (commonForm) {
void onPrint({ commonForm });
}
}
const isSofiaCarton = editMode === "sofiaCarton";
const isCommonWithoutMaker = editMode === "commonWithoutMaker";
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="barcode-print-certificate-edit-title"
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 px-4 py-[8vh]"
onClick={onClose}
>
<div
className="w-[660px] overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-white shadow-[0_30px_80px_rgba(0,0,0,0.3)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-5 py-3">
<h2
id="barcode-print-certificate-edit-title"
className="text-[15px] font-apple-semibold text-[var(--obs-shell-title)]"
>
{isSofiaCarton ? "索菲亚外箱贴纸预览" : "通用外箱贴纸预览"}
</h2>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="overflow-auto px-5 py-5">
{isSofiaCarton && sofiaCartonForm ? (
<div
className="mx-auto"
style={{
position: "relative",
width: 590,
height: 423,
background: `url(${TEMPLATE_BASE}/wxtz_sfy.jpg) no-repeat center center`,
backgroundSize: "cover",
}}
>
<span style={titleStyle(160.5)}>
{[sofiaCartonForm.productCategory, sofiaCartonForm.color]
.map(textValue)
.filter(Boolean)
.join("-")}
</span>
<span style={titleStyle(195.5)}>{sofiaCartonForm.barcode}</span>
<input
ref={lineArtInputRef}
type="file"
accept=".jpg,.png"
className="hidden"
onChange={(event) => {
handleLineArtFile(event.currentTarget.files?.[0] ?? null);
event.currentTarget.value = "";
}}
/>
{sofiaCartonForm.lineArtUrl ? (
<div
style={{
position: "absolute",
left: 5,
top: 114,
display: "flex",
width: 223,
height: 167,
alignItems: "center",
justifyContent: "center",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element -- 本地线稿图需要即时预览。 */}
<img
src={sofiaCartonForm.lineArtUrl}
alt="线稿图"
style={{ display: "block", maxWidth: "80%", maxHeight: "80%" }}
/>
</div>
) : (
<button
type="button"
onClick={() => lineArtInputRef.current?.click()}
className="absolute inline-flex h-6 -translate-x-1/2 items-center gap-1 rounded-[4px] border border-[#8cc5ff] bg-[#ecf5ff] px-2 text-[12px] font-apple-medium text-[#409eff]"
style={{ left: 113, top: 186 }}
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
上传线稿图
</button>
)}
<input
aria-label="颜色"
readOnly
value={sofiaCartonForm.color}
style={miniInputStyle({ left: 314, top: 229, width: 90 })}
/>
<input
aria-label="净重"
placeholder="请填写净重"
value={sofiaCartonForm.JZ}
onChange={(event) => updateSofiaField("JZ", event.target.value)}
style={miniInputStyle({ left: 314, top: 252, width: 90 })}
/>
<input
aria-label="毛重"
placeholder="请填写毛重"
value={sofiaCartonForm.MZ}
onChange={(event) => updateSofiaField("MZ", event.target.value)}
style={miniInputStyle({ left: 476, top: 252, width: 90 })}
/>
<input
aria-label="外箱体积"
placeholder="请填写外箱体积"
value={sofiaCartonForm.WXTJ}
onChange={(event) => updateSofiaField("WXTJ", event.target.value)}
style={miniInputStyle({ left: 314, top: 275, width: 90 })}
/>
<input
aria-label="外箱尺寸"
placeholder="请填写外箱尺寸"
value={sofiaCartonForm.WXCC}
onChange={(event) => updateSofiaField("WXCC", event.target.value)}
style={miniInputStyle({ left: 314, top: 298, width: 90 })}
/>
<input
aria-label="箱号"
placeholder="请填箱号"
value={sofiaCartonForm.XH}
onChange={(event) => updateSofiaField("XH", event.target.value)}
style={miniInputStyle({ left: 314, top: 321, width: 90 })}
/>
<input
aria-label="装箱数量"
placeholder="请填写装箱数量"
value={sofiaCartonForm.ZXSL}
onChange={(event) => updateSofiaField("ZXSL", event.target.value)}
style={miniInputStyle({ left: 314, top: 344, width: 90 })}
/>
<input
aria-label="产地"
placeholder="请填写产地"
value={sofiaCartonForm.CD}
onChange={(event) => updateSofiaField("CD", event.target.value)}
style={miniInputStyle({ left: 314, top: 367, width: 90 })}
/>
</div>
) : null}
{!isSofiaCarton && commonForm ? (
<div
className="mx-auto"
style={{
position: "relative",
width: 590,
height: 413,
background: `url(${TEMPLATE_BASE}/${
isCommonWithoutMaker ? "hgz_easy.jpg" : "hgz.jpg"
}) no-repeat center center`,
backgroundSize: "cover",
}}
>
<input
aria-label="产品名称"
placeholder="请填写产品名称"
value={commonForm.productName}
onChange={(event) => updateCommonField("productName", event.target.value)}
style={commonInputStyle({ left: 112, top: 154 })}
/>
<input
aria-label="条码"
placeholder="请填写条码"
value={commonForm.barcode}
onChange={(event) => updateCommonField("barcode", event.target.value)}
style={commonInputStyle({ left: 112, top: 188 })}
/>
<input
aria-label="产品规格"
placeholder="请填写产品规格"
value={commonForm.size}
onChange={(event) => updateCommonField("size", event.target.value)}
style={commonInputStyle({ left: 112, top: 222 })}
/>
<input
aria-label="主要材质"
placeholder="请填写主要材质"
value={commonForm.hangtagMaterial}
onChange={(event) => updateCommonField("hangtagMaterial", event.target.value)}
style={commonInputStyle({ left: 112, top: 256 })}
/>
{!isCommonWithoutMaker ? (
<input
aria-label="制造商"
placeholder="请填写制造商"
value={commonForm.maker}
onChange={(event) => updateCommonField("maker", event.target.value)}
style={commonInputStyle({ left: 112, top: 290 })}
/>
) : null}
<input
aria-label="执行标准"
placeholder="请填写执行标准"
value={commonForm.executionStandard}
onChange={(event) =>
updateCommonField("executionStandard", event.target.value)
}
style={commonInputStyle({ left: 377, top: 154 })}
/>
<input
aria-label="质量等级"
placeholder="请填写质量等级"
value={commonForm.level}
onChange={(event) => updateCommonField("level", event.target.value)}
style={commonInputStyle({ left: 377, top: 188 })}
/>
<input
aria-label="检验员"
placeholder="请填写检验员"
value={commonForm.auther}
onChange={(event) => updateCommonField("auther", event.target.value)}
style={commonInputStyle({ left: 377, top: 222 })}
/>
{!isCommonWithoutMaker ? (
<>
<input
aria-label="地址"
placeholder="地址"
value={commonForm.address}
onChange={(event) => updateCommonField("address", event.target.value)}
style={commonInputStyle({ left: 377, top: 256 })}
/>
<input
aria-label="联系电话"
placeholder="联系电话"
value={commonForm.phone}
onChange={(event) => updateCommonField("phone", event.target.value)}
style={commonInputStyle({ left: 377, top: 290 })}
/>
</>
) : null}
</div>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
<button
type="button"
onClick={onClose}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
取消
</button>
<button
type="button"
onClick={submitPrint}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
title={templateTitle}
>
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
打印
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,85 @@
"use client";
import { Printer, X } from "lucide-react";
export type BarcodePrintCertificatePreviewDialogProps = {
isOpen: boolean;
title: string;
pageSize: string;
imageDataUrl: string;
onClose: () => void;
onPrint: () => void;
};
export function BarcodePrintCertificatePreviewDialog({
isOpen,
title,
pageSize: _pageSize,
imageDataUrl,
onClose,
onPrint,
}: BarcodePrintCertificatePreviewDialogProps) {
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="barcode-print-certificate-preview-title"
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 px-4 py-[8vh]"
onClick={onClose}
>
<div
className="flex w-[min(660px,96vw)] flex-col overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-white shadow-[0_30px_80px_rgba(0,0,0,0.3)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-5 py-3">
<h2
id="barcode-print-certificate-preview-title"
className="text-[15px] font-apple-semibold text-[var(--obs-shell-title)]"
title={title}
>
合格证预览
</h2>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="max-h-[72vh] overflow-auto bg-white">
<div className="flex justify-center">
{/* eslint-disable-next-line @next/next/no-img-element -- 打印预览使用 html2canvas 生成的 data URL。 */}
<img
src={imageDataUrl}
alt={title}
className="block w-full select-none"
/>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
<button
type="button"
onClick={onPrint}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
>
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
<span>打印</span>
</button>
<button
type="button"
onClick={onClose}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
取消
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,118 @@
"use client";
import type { ReactNode } from "react";
const FILTER_FIELD_CLASS =
"flex h-7 shrink-0 items-center gap-1.5 text-[12px] text-[var(--obs-shell-muted)]";
const FILTER_LABEL_CLASS =
"shrink-0 whitespace-nowrap font-apple-semibold text-[var(--obs-shell-title)]";
const FILTER_CONTROL_CLASS =
"h-7 w-[132px] min-w-[132px] rounded-[5px] border border-[var(--obs-shell-border)] bg-white px-2 text-[11px] font-apple-medium text-[var(--obs-shell-title)] outline-none transition placeholder:text-[var(--obs-shell-subtle)] focus:border-[var(--obs-shell-title)]";
export function BarcodePrintInput({
label,
value,
placeholder,
onChange,
}: {
label: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
}) {
return (
<label className={FILTER_FIELD_CLASS}>
<span className={FILTER_LABEL_CLASS}>{label}</span>
<input
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={FILTER_CONTROL_CLASS}
/>
</label>
);
}
export function BarcodePrintSelect({
label,
value,
placeholder,
options,
onChange,
}: {
label: string;
value: string;
placeholder: string;
options: string[];
onChange: (value: string) => void;
}) {
return (
<label className={FILTER_FIELD_CLASS}>
<span className={FILTER_LABEL_CLASS}>{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className={FILTER_CONTROL_CLASS}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
);
}
export function BarcodePrintDateInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className={FILTER_FIELD_CLASS}>
<span className={FILTER_LABEL_CLASS}>{label}</span>
<input
type="date"
value={value}
onChange={(event) => onChange(event.target.value)}
className={FILTER_CONTROL_CLASS}
/>
</label>
);
}
export function BarcodePrintActionButton({
disabled = false,
children,
intent = "default",
onClick,
}: {
disabled?: boolean;
children: ReactNode;
intent?: "default" | "primary" | "danger";
onClick: () => void;
}) {
const className =
intent === "primary"
? "border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] text-[var(--obs-shell-action-text)] hover:bg-[var(--obs-shell-action-hover-bg)]"
: intent === "danger"
? "border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] text-[var(--obs-shell-danger)] hover:bg-[rgba(179,69,61,0.11)]"
: "border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-control-text)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]";
return (
<button
disabled={disabled}
type="button"
onClick={onClick}
className={`inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[6px] border px-2.5 text-[11px] font-apple-semibold transition disabled:cursor-not-allowed disabled:opacity-55 ${className}`}
>
{children}
</button>
);
}

View File

@ -0,0 +1,10 @@
"use client";
import type { ComponentProps } from "react";
import { BarcodePrintWorkspace } from "@/app/intake/barcode-print/BarcodePrintWorkspace";
export function BarcodePrintDomain(
props: ComponentProps<typeof BarcodePrintWorkspace>,
) {
return <BarcodePrintWorkspace {...props} />;
}

View File

@ -0,0 +1,187 @@
"use client";
import { Download, RotateCcw, Search, Upload } from "lucide-react";
import type { BarcodePrintImportKind } from "./barcodePrintImport";
import {
BarcodePrintActionButton,
BarcodePrintDateInput,
BarcodePrintInput,
BarcodePrintSelect,
} from "./BarcodePrintControls";
import type { BarcodePrintFilterState } from "./barcodePrintTypes";
const CLODOP_DOWNLOAD_URL = "https://www.lodop.net/download.html";
function isCLodopStatusMessage(message: string) {
return /C-?Lodop|LODOP/i.test(message);
}
export function BarcodePrintHeader({
draftFilters,
productCategoryOptions,
salesCategoryOptions,
statusMessage,
onBarcodeImport,
actionDisabled = false,
onBrandSkuImport,
onDownloadTemplate,
onFilterChange,
onSearch,
onReset,
}: {
draftFilters: BarcodePrintFilterState;
productCategoryOptions: string[];
salesCategoryOptions: string[];
statusMessage: string;
actionDisabled?: boolean;
onBarcodeImport: () => void;
onBrandSkuImport: () => void;
onDownloadTemplate: (kind: BarcodePrintImportKind) => void;
onFilterChange: <K extends keyof BarcodePrintFilterState>(
key: K,
value: BarcodePrintFilterState[K],
) => void;
onSearch: () => void;
onReset: () => void;
}) {
return (
<div className="shrink-0 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-3 py-2.5">
<div className="flex flex-wrap items-center justify-end gap-2">
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBarcodeImport}>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
<span>条码导入</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton onClick={() => onDownloadTemplate("barcode")}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>条码导入模板下载</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBrandSkuImport}>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton onClick={() => onDownloadTemplate("brandSku")}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入模板下载</span>
</BarcodePrintActionButton>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<BarcodePrintInput
label="SPU"
value={draftFilters.spu}
placeholder="请输入SPU"
onChange={(value) => onFilterChange("spu", value)}
/>
<BarcodePrintInput
label="型号"
value={draftFilters.model}
placeholder="请输入型号"
onChange={(value) => onFilterChange("model", value)}
/>
<BarcodePrintInput
label="货号"
value={draftFilters.skuCode}
placeholder="请输入货号"
onChange={(value) => onFilterChange("skuCode", value)}
/>
<BarcodePrintInput
label="品牌货号"
value={draftFilters.brandSkuCode}
placeholder="请输入品牌货号"
onChange={(value) => onFilterChange("brandSkuCode", value)}
/>
<BarcodePrintInput
label="条码"
value={draftFilters.barcode}
placeholder="请输入条码"
onChange={(value) => onFilterChange("barcode", value)}
/>
<BarcodePrintInput
label="运营负责人"
value={draftFilters.operationsOwner}
placeholder="请输入关键词进行过滤"
onChange={(value) => onFilterChange("operationsOwner", value)}
/>
<BarcodePrintInput
label="SPU负责人"
value={draftFilters.spuOwner}
placeholder="请输入关键词进行过滤"
onChange={(value) => onFilterChange("spuOwner", value)}
/>
<BarcodePrintInput
label="商品策划"
value={draftFilters.productPlanning}
placeholder="请输入关键词进行过滤"
onChange={(value) => onFilterChange("productPlanning", value)}
/>
<BarcodePrintInput
label="采购负责人"
value={draftFilters.purchaseOwner}
placeholder="请输入关键词进行过滤"
onChange={(value) => onFilterChange("purchaseOwner", value)}
/>
<BarcodePrintSelect
label="商品类目"
value={draftFilters.productCategory}
placeholder="请选择商品类目"
options={productCategoryOptions}
onChange={(value) => onFilterChange("productCategory", value)}
/>
<BarcodePrintSelect
label="销售类目"
value={draftFilters.salesCategory}
placeholder="请选择销售类目"
options={salesCategoryOptions}
onChange={(value) => onFilterChange("salesCategory", value)}
/>
<BarcodePrintInput
label="供应商名称"
value={draftFilters.supplierName}
placeholder="请输入供应商名称"
onChange={(value) => onFilterChange("supplierName", value)}
/>
<BarcodePrintDateInput
label="开始时间"
value={draftFilters.createdFrom}
onChange={(value) => onFilterChange("createdFrom", value)}
/>
<BarcodePrintDateInput
label="结束时间"
value={draftFilters.createdTo}
onChange={(value) => onFilterChange("createdTo", value)}
/>
<button
type="button"
onClick={onSearch}
className="inline-flex h-7 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-3 text-[11px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
>
<Search className="h-3.5 w-3.5" aria-hidden="true" />
<span>查询</span>
</button>
<button
type="button"
onClick={onReset}
className="inline-flex h-7 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-3 text-[11px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
<span>重置</span>
</button>
</div>
{statusMessage ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] font-apple-medium text-[var(--obs-shell-muted)]">
<span>{statusMessage}</span>
{isCLodopStatusMessage(statusMessage) ? (
<a
href={CLODOP_DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
className="inline-flex h-6 items-center rounded-[5px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-2 text-[11px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
>
下载 C-Lodop 插件
</a>
) : null}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { BARCODE_PRINT_PAGE_SIZE } from "./barcodePrintTableConfig";
export function BarcodePrintPagination({
currentPage,
totalPages,
totalRows,
onPageChange,
}: {
currentPage: number;
totalPages: number;
totalRows: number;
onPageChange: (page: number) => void;
}) {
return (
<div className="flex h-10 shrink-0 items-center justify-center border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-3 text-[11px] font-apple-medium text-[var(--obs-shell-muted)]">
<button
type="button"
disabled={currentPage <= 1}
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
className="mr-3 rounded-[5px] px-3 py-1.5 transition hover:bg-[var(--obs-shell-hover-bg)] disabled:cursor-not-allowed disabled:text-[var(--obs-shell-subtle)] disabled:hover:bg-transparent"
>
上一页
</button>
<div className="flex items-center gap-2">
<span>共 {totalRows} 条</span>
<span>第</span>
<span className="inline-flex h-7 min-w-10 items-center justify-center rounded-[5px] border border-[var(--obs-shell-border)] bg-white px-2 text-[var(--obs-shell-title)]">
{currentPage}
</span>
<span>/ {totalPages} 页</span>
<span>每页 {BARCODE_PRINT_PAGE_SIZE} 条</span>
</div>
<button
type="button"
disabled={currentPage >= totalPages}
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
className="ml-3 rounded-[5px] px-3 py-1.5 transition hover:bg-[var(--obs-shell-hover-bg)] disabled:cursor-not-allowed disabled:text-[var(--obs-shell-subtle)] disabled:hover:bg-transparent"
>
下一页
</button>
</div>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import type { ImportProgressState } from "./barcodePrintTypes";
export function BarcodePrintProgressToast({
progress,
}: {
progress: ImportProgressState;
}) {
if (!progress.active) return null;
return (
<div className="fixed right-5 bottom-5 z-50 w-[280px] rounded-[8px] border border-[var(--obs-shell-border)] bg-white px-3 py-2.5 text-[11px] shadow-[0_12px_30px_rgba(17,19,21,0.16)]">
<div className="flex items-center justify-between gap-3 font-apple-semibold text-[var(--obs-shell-title)]">
<span className="truncate">{progress.label}</span>
<span>{progress.percent}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--obs-shell-muted-bg)]">
<div
className={`h-full rounded-full transition-all duration-300 ${
progress.status === "error"
? "bg-[var(--obs-shell-danger)]"
: "bg-[var(--obs-shell-action-bg)]"
}`}
style={{ width: `${progress.percent}%` }}
/>
</div>
<div
className={`mt-2 line-clamp-2 font-apple-medium ${
progress.status === "error"
? "text-[var(--obs-shell-danger)]"
: "text-[var(--obs-shell-muted)]"
}`}
>
{progress.message}
</div>
</div>
);
}

View File

@ -0,0 +1,236 @@
"use client";
import { Barcode, FileText, Trash2 } from "lucide-react";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { BarcodePrintRow } from "./barcodePrintRows";
import {
BARCODE_PRINT_COLUMNS,
BARCODE_PRINT_PAGE_SIZE,
OPERATION_COLUMN_WIDTH,
SELECTION_COLUMN_WIDTH,
} from "./barcodePrintTableConfig";
import type {
BarcodePrintColumnKey,
BarcodePrintColumnWidths,
} from "./barcodePrintTypes";
function formatCell(value: string) {
return value.trim() || "-";
}
export function BarcodePrintTable({
pagedRows,
filteredRowCount,
currentPage,
selectedRowIds,
allVisibleSelected,
tableWidth,
rowNumberColumnWidth,
columnWidths,
onToggleVisibleRows,
emptyMessage = "当前没有符合条件的条码资料。",
onToggleRow,
onStartRowNumberColumnResize,
onStartDataColumnResize,
onBarcodePrint,
onCertificatePrint,
onDeleteImportedData,
}: {
pagedRows: BarcodePrintRow[];
filteredRowCount: number;
currentPage: number;
selectedRowIds: Set<string>;
allVisibleSelected: boolean;
tableWidth: number;
rowNumberColumnWidth: number;
columnWidths: BarcodePrintColumnWidths;
onToggleVisibleRows: () => void;
emptyMessage?: string;
onToggleRow: (rowId: string) => void;
onStartRowNumberColumnResize: (
event: ReactMouseEvent<HTMLButtonElement>,
) => void;
onStartDataColumnResize: (
event: ReactMouseEvent<HTMLButtonElement>,
key: BarcodePrintColumnKey,
) => void;
onBarcodePrint: (row: BarcodePrintRow) => void;
onCertificatePrint: (row: BarcodePrintRow) => void;
onDeleteImportedData: (importRecordIds: string[] | undefined) => void;
}) {
return (
<div className="min-h-0 flex-1 overflow-auto bg-white">
<table
className="table-fixed border-separate border-spacing-0 text-left text-[11px] whitespace-nowrap"
aria-label="条码打印列表"
style={{ width: tableWidth, minWidth: tableWidth }}
>
<thead className="sticky top-0 z-20 bg-[var(--obs-shell-surface-bg)] text-[var(--obs-shell-muted)]">
<tr>
<th
className="border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5"
style={{
width: SELECTION_COLUMN_WIDTH,
minWidth: SELECTION_COLUMN_WIDTH,
}}
>
<input
type="checkbox"
aria-label="选择当前筛选结果"
checked={allVisibleSelected}
onChange={onToggleVisibleRows}
className="h-3.5 w-3.5"
/>
</th>
<th
className="relative border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 pr-3 font-apple-semibold"
style={{
width: rowNumberColumnWidth,
minWidth: rowNumberColumnWidth,
}}
>
<span className="block truncate">序号</span>
<button
type="button"
aria-label="调整序号列宽"
title="拖动调整列宽"
onMouseDown={onStartRowNumberColumnResize}
className="absolute top-0 right-[-4px] z-40 h-full w-2 cursor-col-resize touch-none border-r border-transparent transition hover:border-[var(--obs-shell-title)]"
/>
</th>
{BARCODE_PRINT_COLUMNS.map((column) => (
<th
key={column.key}
className="relative border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 pr-3 font-apple-semibold whitespace-nowrap"
style={{
width: columnWidths[column.key],
minWidth: columnWidths[column.key],
}}
>
<span className="block truncate">{column.label}</span>
<button
type="button"
aria-label={`调整${column.label}列宽`}
title="拖动调整列宽"
onMouseDown={(event) =>
onStartDataColumnResize(event, column.key)
}
className="absolute top-0 right-[-4px] z-40 h-full w-2 cursor-col-resize touch-none border-r border-transparent transition hover:border-[var(--obs-shell-title)]"
/>
</th>
))}
<th
className="sticky right-0 z-30 border-b border-l border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] px-2 py-1.5 text-center font-apple-semibold shadow-[-10px_0_14px_-14px_rgba(17,19,21,0.28)]"
style={{
width: OPERATION_COLUMN_WIDTH,
minWidth: OPERATION_COLUMN_WIDTH,
}}
>
操作
</th>
</tr>
</thead>
<tbody>
{filteredRowCount === 0 ? (
<tr>
<td
colSpan={BARCODE_PRINT_COLUMNS.length + 3}
className="px-6 py-14 text-center text-sm font-apple-medium text-[var(--obs-shell-muted)]"
>
{emptyMessage}
</td>
</tr>
) : (
pagedRows.map((row, index) => (
<tr
key={row.id}
className="odd:bg-white even:bg-[var(--obs-shell-surface-bg)]"
>
<td
className="border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5"
style={{
width: SELECTION_COLUMN_WIDTH,
minWidth: SELECTION_COLUMN_WIDTH,
}}
>
<input
type="checkbox"
aria-label={`选择第${
(currentPage - 1) * BARCODE_PRINT_PAGE_SIZE + index + 1
}行`}
checked={selectedRowIds.has(row.id)}
onChange={() => onToggleRow(row.id)}
className="h-3.5 w-3.5"
/>
</td>
<td
className="border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 text-[var(--obs-shell-muted)]"
style={{
width: rowNumberColumnWidth,
minWidth: rowNumberColumnWidth,
}}
>
{(currentPage - 1) * BARCODE_PRINT_PAGE_SIZE + index + 1}
</td>
{BARCODE_PRINT_COLUMNS.map((column) => (
<td
key={column.key}
className="max-w-0 border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 text-[var(--obs-shell-title)] whitespace-nowrap"
style={{
width: columnWidths[column.key],
minWidth: columnWidths[column.key],
}}
title={formatCell(String(row[column.key]))}
>
<span className="block truncate">
{formatCell(String(row[column.key]))}
</span>
</td>
))}
<td
className="sticky right-0 z-10 border-b border-l border-[var(--obs-shell-border)] bg-inherit px-1.5 py-1.5 text-center shadow-[-10px_0_14px_-14px_rgba(17,19,21,0.2)]"
style={{
width: OPERATION_COLUMN_WIDTH,
minWidth: OPERATION_COLUMN_WIDTH,
}}
>
<div className="flex justify-center gap-1">
<button
type="button"
aria-label="条码打印"
title="条码打印"
onClick={() => onBarcodePrint(row)}
className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
>
<Barcode className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
aria-label="合格证打印"
title="合格证打印"
onClick={() => onCertificatePrint(row)}
className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{row.importRecordIds?.length ? (
<button
type="button"
aria-label="删除导入数据"
title="删除导入数据"
onClick={() => onDeleteImportedData(row.importRecordIds)}
className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] border border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] text-[var(--obs-shell-danger)] transition hover:bg-[rgba(179,69,61,0.11)]"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BarcodePrintTemplateDialog } from './BarcodePrintTemplateDialog'
describe('BarcodePrintTemplateDialog', () => {
it('submits the selected barcode template', async () => {
const actor = userEvent.setup()
const onSubmit = jest.fn()
render(
<BarcodePrintTemplateDialog
isOpen
mode="barcode"
templates={[
{ title: '条码打印_第一套编码(数字开头)&一次性采买条码' },
{ title: '顾家' },
]}
rowSummary="CS0002 / CS000201A / WPH-SZ015713N1170V01"
onClose={jest.fn()}
onSubmit={onSubmit}
/>,
)
await actor.selectOptions(screen.getByLabelText('打印模板'), '顾家')
await actor.type(screen.getByPlaceholderText('可不填'), 'PC01')
await actor.click(screen.getByRole('button', { name: '确定' }))
expect(onSubmit).toHaveBeenCalledWith({
templateTitle: '顾家',
batchNumber: 'PC01',
editMode: undefined,
})
})
it('uses certificate template wording', () => {
render(
<BarcodePrintTemplateDialog
isOpen
mode="certificate"
templates={[{ title: '朴美横版贴纸(10cm*7cm)' }]}
rowSummary="CS0002 / CS000201A"
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
)
expect(screen.getByRole('dialog')).toHaveAccessibleName('请选择合格证模板')
expect(screen.getByText('朴美横版贴纸(10cm*7cm)')).toBeInTheDocument()
})
it('passes edit mode for common certificate supplier template', async () => {
const actor = userEvent.setup()
const onSubmit = jest.fn()
render(
<BarcodePrintTemplateDialog
isOpen
mode="certificate"
templates={[
{ title: '朴美横版贴纸(10cm*7cm)' },
{
title: '通用横版贴纸-带供应商(10cm*7cm)',
editMode: 'commonWithMaker',
},
]}
rowSummary="CS0002 / CS000201A"
onClose={jest.fn()}
onSubmit={onSubmit}
/>,
)
await actor.selectOptions(
screen.getByLabelText('打印模板'),
'通用横版贴纸-带供应商(10cm*7cm)',
)
expect(screen.queryByPlaceholderText('可不填')).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '确定' }))
expect(onSubmit).toHaveBeenCalledWith({
templateTitle: '通用横版贴纸-带供应商(10cm*7cm)',
batchNumber: '',
editMode: 'commonWithMaker',
})
})
})

View File

@ -0,0 +1,164 @@
"use client";
import { X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { CertificateSpecialEditMode } from "./barcodePrintCertificate";
type PrintTemplateOption = {
title: string;
editMode?: CertificateSpecialEditMode;
};
export type BarcodePrintTemplateDialogMode = "barcode" | "certificate";
export type BarcodePrintTemplateDialogSubmit = {
templateTitle: string;
batchNumber?: string;
editMode?: CertificateSpecialEditMode;
};
export function BarcodePrintTemplateDialog({
isOpen,
mode,
templates,
rowSummary,
onClose,
onSubmit,
}: {
isOpen: boolean;
mode: BarcodePrintTemplateDialogMode;
templates: PrintTemplateOption[];
rowSummary: string;
onClose: () => void;
onSubmit: (value: BarcodePrintTemplateDialogSubmit) => void;
}) {
const defaultTemplateTitle = useMemo(
() => templates[0]?.title ?? "",
[templates],
);
const [templateTitle, setTemplateTitle] = useState(defaultTemplateTitle);
const [batchNumber, setBatchNumber] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const title = mode === "barcode" ? "请选择打印模板" : "请选择合格证模板";
const selectedTemplate = templates.find((template) => template.title === templateTitle);
const showBatchNumber = mode === "barcode" && templateTitle === "顾家";
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
function submit() {
if (!templateTitle) {
setErrorMessage("请选择打印模板。");
return;
}
onSubmit({
templateTitle,
batchNumber,
editMode: selectedTemplate?.editMode,
});
}
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="barcode-print-template-dialog-title"
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={onClose}
>
<div
className="flex h-full w-[min(600px,100vw)] flex-col border-l border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[-24px_0_48px_rgba(17,19,21,0.18)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex h-14 items-center justify-between border-b border-[var(--obs-shell-border)] px-5">
<h2
id="barcode-print-template-dialog-title"
className="text-[15px] font-apple-semibold"
>
{title}
</h2>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-5 py-5">
<div className="rounded-[6px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] px-3 py-2 text-[12px] font-apple-medium text-[var(--obs-shell-muted)]">
{rowSummary}
</div>
<label className="block space-y-1.5 text-[12px] font-apple-semibold text-[var(--obs-shell-title)]">
<span>打印模板</span>
<select
autoFocus
value={templateTitle}
onChange={(event) => {
setTemplateTitle(event.target.value);
setErrorMessage("");
}}
className="h-9 w-full rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-2.5 text-[12px] font-apple-medium text-[var(--obs-shell-title)] outline-none transition focus:border-[var(--obs-shell-title)]"
>
<option value="">请选择打印模板,目前暂不支持模板配置</option>
{templates.map((template) => (
<option key={template.title} value={template.title}>
{template.title}
</option>
))}
</select>
</label>
{showBatchNumber ? (
<label className="block space-y-1.5 text-[12px] font-apple-semibold text-[var(--obs-shell-title)]">
<span>批次</span>
<input
value={batchNumber}
placeholder="可不填"
onChange={(event) => setBatchNumber(event.target.value)}
className="h-9 w-full rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-2.5 text-[12px] font-apple-medium text-[var(--obs-shell-title)] outline-none transition placeholder:text-[var(--obs-shell-subtle)] focus:border-[var(--obs-shell-title)]"
/>
</label>
) : null}
{errorMessage ? (
<div className="rounded-[6px] border border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] px-3 py-2 text-[12px] font-apple-medium text-[var(--obs-shell-danger)]">
{errorMessage}
</div>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
<button
type="button"
onClick={submit}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
>
确定
</button>
<button
type="button"
onClick={onClose}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
取消
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import { Barcode } from "lucide-react";
import {
FORMAL_SIDEBAR_SECTION_ICON_CLASS,
FormalSidebarIcon,
} from "@/app/intake/components/FormalSidebarIcon";
import type { IntakeSection } from "@/app/intake/intakeWorkspaceTypes";
import { BARCODE_PRINT_LABEL } from "@/app/intake/barcode-print/barcodePrintConfig";
export function BarcodePrintWorkbenchButton({
activeSection,
canViewBarcodePrint,
onOpenBarcodePrintWorkspace,
}: {
activeSection: IntakeSection;
canViewBarcodePrint: boolean;
onOpenBarcodePrintWorkspace: () => void;
}) {
if (!canViewBarcodePrint) return null;
return (
<button
type="button"
onClick={onOpenBarcodePrintWorkspace}
className={`flex w-full items-center gap-2 rounded-apple-xl border px-3.5 py-3 text-left shadow-apple-xs transition hover:bg-apple-bg-secondary/50 ${
activeSection === "barcodePrint"
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-primary text-apple-label-primary"
}`}
>
<span className="flex h-6.5 w-6.5 shrink-0 items-center justify-center rounded-apple-md bg-apple-bg-tertiary text-apple-blue">
<FormalSidebarIcon
icon={Barcode}
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-apple-semibold leading-4">
{BARCODE_PRINT_LABEL}
</span>
</button>
);
}

View File

@ -0,0 +1,688 @@
"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import {
BARCODE_PRINT_IMPORT_ACCEPT,
downloadBarcodePrintImportTemplate,
mergeBarcodePrintImportRows,
parseBarcodePrintImportFile,
upsertBarcodePrintImportRecords,
type BarcodePrintImportKind,
type BarcodePrintImportRecord,
} from "./barcodePrintImport";
import {
buildBarcodePrintRows,
type BarcodePrintRow,
} from "./barcodePrintRows";
import { BarcodePrintHeader } from "./BarcodePrintHeader";
import { BarcodePrintPagination } from "./BarcodePrintPagination";
import { BarcodePrintProgressToast } from "./BarcodePrintProgressToast";
import { BarcodePrintTable } from "./BarcodePrintTable";
import { BarcodePrintCertificateEditDialog } from "./BarcodePrintCertificateEditDialog";
import { BarcodePrintCertificatePreviewDialog } from "./BarcodePrintCertificatePreviewDialog";
import {
BarcodePrintTemplateDialog,
type BarcodePrintTemplateDialogSubmit,
} from "./BarcodePrintTemplateDialog";
import {
CERTIFICATE_PRINT_TEMPLATE_OPTIONS,
buildCommonCertificateForm,
buildSofiaCartonCertificateForm,
createCertificatePrintPreview,
printCertificatePreview,
type CertificatePrintOptions,
type CertificatePrintPreview,
type CertificateSpecialEditMode,
} from "./barcodePrintCertificate";
import {
EMPTY_FILTERS,
getBarcodePrintUniqueOptions,
rowMatchesBarcodePrintFilters,
} from "./barcodePrintFiltering";
import {
BARCODE_PRINT_COLUMNS,
BARCODE_PRINT_PAGE_SIZE,
OPERATION_COLUMN_WIDTH,
ROW_NUMBER_COLUMN_MIN_WIDTH,
ROW_NUMBER_COLUMN_WIDTH,
SELECTION_COLUMN_WIDTH,
buildInitialBarcodePrintColumnWidths,
getBarcodePrintColumnMinWidth,
} from "./barcodePrintTableConfig";
import {
EMPTY_IMPORT_PROGRESS,
waitForProgressFrame,
} from "./barcodePrintProgressState";
import {
loadBarcodePrintImportRecords,
saveBarcodePrintImportRecords,
} from "./barcodePrintImportStorage";
import { refreshBarcodePrintProducts } from "./barcodePrintProducts";
import {
BARCODE_PRINT_TEMPLATE_OPTIONS,
printBarcodeRow,
} from "./barcodePrintLodop";
import type {
BarcodePrintColumnKey,
BarcodePrintFilterState,
} from "./barcodePrintTypes";
type BarcodePrintWorkspaceUser = {
id: string;
businessRoles?: string[];
supplierCode?: string | null;
};
type PendingPrintJob = {
mode: "barcode" | "certificate";
rowId: string;
};
type PendingCertificateEdit = {
rowId: string;
templateTitle: string;
editMode: CertificateSpecialEditMode;
};
const UNBOUND_SUPPLIER_MESSAGE =
"当前供应商账号未绑定供应商编码,请联系管理员绑定后查看条码打印数据。";
export function BarcodePrintWorkspace({
products,
user,
}: {
products: NonIntegratedProductRecord[];
user: BarcodePrintWorkspaceUser;
}) {
const barcodeImportInputRef = useRef<HTMLInputElement | null>(null);
const brandSkuImportInputRef = useRef<HTMLInputElement | null>(null);
const [latestProducts, setLatestProducts] = useState(products);
const userId = user.id;
const isSupplierUser = user.businessRoles?.includes("SUPPLIER") ?? false;
const supplierCode = user.supplierCode?.trim().toUpperCase() ?? "";
const isUnboundSupplier = isSupplierUser && !supplierCode;
const baseRows = useMemo(
() => buildBarcodePrintRows(isUnboundSupplier ? [] : latestProducts),
[isUnboundSupplier, latestProducts],
);
const [importRecords, setImportRecords] = useState<BarcodePrintImportRecord[]>(
[],
);
const [importRecordsLoaded, setImportRecordsLoaded] = useState(false);
const [draftFilters, setDraftFilters] =
useState<BarcodePrintFilterState>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] =
useState<BarcodePrintFilterState>(EMPTY_FILTERS);
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(
() => new Set(),
);
const [currentPage, setCurrentPage] = useState(1);
const [statusMessage, setStatusMessage] = useState("");
const [importProgress, setImportProgress] =
useState(EMPTY_IMPORT_PROGRESS);
const [rowNumberColumnWidth, setRowNumberColumnWidth] = useState(
ROW_NUMBER_COLUMN_WIDTH,
);
const [columnWidths, setColumnWidths] = useState(
() => buildInitialBarcodePrintColumnWidths(),
);
const [pendingPrintJob, setPendingPrintJob] =
useState<PendingPrintJob | null>(null);
const [pendingCertificateEdit, setPendingCertificateEdit] =
useState<PendingCertificateEdit | null>(null);
const [certificatePreview, setCertificatePreview] =
useState<CertificatePrintPreview | null>(null);
const rows = useMemo(
() => mergeBarcodePrintImportRows(baseRows, importRecords),
[baseRows, importRecords],
);
const tableWidth = useMemo(
() =>
SELECTION_COLUMN_WIDTH +
rowNumberColumnWidth +
OPERATION_COLUMN_WIDTH +
BARCODE_PRINT_COLUMNS.reduce(
(totalWidth, column) => totalWidth + columnWidths[column.key],
0,
),
[columnWidths, rowNumberColumnWidth],
);
const productCategoryOptions = useMemo(
() => getBarcodePrintUniqueOptions(rows, "productCategory"),
[rows],
);
const salesCategoryOptions = useMemo(
() => getBarcodePrintUniqueOptions(rows, "salesCategory"),
[rows],
);
const filteredRows = useMemo(
() => rows.filter((row) => rowMatchesBarcodePrintFilters(row, appliedFilters)),
[appliedFilters, rows],
);
const totalPages = Math.max(
1,
Math.ceil(filteredRows.length / BARCODE_PRINT_PAGE_SIZE),
);
const pagedRows = useMemo(() => {
const safePage = Math.min(currentPage, totalPages);
const startIndex = (safePage - 1) * BARCODE_PRINT_PAGE_SIZE;
return filteredRows.slice(startIndex, startIndex + BARCODE_PRINT_PAGE_SIZE);
}, [currentPage, filteredRows, totalPages]);
const allVisibleSelected =
pagedRows.length > 0 && pagedRows.every((row) => selectedRowIds.has(row.id));
const pendingPrintRow = pendingPrintJob
? rows.find((row) => row.id === pendingPrintJob.rowId) ?? null
: null;
const pendingCertificateEditRow = pendingCertificateEdit
? rows.find((row) => row.id === pendingCertificateEdit.rowId) ?? null
: null;
useEffect(() => {
setLatestProducts(products);
}, [products]);
useEffect(() => {
if (isUnboundSupplier) {
setImportRecords([]);
setImportRecordsLoaded(true);
return;
}
setImportRecords(loadBarcodePrintImportRecords(userId));
setImportRecordsLoaded(true);
}, [isUnboundSupplier, userId]);
useEffect(() => {
if (!importRecordsLoaded) return;
if (isUnboundSupplier) return;
saveBarcodePrintImportRecords(importRecords, userId);
}, [importRecords, importRecordsLoaded, isUnboundSupplier, userId]);
useEffect(() => {
if (isUnboundSupplier) {
setLatestProducts([]);
setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
return;
}
let active = true;
refreshBarcodePrintProducts()
.then((nextProducts) => {
if (active) {
setLatestProducts(nextProducts);
}
})
.catch(() => {
// 保留当前页面已有数据;刷新失败不阻断条码打印页面使用。
});
return () => {
active = false;
};
}, [isUnboundSupplier]);
useEffect(() => {
setCurrentPage(1);
}, [appliedFilters, rows.length]);
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages);
}
}, [currentPage, totalPages]);
useEffect(() => {
if (!importProgress.active) return;
if (importProgress.status !== "success" && importProgress.status !== "error") {
return;
}
const timer = window.setTimeout(() => {
setImportProgress(EMPTY_IMPORT_PROGRESS);
}, 3000);
return () => window.clearTimeout(timer);
}, [importProgress.active, importProgress.status]);
function updateDraftFilter<K extends keyof BarcodePrintFilterState>(
key: K,
value: BarcodePrintFilterState[K],
) {
setDraftFilters((current) => ({ ...current, [key]: value }));
}
function toggleVisibleRows() {
setSelectedRowIds((current) => {
const next = new Set(current);
if (allVisibleSelected) {
pagedRows.forEach((row) => next.delete(row.id));
} else {
pagedRows.forEach((row) => next.add(row.id));
}
return next;
});
}
function toggleRow(rowId: string) {
setSelectedRowIds((current) => {
const next = new Set(current);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
}
async function handleImportFile(kind: BarcodePrintImportKind, file: File | null) {
if (!file) return;
if (isUnboundSupplier) {
setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
return;
}
const label = kind === "barcode" ? "条码导入" : "品牌货号导入";
setStatusMessage("");
setImportProgress({
active: true,
label,
percent: 15,
status: "reading",
message: `正在读取 ${file.name}`,
});
try {
await waitForProgressFrame();
const result = await parseBarcodePrintImportFile(file, kind);
setImportProgress({
active: true,
label,
percent: 45,
status: "validating",
message: "字段校验通过,准备写入页面数据。",
});
await waitForProgressFrame();
setImportProgress({
active: true,
label,
percent: 75,
status: "importing",
message: "正在合并导入数据。",
});
await waitForProgressFrame();
setImportRecords((current) =>
upsertBarcodePrintImportRecords(current, result.records),
);
setSelectedRowIds(new Set());
setCurrentPage(1);
setImportProgress({
active: true,
label,
percent: 100,
status: "success",
message: `${label}成功,共导入 ${result.records.length} 条,数据源已标记为“导入”。`,
});
} catch (error) {
setImportProgress({
active: true,
label,
percent: 100,
status: "error",
message:
error instanceof Error ? error.message : `${label}失败,请检查模板。`,
});
}
}
async function handleDownloadTemplate(kind: BarcodePrintImportKind) {
const label = kind === "barcode" ? "条码导入模板" : "品牌货号导入模板";
setStatusMessage("");
setImportProgress({
active: true,
label,
percent: 50,
status: "importing",
message: `正在生成${label}。`,
});
try {
await waitForProgressFrame();
await downloadBarcodePrintImportTemplate(kind);
setImportProgress({
active: true,
label,
percent: 100,
status: "success",
message: `${label}已生成下载。`,
});
} catch (error) {
setImportProgress({
active: true,
label,
percent: 100,
status: "error",
message: error instanceof Error ? error.message : `${label}下载失败。`,
});
}
}
function deleteImportedData(importRecordIds: string[] | undefined) {
if (!importRecordIds?.length) return;
const deleteRecordIdSet = new Set(importRecordIds);
setImportRecords((current) =>
current.filter((record) => !deleteRecordIdSet.has(record.id)),
);
setSelectedRowIds(new Set());
setImportProgress(EMPTY_IMPORT_PROGRESS);
setStatusMessage("已删除该行导入数据。");
}
function startRowNumberColumnResize(
event: ReactMouseEvent<HTMLButtonElement>,
) {
startColumnResize({
event,
minWidth: ROW_NUMBER_COLUMN_MIN_WIDTH,
startWidth: rowNumberColumnWidth,
onResize: setRowNumberColumnWidth,
});
}
function startDataColumnResize(
event: ReactMouseEvent<HTMLButtonElement>,
key: BarcodePrintColumnKey,
) {
startColumnResize({
event,
minWidth: getBarcodePrintColumnMinWidth(key),
startWidth: columnWidths[key],
onResize: (width) =>
setColumnWidths((current) => ({ ...current, [key]: width })),
});
}
function startColumnResize({
event,
minWidth,
startWidth,
onResize,
}: {
event: ReactMouseEvent<HTMLButtonElement>;
minWidth: number;
startWidth: number;
onResize: (width: number) => void;
}) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const handleMouseMove = (mouseEvent: MouseEvent) => {
const nextWidth = Math.max(
minWidth,
Math.round(startWidth + mouseEvent.clientX - startX),
);
onResize(nextWidth);
};
const handleMouseUp = () => {
window.removeEventListener("mousemove", handleMouseMove);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp, { once: true });
}
function handleBarcodePrint(rowId: string) {
const row = rows.find((item) => item.id === rowId);
if (!row) return;
if (isUnboundSupplier) {
setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
return;
}
setStatusMessage("");
setPendingPrintJob({ mode: "barcode", rowId });
}
function handleCertificatePrint(rowId: string) {
const row = rows.find((item) => item.id === rowId);
if (!row) return;
if (isUnboundSupplier) {
setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
return;
}
setStatusMessage("");
setPendingPrintJob({ mode: "certificate", rowId });
}
async function handleTemplatePrint({
templateTitle,
batchNumber,
editMode,
}: BarcodePrintTemplateDialogSubmit) {
if (!pendingPrintJob || !pendingPrintRow) return;
const { mode } = pendingPrintJob;
const row = pendingPrintRow;
setPendingPrintJob(null);
setStatusMessage("");
try {
if (mode === "certificate") {
if (editMode) {
setPendingCertificateEdit({
rowId: row.id,
templateTitle,
editMode,
});
setStatusMessage(`已打开合格证编辑预览:${templateTitle}`);
return;
}
const preview = await createCertificatePrintPreview(row, templateTitle, {
batchNumber,
});
setCertificatePreview(preview);
setStatusMessage(`已生成合格证预览:${preview.title}`);
return;
}
const printedTemplateTitle = await printBarcodeRow(row, {
templateTitle,
batchNumber,
});
setStatusMessage(
`已打开条码打印预览:${printedTemplateTitle}`,
);
} catch (error) {
setStatusMessage(
error instanceof Error
? error.message
: mode === "barcode"
? "条码打印失败,请检查打印插件。"
: "合格证打印失败,请重试。",
);
}
}
async function handleCertificateEditPrint(options: CertificatePrintOptions) {
if (!pendingCertificateEdit || !pendingCertificateEditRow) return;
const batchNumber =
pendingCertificateEdit.editMode === "commonWithMaker"
? window.prompt("请输入批次,可不填", "")
: undefined;
if (batchNumber === null) return;
try {
const preview = await createCertificatePrintPreview(
pendingCertificateEditRow,
pendingCertificateEdit.templateTitle,
{
...options,
batchNumber: batchNumber ?? undefined,
},
);
setPendingCertificateEdit(null);
setCertificatePreview(preview);
setStatusMessage(`已生成合格证预览:${preview.title}`);
} catch (error) {
setStatusMessage(
error instanceof Error ? error.message : "合格证打印失败,请重试。",
);
}
}
function buildPrintRowSummary(row: BarcodePrintRow) {
return [row.spu, row.skuCode, row.barcode, row.supplierName]
.filter((value) => value.trim())
.join(" / ");
}
function resetFilters() {
setDraftFilters(EMPTY_FILTERS);
setAppliedFilters(EMPTY_FILTERS);
}
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[0_1px_2px_rgba(17,19,21,0.04)]">
<input
ref={barcodeImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("barcode", file);
}}
/>
<input
ref={brandSkuImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("brandSku", file);
}}
/>
<BarcodePrintHeader
draftFilters={draftFilters}
productCategoryOptions={productCategoryOptions}
salesCategoryOptions={salesCategoryOptions}
statusMessage={statusMessage}
actionDisabled={isUnboundSupplier}
onBarcodeImport={() => {
if (isUnboundSupplier) setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
else barcodeImportInputRef.current?.click();
}}
onBrandSkuImport={() => {
if (isUnboundSupplier) setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
else brandSkuImportInputRef.current?.click();
}}
onDownloadTemplate={(kind) => void handleDownloadTemplate(kind)}
onFilterChange={updateDraftFilter}
onSearch={() => setAppliedFilters(draftFilters)}
onReset={resetFilters}
/>
<BarcodePrintTable
pagedRows={pagedRows}
filteredRowCount={filteredRows.length}
currentPage={currentPage}
selectedRowIds={selectedRowIds}
allVisibleSelected={allVisibleSelected}
tableWidth={tableWidth}
rowNumberColumnWidth={rowNumberColumnWidth}
columnWidths={columnWidths}
emptyMessage={isUnboundSupplier ? UNBOUND_SUPPLIER_MESSAGE : undefined}
onToggleVisibleRows={toggleVisibleRows}
onToggleRow={toggleRow}
onStartRowNumberColumnResize={startRowNumberColumnResize}
onStartDataColumnResize={startDataColumnResize}
onBarcodePrint={(row) => void handleBarcodePrint(row.id)}
onCertificatePrint={(row) => handleCertificatePrint(row.id)}
onDeleteImportedData={deleteImportedData}
/>
<BarcodePrintPagination
currentPage={currentPage}
totalPages={totalPages}
totalRows={filteredRows.length}
onPageChange={setCurrentPage}
/>
{pendingPrintJob && pendingPrintRow ? (
<BarcodePrintTemplateDialog
key={`${pendingPrintJob.mode}:${pendingPrintJob.rowId}`}
isOpen
mode={pendingPrintJob.mode}
templates={
pendingPrintJob.mode === "certificate"
? CERTIFICATE_PRINT_TEMPLATE_OPTIONS
: BARCODE_PRINT_TEMPLATE_OPTIONS
}
rowSummary={buildPrintRowSummary(pendingPrintRow)}
onClose={() => setPendingPrintJob(null)}
onSubmit={(value) => void handleTemplatePrint(value)}
/>
) : null}
{pendingCertificateEdit && pendingCertificateEditRow ? (
<BarcodePrintCertificateEditDialog
key={`${pendingCertificateEdit.templateTitle}:${pendingCertificateEdit.rowId}`}
isOpen
editMode={pendingCertificateEdit.editMode}
templateTitle={pendingCertificateEdit.templateTitle}
initialSofiaCartonForm={
pendingCertificateEdit.editMode === "sofiaCarton"
? buildSofiaCartonCertificateForm(pendingCertificateEditRow)
: undefined
}
initialCommonForm={
pendingCertificateEdit.editMode === "commonWithMaker" ||
pendingCertificateEdit.editMode === "commonWithoutMaker"
? buildCommonCertificateForm(pendingCertificateEditRow)
: undefined
}
onClose={() => setPendingCertificateEdit(null)}
onPrint={(options) => void handleCertificateEditPrint(options)}
/>
) : null}
{certificatePreview ? (
<BarcodePrintCertificatePreviewDialog
isOpen
title={certificatePreview.title}
pageSize={certificatePreview.pageSize}
imageDataUrl={certificatePreview.imageDataUrl}
onClose={() => setCertificatePreview(null)}
onPrint={() => void printCertificatePreview(certificatePreview)}
/>
) : null}
<BarcodePrintProgressToast progress={importProgress} />
</section>
);
}

View File

@ -0,0 +1,808 @@
"use client";
import html2canvas from "html2canvas";
import QRCode from "qrcode";
import type { BarcodePrintRow } from "./barcodePrintRows";
type CertificateTemplate = {
title: string;
pageSize: string;
background: string;
editMode?: CertificateSpecialEditMode;
render: (
record: CertificateRecord,
qrCodeBase64: string,
options: CertificatePrintOptions,
backgroundDataUrl: string,
) => HTMLElement;
};
type CertificateRecord = {
barcode: string;
productCategory: string;
color: string;
procode: string;
brandProcode: string;
size: string;
productSpec: string;
hangtagMaterial: string;
qualifiedCertMainMaterial: string;
executionStandard: string;
};
export type CertificateSpecialEditMode =
| "sofiaCarton"
| "commonWithMaker"
| "commonWithoutMaker";
export type SofiaCartonCertificateForm = {
color: string;
barcode: string;
productCategory: string;
lineArtUrl: string;
JZ: string;
MZ: string;
WXTJ: string;
WXCC: string;
XH: string;
ZXSL: string;
CD: string;
};
export type CommonCertificateForm = {
productName: string;
barcode: string;
size: string;
hangtagMaterial: string;
maker: string;
executionStandard: string;
level: string;
auther: string;
address: string;
phone: string;
};
export type CertificatePrintOptions = {
batchNumber?: string;
sofiaCartonForm?: SofiaCartonCertificateForm;
commonForm?: CommonCertificateForm;
};
export type CertificatePrintPreview = {
title: string;
pageSize: string;
imageDataUrl: string;
};
const TEMPLATE_BASE = "/barcode-print/templates";
const COMMON_MAKER = "常州朴美家居有限公司";
const COMMON_ADDRESS = "常州经济开发区横林镇红赵路62号";
const PUMEI_LANDSCAPE_TITLE = "朴美横版贴纸(10cm*7cm)";
const PUMEI_PORTRAIT_TITLE = "朴美竖版贴纸(10cm*13cm)";
const SOFIA_CERTIFICATE_TITLE = "索菲亚合格证卡片(7.4cm*10cm)";
const SOFIA_CARTON_TITLE = "索菲亚外箱贴纸(10cm*7cm)";
const COMMON_WITH_MAKER_TITLE = "通用横版贴纸-带供应商(10cm*7cm)";
const COMMON_WITHOUT_MAKER_TITLE = "通用横版贴纸-不带供应商(10cm*7cm)";
const GUJIA_CERTIFICATE_TITLE = "顾家合格证卡片(11cm*13cm)";
const CERTIFICATE_FONT_FAMILY =
"Microsoft YaHei, 微软雅黑, Arial, sans-serif";
const LANDSCAPE_FIELD_TOP = 302;
const LANDSCAPE_FIELD_ROW_GAP = 69;
const LANDSCAPE_BARCODE_ROW_TOP_OFFSET = 3;
const PORTRAIT_FIELD_TOP = 883;
const PORTRAIT_FIELD_ROW_GAP = 68;
const PORTRAIT_RIGHT_FIRST_ROW_TOP_OFFSET = 0;
const PORTRAIT_VALUE_VISUAL_OFFSET = -8;
function trimText(value: unknown) {
return String(value ?? "").trim();
}
function readLeafCategory(value: string) {
const parts = value
.split(">")
.map((item) => item.trim())
.filter(Boolean);
return parts.at(-1) ?? value.trim();
}
function assetUrl(path: string) {
if (typeof window === "undefined") return path;
return new URL(path, window.location.origin).toString();
}
function buildCertificateRecord(row: BarcodePrintRow): CertificateRecord {
const productCategory = readLeafCategory(trimText(row.productCategory));
const size = trimText(row.specification || row.size).replace(/\//g, "*");
return {
barcode: trimText(row.barcode),
productCategory,
color: trimText(row.color),
procode: trimText(row.skuCode),
brandProcode: trimText(row.brandSkuCode),
size,
productSpec: size,
hangtagMaterial: trimText(row.tagMaterial || row.material),
qualifiedCertMainMaterial: trimText(row.certificateMaterial),
executionStandard: trimText(row.executeStandard),
};
}
function getProductLabel(record: CertificateRecord) {
return [record.productCategory, record.color]
.map((value) => value.trim())
.filter(Boolean)
.join("-");
}
function buildSofiaCartonCertificateFormFromRecord(
record: CertificateRecord,
): SofiaCartonCertificateForm {
return {
color: record.color,
barcode: record.barcode,
productCategory: record.productCategory,
lineArtUrl: "",
JZ: "",
MZ: "",
WXTJ: "",
WXCC: "",
XH: "",
ZXSL: "",
CD: "",
};
}
function buildCommonCertificateFormFromRecord(
record: CertificateRecord,
): CommonCertificateForm {
return {
address: COMMON_ADDRESS,
maker: COMMON_MAKER,
phone: "19941684327",
productName: getProductLabel(record),
barcode: record.barcode,
size: record.size,
hangtagMaterial: record.hangtagMaterial,
executionStandard: record.executionStandard,
level: "合格",
auther: "B",
};
}
export function buildSofiaCartonCertificateForm(
row: BarcodePrintRow,
): SofiaCartonCertificateForm {
return buildSofiaCartonCertificateFormFromRecord(buildCertificateRecord(row));
}
export function buildCommonCertificateForm(
row: BarcodePrintRow,
): CommonCertificateForm {
return buildCommonCertificateFormFromRecord(buildCertificateRecord(row));
}
function getLast10DayText() {
const date = new Date();
date.setDate(date.getDate() - 10);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
}
function createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
className?: string,
textContent?: string,
) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (textContent != null) element.textContent = textContent;
return element;
}
function appendItem(
container: HTMLElement,
className: string,
textContent: string,
style?: Partial<CSSStyleDeclaration>,
) {
const element = createElement("div", className, textContent);
Object.assign(element.style, {
boxSizing: "border-box",
fontFamily: CERTIFICATE_FONT_FAMILY,
whiteSpace: "nowrap",
zIndex: "1",
...style,
} satisfies Partial<CSSStyleDeclaration>);
container.appendChild(element);
}
function appendCenteredItem(
container: HTMLElement,
className: string,
textContent: string,
style?: Partial<CSSStyleDeclaration>,
) {
appendItem(container, className, textContent, {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
height: "32px",
lineHeight: "1",
...style,
});
}
function createQrCode(qrCodeBase64: string) {
const wrapper = createElement("div", "qr_code");
const image = document.createElement("img");
image.src = qrCodeBase64;
Object.assign(wrapper.style, {
width: "182px",
height: "182px",
position: "absolute",
top: "44px",
right: "78px",
zIndex: "1",
} satisfies Partial<CSSStyleDeclaration>);
Object.assign(image.style, {
display: "block",
width: "182px",
height: "182px",
} satisfies Partial<CSSStyleDeclaration>);
wrapper.appendChild(image);
return wrapper;
}
function createCertificateMain(
className: string,
backgroundDataUrl: string,
width: number,
height: number,
) {
const container = createElement("div", className);
const backgroundImage = document.createElement("img");
backgroundImage.src = backgroundDataUrl;
backgroundImage.alt = "";
backgroundImage.decoding = "sync";
Object.assign(backgroundImage.style, {
position: "absolute",
inset: "0",
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: "0",
} satisfies Partial<CSSStyleDeclaration>);
Object.assign(container.style, {
position: "relative",
width: `${width}px`,
height: `${height}px`,
overflow: "hidden",
} satisfies Partial<CSSStyleDeclaration>);
container.appendChild(backgroundImage);
return container;
}
function addLandscapeRows(
container: HTMLElement,
leftRows: string[],
rightRows: string[],
options: { leftBarcodeFontSize?: number } = {},
) {
leftRows.forEach((text, index) => {
appendCenteredItem(container, `item_${index + 1} left item`, text, {
position: "absolute",
fontWeight: "600",
fontSize: `${index === 1 ? (options.leftBarcodeFontSize ?? 30) : 30}px`,
color: "#000000",
top: `${
LANDSCAPE_FIELD_TOP +
index * LANDSCAPE_FIELD_ROW_GAP +
(index === 1 ? LANDSCAPE_BARCODE_ROW_TOP_OFFSET : 0)
}px`,
left: "224px",
});
});
rightRows.forEach((text, index) => {
appendCenteredItem(container, `item_${index + 1} right item`, text, {
position: "absolute",
fontWeight: "600",
fontSize: index === 3 ? "28px" : "30px",
color: "#000000",
top: `${LANDSCAPE_FIELD_TOP + index * LANDSCAPE_FIELD_ROW_GAP}px`,
left: "754px",
});
});
}
function renderPumeiLandscape(
record: CertificateRecord,
qrCodeBase64: string,
_options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main", backgroundDataUrl, 1181, 827);
container.appendChild(createQrCode(qrCodeBase64));
addLandscapeRows(
container,
[getProductLabel(record), record.barcode, record.size, record.hangtagMaterial, COMMON_MAKER],
[record.executionStandard, "合格", "B", COMMON_ADDRESS, "19941684324"],
{ leftBarcodeFontSize: 26 },
);
return container;
}
function renderPumeiPortrait(
record: CertificateRecord,
_qrCodeBase64: string,
_options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main_v2", backgroundDataUrl, 1181, 1535);
const leftRows = [
getProductLabel(record),
record.procode,
record.size,
record.hangtagMaterial,
COMMON_MAKER,
];
const rightRows = [record.executionStandard, "合格", "B", COMMON_ADDRESS, "19941684324"];
leftRows.forEach((text, index) => {
appendCenteredItem(container, `item_${index + 1} left item`, text, {
position: "absolute",
fontWeight: "500",
fontSize: "26px",
color: "#000000",
top: `${
PORTRAIT_FIELD_TOP +
index * PORTRAIT_FIELD_ROW_GAP +
PORTRAIT_VALUE_VISUAL_OFFSET
}px`,
left: "224px",
});
});
rightRows.forEach((text, index) => {
appendCenteredItem(container, `item_${index + 1} right item`, text, {
position: "absolute",
fontWeight: "500",
fontSize: "26px",
color: "#000000",
top: `${
PORTRAIT_FIELD_TOP +
index * PORTRAIT_FIELD_ROW_GAP +
(index === 0 ? PORTRAIT_RIGHT_FIRST_ROW_TOP_OFFSET : 0) +
PORTRAIT_VALUE_VISUAL_OFFSET
}px`,
left: "754px",
});
});
return container;
}
function renderSofiaCertificate(
record: CertificateRecord,
_qrCodeBase64: string,
_options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main_sfy", backgroundDataUrl, 874, 1181);
[
getProductLabel(record),
record.procode || record.brandProcode,
record.size,
record.executionStandard,
"QC01",
getLast10DayText(),
].forEach((text, index) => {
appendItem(container, `item_${index + 1} item`, text, {
position: "absolute",
fontSize: "26px",
color: "#000000",
lineHeight: "32px",
left: "310px",
top: `${398 + index * 66}px`,
});
});
return container;
}
function renderGujiaCertificate(
record: CertificateRecord,
_qrCodeBase64: string,
_options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main_gj", backgroundDataUrl, 1323, 1559);
[
getProductLabel(record),
record.brandProcode || record.procode,
record.productSpec || record.size,
record.hangtagMaterial || record.qualifiedCertMainMaterial,
"顾家家居股份有限公司",
"B",
"合格",
getLast10DayText(),
"",
].forEach((text, index) => {
appendItem(container, `item_${index + 1} item`, text, {
position: "absolute",
fontSize: "42px",
color: "#000000",
lineHeight: "48px",
left: "455px",
top: `${530 + index * 101}px`,
});
});
return container;
}
function appendSofiaCartonItem(
container: HTMLElement,
text: string,
top: number,
left = 634,
) {
appendItem(container, "item", text, {
position: "absolute",
left: `${left}px`,
top: `${top}px`,
fontSize: "24px",
lineHeight: "1",
fontWeight: "bold",
});
}
function renderSofiaCarton(
record: CertificateRecord,
_qrCodeBase64: string,
options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const form =
options.sofiaCartonForm ?? buildSofiaCartonCertificateFormFromRecord(record);
const productLabel = [form.productCategory, form.color]
.map((value) => value.trim())
.filter(Boolean)
.join("-");
const container = createCertificateMain("certificate_main_sfy_wxtz", backgroundDataUrl, 1181, 827);
if (form.lineArtUrl) {
const lineArtContainer = createElement("div", "blob-container");
const image = document.createElement("img");
image.src = form.lineArtUrl;
Object.assign(lineArtContainer.style, {
position: "absolute",
width: "446px",
height: "354px",
display: "flex",
alignItems: "center",
justifyContent: "center",
left: "10px",
top: "228px",
zIndex: "1",
} satisfies Partial<CSSStyleDeclaration>);
Object.assign(image.style, {
display: "block",
maxWidth: "80%",
maxHeight: "80%",
} satisfies Partial<CSSStyleDeclaration>);
lineArtContainer.appendChild(image);
container.appendChild(lineArtContainer);
}
appendItem(container, "title productCategory", productLabel, {
position: "absolute",
left: "500px",
top: "321px",
lineHeight: "1",
fontSize: "40px",
fontWeight: "bold",
});
appendItem(container, "title barcode", form.barcode, {
position: "absolute",
left: "500px",
top: "391px",
lineHeight: "1",
fontSize: "40px",
fontWeight: "bold",
});
appendSofiaCartonItem(container, form.color, 454);
appendSofiaCartonItem(container, form.JZ, 500);
appendSofiaCartonItem(container, form.MZ, 500, 952);
appendSofiaCartonItem(container, form.WXTJ, 546);
appendSofiaCartonItem(container, form.WXCC, 592);
appendSofiaCartonItem(container, form.XH, 638);
appendSofiaCartonItem(container, form.ZXSL, 684);
appendSofiaCartonItem(container, form.CD, 730);
return container;
}
function renderCommonWithMaker(
record: CertificateRecord,
qrCodeBase64: string,
options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main", backgroundDataUrl, 1181, 827);
const form = options.commonForm ?? buildCommonCertificateFormFromRecord(record);
const maker = options.batchNumber?.trim()
? `${form.maker}(${options.batchNumber.trim()})`
: form.maker;
container.appendChild(createQrCode(qrCodeBase64));
addLandscapeRows(
container,
[form.productName, form.barcode, form.size, form.hangtagMaterial, maker],
[form.executionStandard, form.level, form.auther, form.address, form.phone],
);
return container;
}
function renderCommonWithoutMaker(
record: CertificateRecord,
qrCodeBase64: string,
options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain(
"certificate_main certificate_main_easy",
backgroundDataUrl,
1181,
827,
);
const form = options.commonForm ?? buildCommonCertificateFormFromRecord(record);
container.appendChild(createQrCode(qrCodeBase64));
addLandscapeRows(
container,
[form.productName, form.barcode, form.size, form.hangtagMaterial],
[form.executionStandard, form.level, form.auther],
);
return container;
}
const CERTIFICATE_TEMPLATES: CertificateTemplate[] = [
{
title: PUMEI_LANDSCAPE_TITLE,
pageSize: "100mm 70mm",
background: "hgz.jpg",
render: renderPumeiLandscape,
},
{
title: PUMEI_PORTRAIT_TITLE,
pageSize: "100mm 130mm",
background: "hgz_v2.jpg",
render: renderPumeiPortrait,
},
{
title: SOFIA_CERTIFICATE_TITLE,
pageSize: "74mm 100mm",
background: "hgz_sfy.png",
render: renderSofiaCertificate,
},
{
title: SOFIA_CARTON_TITLE,
pageSize: "100mm 70mm",
background: "wxtz_sfy.jpg",
editMode: "sofiaCarton",
render: renderSofiaCarton,
},
{
title: COMMON_WITH_MAKER_TITLE,
pageSize: "100mm 70mm",
background: "hgz.jpg",
editMode: "commonWithMaker",
render: renderCommonWithMaker,
},
{
title: COMMON_WITHOUT_MAKER_TITLE,
pageSize: "100mm 70mm",
background: "hgz_easy.jpg",
editMode: "commonWithoutMaker",
render: renderCommonWithoutMaker,
},
{
title: GUJIA_CERTIFICATE_TITLE,
pageSize: "110mm 130mm",
background: "hgz_gj.jpg",
render: renderGujiaCertificate,
},
];
export const CERTIFICATE_PRINT_TEMPLATE_OPTIONS = CERTIFICATE_TEMPLATES.map(
({ editMode, title }) => ({ editMode, title }),
);
function findCertificateTemplate(title: string) {
const template = CERTIFICATE_TEMPLATES.find((item) => item.title === title);
if (!template) {
throw new Error("请选择有效的合格证模板。");
}
return template;
}
async function createQrCodeBase64(barcode: string) {
if (!barcode) return "";
return new Promise<string>((resolve, reject) => {
QRCode.toDataURL(
barcode,
{
width: 182,
margin: 0,
},
(error, url) => {
if (error) {
reject(error);
return;
}
resolve(url);
},
);
});
}
function appendHiddenRenderRoot(element: HTMLElement) {
const root = document.createElement("div");
Object.assign(root.style, {
position: "fixed",
left: "-20000px",
top: "0",
width: element.style.width || "auto",
height: element.style.height || "auto",
overflow: "visible",
pointerEvents: "none",
} satisfies Partial<CSSStyleDeclaration>);
root.appendChild(element);
document.body.appendChild(root);
return root;
}
function waitForImages(root: HTMLElement) {
const images = Array.from(root.querySelectorAll("img"));
return Promise.all(
images.map(
(image) =>
new Promise<void>((resolve, reject) => {
if (image.complete) {
if (image.naturalWidth > 0) {
resolve();
return;
}
reject(new Error("合格证图片加载失败。"));
return;
}
image.onload = () => {
if (image.naturalWidth > 0) {
resolve();
return;
}
reject(new Error("合格证图片加载失败。"));
};
image.onerror = () => reject(new Error("合格证图片加载失败。"));
}),
),
);
}
function waitForImageUrl(url: string) {
return new Promise<void>((resolve, reject) => {
const image = new Image();
image.onload = () => {
if (image.naturalWidth > 0) {
resolve();
return;
}
reject(new Error("合格证背景图片加载失败。"));
};
image.onerror = () => reject(new Error("合格证背景图片加载失败。"));
image.src = url;
});
}
async function imageUrlToDataUrl(url: string) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error("合格证背景图片加载失败。");
}
const blob = await response.blob();
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string" && reader.result.startsWith("data:")) {
resolve(reader.result);
return;
}
reject(new Error("合格证背景图片读取失败。"));
};
reader.onerror = () => reject(new Error("合格证背景图片读取失败。"));
reader.readAsDataURL(blob);
});
}
export async function createCertificatePrintPreview(
row: BarcodePrintRow,
templateTitle: string,
options: CertificatePrintOptions = {},
): Promise<CertificatePrintPreview> {
const template = findCertificateTemplate(templateTitle);
const record = buildCertificateRecord(row);
const qrCodeBase64 = await createQrCodeBase64(record.barcode);
const backgroundUrl = assetUrl(`${TEMPLATE_BASE}/${template.background}`);
const backgroundDataUrl = await imageUrlToDataUrl(backgroundUrl);
const element = template.render(record, qrCodeBase64, options, backgroundDataUrl);
const root = appendHiddenRenderRoot(element);
try {
await Promise.all([waitForImages(root), waitForImageUrl(backgroundDataUrl)]);
const canvas = await html2canvas(element, {
backgroundColor: null,
logging: false,
scale: 1,
useCORS: true,
});
return {
title: template.title,
pageSize: template.pageSize,
imageDataUrl: canvas.toDataURL("image/png", 1),
};
} finally {
root.remove();
}
}
export async function printCertificatePreview(preview: CertificatePrintPreview) {
const printJS = (await import("print-js")).default;
printJS({
printable: preview.imageDataUrl,
type: "image",
style: `
@page {
size: ${preview.pageSize};
margin: 0;
}
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
img {
max-width: 100%;
max-height: 100%;
display: block;
}
`,
honorMarginPadding: false,
targetStyles: ["*"],
});
}

View File

@ -0,0 +1 @@
export const BARCODE_PRINT_LABEL = "条码打印";

View File

@ -0,0 +1,93 @@
import type { BarcodePrintRow } from "./barcodePrintRows";
import type { BarcodePrintFilterState } from "./barcodePrintTypes";
export const EMPTY_FILTERS: BarcodePrintFilterState = {
spu: "",
model: "",
skuCode: "",
brandSkuCode: "",
barcode: "",
operationsOwner: "",
spuOwner: "",
productPlanning: "",
purchaseOwner: "",
productCategory: "",
salesCategory: "",
supplierName: "",
createdFrom: "",
createdTo: "",
};
function textIncludes(value: string, query: string) {
return value
.trim()
.toLowerCase()
.includes(query.trim().toLowerCase());
}
export function rowMatchesBarcodePrintFilters(
row: BarcodePrintRow,
filters: BarcodePrintFilterState,
) {
if (filters.spu && !textIncludes(row.spu, filters.spu)) return false;
if (filters.model && !textIncludes(row.model, filters.model)) return false;
if (filters.skuCode && !textIncludes(row.skuCode, filters.skuCode)) {
return false;
}
if (
filters.brandSkuCode &&
!textIncludes(row.brandSkuCode, filters.brandSkuCode)
) {
return false;
}
if (filters.barcode && !textIncludes(row.barcode, filters.barcode)) {
return false;
}
if (
filters.operationsOwner &&
!textIncludes(row.operationsOwner, filters.operationsOwner)
) {
return false;
}
if (filters.spuOwner && !textIncludes(row.spuOwner, filters.spuOwner)) {
return false;
}
if (
filters.productPlanning &&
!textIncludes(row.productPlanning, filters.productPlanning)
) {
return false;
}
if (
filters.purchaseOwner &&
!textIncludes(row.purchaseOwner, filters.purchaseOwner)
) {
return false;
}
if (filters.productCategory && row.productCategory !== filters.productCategory) {
return false;
}
if (filters.salesCategory && row.salesCategory !== filters.salesCategory) {
return false;
}
if (filters.supplierName && !textIncludes(row.supplierName, filters.supplierName)) {
return false;
}
if (filters.createdFrom && row.createdAt && row.createdAt < filters.createdFrom) {
return false;
}
if (filters.createdTo && row.createdAt && row.createdAt > filters.createdTo) {
return false;
}
return true;
}
export function getBarcodePrintUniqueOptions(
rows: BarcodePrintRow[],
key: keyof BarcodePrintRow,
) {
return Array.from(
new Set(rows.map((row) => String(row[key]).trim()).filter(Boolean)),
).sort((left, right) => left.localeCompare(right, "zh-CN"));
}

View File

@ -0,0 +1,431 @@
import type { BarcodePrintRow } from "./barcodePrintRows";
export type BarcodePrintImportKind = "barcode" | "brandSku";
type BarcodePrintImportUpdates = Partial<
Omit<BarcodePrintRow, "id" | "importRecordIds">
> & {
skuCode: string;
dataSource: string;
};
export type BarcodePrintImportRecord = {
id: string;
kind: BarcodePrintImportKind;
skuCode: string;
sourceFileName: string;
rowNumber: number;
importedAt: number;
updates: BarcodePrintImportUpdates;
};
export type BarcodePrintImportResult = {
kind: BarcodePrintImportKind;
fileName: string;
records: BarcodePrintImportRecord[];
};
export const BARCODE_PRINT_IMPORT_ACCEPT =
".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
const BARCODE_IMPORT_HEADERS = [
"SPU",
"型号",
"货号",
"条码",
"工厂型号",
"规格名称",
"商品类目",
"尺寸",
"颜色",
"执行标准",
"吊牌材质",
"供应商名称",
];
const BRAND_SKU_IMPORT_HEADERS = [
"货号",
"品牌货号",
"品名",
"料号",
"规格(长*宽*高)",
"合格证的主材*基材",
];
const IMPORT_LABEL_BY_KIND: Record<BarcodePrintImportKind, string> = {
barcode: "条码导入",
brandSku: "品牌货号导入",
};
const TEMPLATE_HEADERS_BY_KIND: Record<BarcodePrintImportKind, string[]> = {
barcode: BARCODE_IMPORT_HEADERS,
brandSku: BRAND_SKU_IMPORT_HEADERS,
};
const TEMPLATE_FILE_NAME_BY_KIND: Record<BarcodePrintImportKind, string> = {
barcode: "条码导入模板.xlsx",
brandSku: "品牌货号导入模板.xlsx",
};
const EMPTY_STANDALONE_ROW: Omit<BarcodePrintRow, "id"> = {
spu: "",
model: "",
specCode: "",
setCount: "",
skuCode: "",
brandSkuCode: "",
productName: "",
materialNo: "",
specification: "",
certificateMaterial: "",
barcode: "",
factoryModel: "",
attributeCode: "",
drawingId: "",
barcodeSpecName: "",
productCategory: "",
salesCategory: "",
materialType: "",
size: "",
color: "",
material: "",
executeStandard: "",
tagMaterial: "",
supplierName: "",
warehouse: "",
installationService: "",
lengthCm: "",
widthCm: "",
heightCm: "",
volumeCm3: "",
weightKg: "",
costPrice: "",
productPlanning: "",
visualPlanning: "",
spuOwner: "",
operationsOwner: "",
purchaseOwner: "",
productionType: "",
shippingMode: "",
supplierCategory: "",
purchaseOrderMethod: "",
assemblyDetail: "",
createdAt: "",
dataSource: "导入",
};
function normalizeHeader(value: unknown) {
return String(value ?? "").replace(/^\uFEFF/, "").trim();
}
function normalizeSkuCode(value: string) {
return value.trim().toUpperCase();
}
function cellText(value: unknown) {
return String(value ?? "").trim();
}
function importRecordKey(record: BarcodePrintImportRecord) {
return `${record.kind}:${normalizeSkuCode(record.skuCode)}`;
}
function buildHeaderIndex(headers: unknown[]) {
const headerIndex = new Map<string, number>();
headers.forEach((header, index) => {
const normalizedHeader = normalizeHeader(header);
if (normalizedHeader) {
headerIndex.set(normalizedHeader, index);
}
});
return headerIndex;
}
function requireHeaders(
headerIndex: Map<string, number>,
kind: BarcodePrintImportKind,
) {
const missingHeaders = TEMPLATE_HEADERS_BY_KIND[kind].filter(
(header) => !headerIndex.has(header),
);
if (missingHeaders.length > 0) {
throw new Error(
`${IMPORT_LABEL_BY_KIND[kind]}模板缺少字段:${missingHeaders.join("、")}`,
);
}
}
function getCell(
row: unknown[],
headerIndex: Map<string, number>,
headerName: string,
) {
const index = headerIndex.get(headerName);
return typeof index === "number" ? cellText(row[index]) : "";
}
function setIfPresent<
K extends keyof Omit<BarcodePrintRow, "id" | "importRecordIds">,
>(
updates: Partial<Record<K, string>>,
key: K,
value: string,
) {
if (value.trim()) {
updates[key] = value;
}
}
function createRecordId(
kind: BarcodePrintImportKind,
skuCode: string,
rowNumber: number,
) {
const normalizedSkuCode = normalizeSkuCode(skuCode).replace(
/[^A-Z0-9_-]/g,
"_",
);
return `barcode-print-import:${kind}:${Date.now()}:${rowNumber}:${normalizedSkuCode || "row"}`;
}
function buildBarcodeUpdates(
row: unknown[],
headerIndex: Map<string, number>,
) {
const skuCode = getCell(row, headerIndex, "货号");
const updates: BarcodePrintImportRecord["updates"] = {
skuCode,
dataSource: "导入",
};
const importedCategory = getCell(row, headerIndex, "商品类目");
const importedMaterial = getCell(row, headerIndex, "吊牌材质");
setIfPresent(updates, "spu", getCell(row, headerIndex, "SPU"));
setIfPresent(updates, "model", getCell(row, headerIndex, "型号"));
setIfPresent(updates, "barcode", getCell(row, headerIndex, "条码"));
setIfPresent(updates, "factoryModel", getCell(row, headerIndex, "工厂型号"));
setIfPresent(
updates,
"barcodeSpecName",
getCell(row, headerIndex, "规格名称"),
);
setIfPresent(updates, "productCategory", importedCategory);
setIfPresent(updates, "salesCategory", importedCategory);
setIfPresent(updates, "size", getCell(row, headerIndex, "尺寸"));
setIfPresent(updates, "color", getCell(row, headerIndex, "颜色"));
setIfPresent(
updates,
"executeStandard",
getCell(row, headerIndex, "执行标准"),
);
setIfPresent(updates, "material", importedMaterial);
setIfPresent(updates, "tagMaterial", importedMaterial);
setIfPresent(
updates,
"supplierName",
getCell(row, headerIndex, "供应商名称"),
);
return updates;
}
function buildBrandSkuUpdates(
row: unknown[],
headerIndex: Map<string, number>,
) {
const skuCode = getCell(row, headerIndex, "货号");
const updates: BarcodePrintImportRecord["updates"] = {
skuCode,
dataSource: "导入",
};
setIfPresent(
updates,
"brandSkuCode",
getCell(row, headerIndex, "品牌货号"),
);
setIfPresent(updates, "productName", getCell(row, headerIndex, "品名"));
setIfPresent(updates, "materialNo", getCell(row, headerIndex, "料号"));
setIfPresent(
updates,
"specification",
getCell(row, headerIndex, "规格(长*宽*高)"),
);
setIfPresent(
updates,
"certificateMaterial",
getCell(row, headerIndex, "合格证的主材*基材"),
);
return updates;
}
function buildImportRecord({
row,
headerIndex,
kind,
fileName,
rowNumber,
}: {
row: unknown[];
headerIndex: Map<string, number>;
kind: BarcodePrintImportKind;
fileName: string;
rowNumber: number;
}) {
const updates =
kind === "barcode"
? buildBarcodeUpdates(row, headerIndex)
: buildBrandSkuUpdates(row, headerIndex);
if (!updates.skuCode.trim()) {
throw new Error(`第 ${rowNumber} 行缺少货号。`);
}
return {
id: createRecordId(kind, updates.skuCode, rowNumber),
kind,
skuCode: updates.skuCode,
sourceFileName: fileName,
rowNumber,
importedAt: Date.now(),
updates,
};
}
export async function parseBarcodePrintImportFile(
file: File,
kind: BarcodePrintImportKind,
): Promise<BarcodePrintImportResult> {
const XLSX = await import("xlsx");
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array", cellDates: false });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new Error("导入文件里没有可读取的工作表。");
}
const worksheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: "",
raw: false,
blankrows: false,
}) as unknown[][];
const headerRow = rows[0] ?? [];
const headerIndex = buildHeaderIndex(headerRow);
requireHeaders(headerIndex, kind);
const dataRows = rows
.slice(1)
.map((row, index) => ({ row, rowNumber: index + 2 }))
.filter(({ row }) => row.some((cell) => cellText(cell)));
if (dataRows.length === 0) {
throw new Error(`${IMPORT_LABEL_BY_KIND[kind]}模板里没有可导入的数据。`);
}
return {
kind,
fileName: file.name,
records: dataRows.map(({ row, rowNumber }) =>
buildImportRecord({
row,
headerIndex,
kind,
fileName: file.name,
rowNumber,
}),
),
};
}
export function upsertBarcodePrintImportRecords(
currentRecords: BarcodePrintImportRecord[],
incomingRecords: BarcodePrintImportRecord[],
) {
const incomingKeys = new Set(incomingRecords.map(importRecordKey));
return [
...currentRecords.filter(
(record) => !incomingKeys.has(importRecordKey(record)),
),
...incomingRecords,
];
}
export function mergeBarcodePrintImportRows(
baseRows: BarcodePrintRow[],
importRecords: BarcodePrintImportRecord[],
): BarcodePrintRow[] {
const recordsBySkuCode = new Map<string, BarcodePrintImportRecord[]>();
importRecords.forEach((record) => {
const skuCodeKey = normalizeSkuCode(record.skuCode);
if (!skuCodeKey) return;
const records = recordsBySkuCode.get(skuCodeKey) ?? [];
records.push(record);
recordsBySkuCode.set(skuCodeKey, records);
});
const matchedSkuCodes = new Set<string>();
const mergedRows = baseRows.map((row) => {
const skuCodeKey = normalizeSkuCode(row.skuCode);
const records = skuCodeKey ? recordsBySkuCode.get(skuCodeKey) : undefined;
if (!records?.length) return row;
matchedSkuCodes.add(skuCodeKey);
const updates = Object.assign({}, ...records.map((record) => record.updates));
return {
...row,
...updates,
id: row.id,
skuCode: row.skuCode || updates.skuCode || "",
dataSource: "导入",
importRecordIds: records.map((record) => record.id),
};
});
const standaloneRows = Array.from(recordsBySkuCode.entries())
.filter(([skuCodeKey]) => !matchedSkuCodes.has(skuCodeKey))
.map(([skuCodeKey, records]) => {
const updates = Object.assign(
{},
...records.map((record) => record.updates),
);
return {
...EMPTY_STANDALONE_ROW,
...updates,
id: `barcode-print-standalone:${skuCodeKey}`,
skuCode: updates.skuCode || records.at(-1)?.skuCode || "",
dataSource: "导入",
importRecordIds: records.map((record) => record.id),
};
});
return [...mergedRows, ...standaloneRows];
}
export async function downloadBarcodePrintImportTemplate(
kind: BarcodePrintImportKind,
) {
const XLSX = await import("xlsx");
const headers = TEMPLATE_HEADERS_BY_KIND[kind];
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.aoa_to_sheet([headers]);
worksheet["!cols"] = headers.map((header) => ({
wch: Math.max(12, header.length + 4),
}));
XLSX.utils.book_append_sheet(workbook, worksheet, "模板");
XLSX.writeFile(workbook, TEMPLATE_FILE_NAME_BY_KIND[kind], {
bookType: "xlsx",
});
}

View File

@ -0,0 +1,84 @@
import type { BarcodePrintImportRecord } from "./barcodePrintImport";
const BARCODE_PRINT_IMPORT_STORAGE_KEY =
"intake:barcode-print:import-records:v1";
function getBarcodePrintImportStorageKey(userId?: string) {
const normalizedUserId = String(userId ?? "").trim();
return normalizedUserId
? `${BARCODE_PRINT_IMPORT_STORAGE_KEY}:${normalizedUserId}`
: BARCODE_PRINT_IMPORT_STORAGE_KEY;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeImportRecord(
value: unknown,
): BarcodePrintImportRecord | null {
if (!isPlainObject(value)) return null;
if (value.kind !== "barcode" && value.kind !== "brandSku") return null;
if (typeof value.id !== "string" || !value.id.trim()) return null;
if (typeof value.skuCode !== "string" || !value.skuCode.trim()) return null;
if (!isPlainObject(value.updates)) return null;
return {
id: value.id,
kind: value.kind,
skuCode: value.skuCode,
sourceFileName:
typeof value.sourceFileName === "string" ? value.sourceFileName : "",
rowNumber:
typeof value.rowNumber === "number" && Number.isFinite(value.rowNumber)
? value.rowNumber
: 0,
importedAt:
typeof value.importedAt === "number" && Number.isFinite(value.importedAt)
? value.importedAt
: Date.now(),
updates: {
...value.updates,
skuCode: String(value.updates.skuCode ?? value.skuCode),
dataSource: String(value.updates.dataSource ?? "导入"),
},
};
}
export function loadBarcodePrintImportRecords(
userId?: string,
): BarcodePrintImportRecord[] {
if (typeof window === "undefined") return [];
try {
const storedValue = window.localStorage.getItem(
getBarcodePrintImportStorageKey(userId),
);
if (!storedValue) return [];
const parsedValue = JSON.parse(storedValue) as unknown;
if (!Array.isArray(parsedValue)) return [];
return parsedValue
.map(normalizeImportRecord)
.filter((record): record is BarcodePrintImportRecord => Boolean(record));
} catch {
return [];
}
}
export function saveBarcodePrintImportRecords(
records: BarcodePrintImportRecord[],
userId?: string,
) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
getBarcodePrintImportStorageKey(userId),
JSON.stringify(records),
);
} catch {
// 本地存储满或被浏览器拦截时,不影响当前页面导入结果。
}
}

View File

@ -0,0 +1,810 @@
"use client";
import { pinyin } from "pinyin-pro";
import type { BarcodePrintRow } from "./barcodePrintRows";
type LodopValue = string | number;
type Lodop = {
PRINT_INIT(title: string): void;
SET_PRINT_PAGESIZE?(orientation: number, width: string, height: string, name: string): void;
NEWPAGE(): void;
SET_PRINT_MODE(key: string, value: string): void;
ADD_PRINT_BARCODE(
top: LodopValue,
left: LodopValue,
width: LodopValue,
height: LodopValue,
codeType: string,
value: string,
): void;
ADD_PRINT_TEXT(
top: LodopValue,
left: LodopValue,
width: LodopValue,
height: LodopValue,
value: string,
): void;
ADD_PRINT_RECT(
top: LodopValue,
left: LodopValue,
width: LodopValue,
height: LodopValue,
lineStyle: number,
lineWidth: number,
): void;
ADD_PRINT_IMAGE(
top: LodopValue,
left: LodopValue,
width: LodopValue,
height: LodopValue,
html: string,
): void;
SET_PRINT_STYLEA(index: number, key: string, value: LodopValue): void;
PREVIEW(): void;
};
declare global {
interface Window {
getCLodop?: () => Lodop | undefined;
getLodop?: () => Lodop | undefined;
LODOP?: Lodop;
}
}
type BarcodeLodopRecord = {
barcode: string;
factoryNumber: string;
skuName1: string;
productCategory: string;
hangtagMaterial: string;
executionStandard: string;
printDate: string;
catcode: string;
bar2: string;
bar3: string;
};
type CertificateLodopRecord = {
barcode: string;
productName: string;
skuCode: string;
brandSkuCode: string;
productCategory: string;
color: string;
specification: string;
material: string;
executionStandard: string;
supplierName: string;
};
type BarcodePrintTemplate = {
title: string;
printFunc: (
record: BarcodeLodopRecord,
lodop: Lodop,
options?: { batchNumber?: string },
) => void;
};
type CertificatePrintTemplate = {
title: string;
width: string;
height: string;
orientation: 1 | 2;
canvasWidth: number;
canvasHeight: number;
background: string;
layout:
| "pumeiLandscape"
| "pumeiPortrait"
| "sofiaCertificate"
| "sofiaCarton"
| "commonWithMaker"
| "commonWithoutMaker"
| "gujiaCertificate";
qr?: {
x: number;
y: number;
size: number;
};
};
export type BarcodePrintTemplateTitle = string;
export type CertificatePrintTemplateTitle = string;
const CLODOP_SCRIPT_URLS = [
"http://127.0.0.1:8000/CLodopfuncs.js",
"http://127.0.0.1:18000/CLodopfuncs.js",
"http://localhost:8000/CLodopfuncs.js",
"http://localhost:18000/CLodopfuncs.js",
"https://localhost.lodop.net:8443/CLodopfuncs.js",
];
let lodopScriptLoadPromise: Promise<void> | null = null;
function trimText(value: unknown) {
return String(value ?? "").trim();
}
function getTodayPrintDate() {
const now = new Date();
return [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("");
}
function readLeafCategory(value: string) {
const parts = value
.split(">")
.map((item) => item.trim())
.filter(Boolean);
return parts.at(-1) ?? value.trim();
}
function buildColorCode(color: string) {
if (!color.trim()) return "";
return pinyin(color, { toneType: "none", type: "array" })
.join("")
.toUpperCase();
}
function splitBarcodeParts(barcode: string) {
const catcode = barcode.match(/^[A-Za-z|-]+/)?.[0] ?? "";
const rest = catcode ? barcode.slice(catcode.length) : barcode;
const [bar2 = rest, bar3Tail = ""] = rest.split("N");
return {
catcode,
bar2,
bar3: bar3Tail ? `N${bar3Tail}` : "",
};
}
function buildLodopRecord(row: BarcodePrintRow): BarcodeLodopRecord {
const barcode = trimText(row.barcode);
if (!barcode) {
throw new Error("当前行没有条码,无法打印。");
}
const parts = splitBarcodeParts(barcode);
const size = trimText(row.size).replace(/\//g, ".");
return {
barcode,
factoryNumber: trimText(row.factoryModel),
skuName1: `${buildColorCode(row.color)}${size}`,
productCategory: readLeafCategory(row.productCategory),
hangtagMaterial: trimText(row.tagMaterial || row.material),
executionStandard: trimText(row.executeStandard),
printDate: getTodayPrintDate(),
...parts,
};
}
function buildCertificateRecord(row: BarcodePrintRow): CertificateLodopRecord {
const barcode = trimText(row.barcode);
if (!barcode) {
throw new Error("当前行没有条码,无法打印合格证。");
}
return {
barcode,
productName: trimText(row.productName),
skuCode: trimText(row.skuCode),
brandSkuCode: trimText(row.brandSkuCode),
productCategory: readLeafCategory(trimText(row.productCategory)),
color: trimText(row.color),
specification: trimText(row.specification || row.size),
material: trimText(row.certificateMaterial || row.material || row.tagMaterial),
executionStandard: trimText(row.executeStandard),
supplierName: trimText(row.supplierName),
};
}
function loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[data-clodop-src="${src}"]`);
if (existing) {
resolve();
return;
}
const script = document.createElement("script");
const timer = window.setTimeout(() => {
script.remove();
reject(new Error("C-Lodop 加载超时"));
}, 1800);
script.src = src;
script.async = true;
script.dataset.clodopSrc = src;
script.onload = () => {
window.clearTimeout(timer);
resolve();
};
script.onerror = () => {
window.clearTimeout(timer);
script.remove();
reject(new Error("C-Lodop 加载失败"));
};
document.head.appendChild(script);
});
}
async function loadCLodopScript() {
if (window.getCLodop || window.getLodop) return;
if (!lodopScriptLoadPromise) {
lodopScriptLoadPromise = (async () => {
for (const url of CLODOP_SCRIPT_URLS) {
try {
await loadScript(url);
if (window.getCLodop || window.getLodop) return;
} catch {
// Try the next C-Lodop endpoint.
}
}
})();
}
await lodopScriptLoadPromise;
if (!window.getCLodop && !window.getLodop) {
lodopScriptLoadPromise = null;
}
}
async function getLodop() {
await loadCLodopScript();
const lodop = window.getLodop?.() ?? window.getCLodop?.() ?? window.LODOP;
if (!lodop) {
throw new Error("未检测到 C-Lodop 打印插件。请下载安装 C-Lodop,确认插件服务已启动,然后刷新页面再打印。");
}
window.LODOP = lodop;
return lodop;
}
function setFont(lodop: Lodop, size: number, bold = false) {
lodop.SET_PRINT_STYLEA(0, "FontName", "微软雅黑");
lodop.SET_PRINT_STYLEA(0, "FontSize", size);
if (bold) lodop.SET_PRINT_STYLEA(0, "Bold", 1);
}
function printCommonLayout(
record: BarcodeLodopRecord,
lodop: Lodop,
options?: {
splitCode?: boolean;
hideStandard?: boolean;
labelPrefix?: string;
barcodeTextHeight?: number;
barcodeFontSize?: number;
productionBatchLeft?: number;
},
) {
lodop.PRINT_INIT("条码打印");
lodop.ADD_PRINT_BARCODE(15, 22, "100.01mm", "45.01mm", "QRCode", record.barcode);
lodop.SET_PRINT_STYLEA(0, "ShowBarText", 0);
lodop.ADD_PRINT_TEXT(26, 181, 304, 25, `型号:${record.factoryNumber}`);
setFont(lodop, 12);
lodop.ADD_PRINT_TEXT(59, 181, 415, 75, `${options?.labelPrefix ?? ""}${record.skuName1}`);
setFont(lodop, 15);
lodop.SET_PRINT_STYLEA(0, "TextNeatRow", 1);
lodop.ADD_PRINT_TEXT(26, 429, 198, 25, `类目:${record.productCategory}`);
setFont(lodop, 15);
lodop.ADD_PRINT_TEXT(145, 181, 407, 25, `材质:${record.hangtagMaterial}`);
setFont(lodop, 15);
lodop.ADD_PRINT_TEXT(179, 26, 160, 25, "执行标准");
setFont(lodop, 15);
lodop.ADD_PRINT_TEXT(203, 14, 195, 25, options?.hideStandard ? "" : record.executionStandard);
setFont(lodop, 15);
if (options?.splitCode) {
lodop.ADD_PRINT_TEXT(203, 191, 150, 25, record.catcode);
setFont(lodop, 15);
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
lodop.ADD_PRINT_TEXT(186, 274, 230, 52, record.bar2);
setFont(lodop, 28, true);
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
lodop.ADD_PRINT_TEXT(203, 474, 141, 25, record.bar3);
setFont(lodop, 15);
} else {
lodop.ADD_PRINT_TEXT(203, 191, 800, options?.barcodeTextHeight ?? 25, record.barcode);
setFont(lodop, options?.barcodeFontSize ?? 25, true);
}
lodop.ADD_PRINT_TEXT(182, options?.productionBatchLeft ?? 438, 172, 25, `生产批号:${record.printDate}`);
lodop.SET_PRINT_STYLEA(0, "FontName", "微软雅黑");
lodop.ADD_PRINT_RECT(20, 178, 420, 157, 0, 1);
lodop.ADD_PRINT_RECT(56, 178, 420, 1, 0, 1);
lodop.ADD_PRINT_RECT(135, 178, 420, 1, 0, 1);
lodop.ADD_PRINT_RECT(21, 426, 1, 34, 0, 1);
}
const BARCODE_PRINT_TEMPLATES: BarcodePrintTemplate[] = [
{
title: "条码打印_第一套编码(数字开头)&一次性采买条码",
printFunc(record, lodop) {
printCommonLayout(record, lodop);
},
},
{
title: "条码打印_第二套模板",
printFunc(record, lodop) {
printCommonLayout(record, lodop, {
splitCode: true,
labelPrefix: "编码:",
productionBatchLeft: 458,
});
},
},
{
title: "外贸条码打印",
printFunc(record, lodop) {
printCommonLayout(record, lodop, {
hideStandard: true,
barcodeTextHeight: 20,
barcodeFontSize: 20,
});
},
},
{
title: "顾家",
printFunc(record, lodop, options) {
lodop.PRINT_INIT("条码打印");
lodop.SET_PRINT_PAGESIZE?.(2, "60mm", "40mm", "");
lodop.ADD_PRINT_BARCODE("4mm", "10mm", "25mm", "25mm", "QRCode", record.barcode);
lodop.SET_PRINT_STYLEA(0, "ShowBarText", 0);
lodop.ADD_PRINT_TEXT("27mm", "0mm", "100%", "1mm", record.catcode);
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
setFont(lodop, 10, true);
lodop.ADD_PRINT_TEXT("32mm", "0mm", "100%", "1mm", record.bar2);
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
setFont(lodop, 20, true);
lodop.ADD_PRINT_TEXT("44mm", "0mm", "100%", "1mm", record.bar3);
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
setFont(lodop, 10, true);
if (options?.batchNumber?.trim()) {
lodop.ADD_PRINT_TEXT("48mm", "0mm", "100%", "1mm", `(${options.batchNumber.trim()})`);
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
setFont(lodop, 10, true);
}
},
},
];
export const BARCODE_PRINT_TEMPLATE_OPTIONS = BARCODE_PRINT_TEMPLATES.map(
({ title }) => ({ title }),
);
const CERTIFICATE_PRINT_TEMPLATES: CertificatePrintTemplate[] = [
{
title: "朴美横版贴纸(10cm*7cm)",
width: "100mm",
height: "70mm",
orientation: 2,
canvasWidth: 1181,
canvasHeight: 827,
background: "/barcode-print/templates/hgz.jpg",
layout: "pumeiLandscape",
qr: { x: 921, y: 44, size: 182 },
},
{
title: "朴美竖版贴纸(10cm*13cm)",
width: "100mm",
height: "130mm",
orientation: 1,
canvasWidth: 1181,
canvasHeight: 1535,
background: "/barcode-print/templates/hgz_v2.jpg",
layout: "pumeiPortrait",
},
{
title: "索菲亚合格证卡片(7.4cm*10cm)",
width: "74mm",
height: "100mm",
orientation: 1,
canvasWidth: 874,
canvasHeight: 1181,
background: "/barcode-print/templates/hgz_sfy.png",
layout: "sofiaCertificate",
},
{
title: "索菲亚外箱贴纸(10cm*7cm)",
width: "100mm",
height: "70mm",
orientation: 2,
canvasWidth: 1181,
canvasHeight: 827,
background: "/barcode-print/templates/wxtz_sfy.jpg",
layout: "sofiaCarton",
},
{
title: "通用横版贴纸-带供应商(10cm*7cm)",
width: "100mm",
height: "70mm",
orientation: 2,
canvasWidth: 1181,
canvasHeight: 827,
background: "/barcode-print/templates/hgz.jpg",
layout: "commonWithMaker",
qr: { x: 921, y: 44, size: 182 },
},
{
title: "通用横版贴纸-不带供应商(10cm*7cm)",
width: "100mm",
height: "70mm",
orientation: 2,
canvasWidth: 1181,
canvasHeight: 827,
background: "/barcode-print/templates/hgz_easy.jpg",
layout: "commonWithoutMaker",
qr: { x: 921, y: 44, size: 182 },
},
{
title: "顾家合格证卡片(11cm*13cm)",
width: "110mm",
height: "130mm",
orientation: 1,
canvasWidth: 1323,
canvasHeight: 1559,
background: "/barcode-print/templates/hgz_gj.jpg",
layout: "gujiaCertificate",
},
];
export const CERTIFICATE_PRINT_TEMPLATE_OPTIONS = CERTIFICATE_PRINT_TEMPLATES.map(
({ title }) => ({ title }),
);
function chooseBarcodeTemplate(row: BarcodePrintRow) {
if (row.supplierName.includes("顾家")) {
return BARCODE_PRINT_TEMPLATES.find((template) => template.title === "顾家") ?? BARCODE_PRINT_TEMPLATES[0];
}
return BARCODE_PRINT_TEMPLATES[0];
}
export async function printBarcodeRow(
row: BarcodePrintRow,
options: {
templateTitle?: BarcodePrintTemplateTitle;
batchNumber?: string;
} = {},
) {
const lodop = await getLodop();
const record = buildLodopRecord(row);
const template = options.templateTitle
? BARCODE_PRINT_TEMPLATES.find((item) => item.title === options.templateTitle)
: chooseBarcodeTemplate(row);
if (!template) {
throw new Error("请选择有效的条码打印模板。");
}
lodop.NEWPAGE();
lodop.SET_PRINT_MODE("PRINT_PAGE_PERCENT", "Full-Page");
template.printFunc(record, lodop, { batchNumber: options.batchNumber });
lodop.PREVIEW();
return template.title;
}
type CertificateCanvasText = {
text: string;
x: number;
y: number;
fontSize: number;
fontWeight: string;
lineHeight: number;
};
function normalizeCertificateText(value: string) {
return value.trim();
}
function formatCertificateSpec(value: string) {
return normalizeCertificateText(value).replace(/\//g, "*");
}
function getCertificateProductLabel(record: CertificateLodopRecord) {
const label = [record.productCategory, record.color]
.map((value) => value.trim())
.filter(Boolean)
.join("-");
return normalizeCertificateText(label || record.productName || record.skuCode);
}
function getLast10DayText() {
const date = new Date();
date.setDate(date.getDate() - 10);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
}
function getCommonMakerText(batchNumber?: string) {
const maker = "常州朴美家居有限公司";
return batchNumber?.trim() ? `${maker}(${batchNumber.trim()})` : maker;
}
function createCertificateText(
text: string,
x: number,
y: number,
options: { fontSize: number; fontWeight?: string; lineHeight?: number },
): CertificateCanvasText {
return {
text: normalizeCertificateText(text),
x,
y,
fontSize: options.fontSize,
fontWeight: options.fontWeight ?? "normal",
lineHeight: options.lineHeight ?? Math.round(options.fontSize * 1.2),
};
}
function getCertificateCanvasTexts(
template: CertificatePrintTemplate,
record: CertificateLodopRecord,
batchNumber?: string,
) {
const productLabel = getCertificateProductLabel(record);
const specification = formatCertificateSpec(record.specification);
const material = normalizeCertificateText(record.material);
const standard = normalizeCertificateText(record.executionStandard);
const last10Day = getLast10DayText();
const commonRows = [
productLabel,
record.barcode,
specification,
material,
"常州朴美家居有限公司",
];
const commonRightRows = [
standard,
"合格",
"B",
"常州经济开发区横林镇红赵路62号",
"19941684324",
];
if (template.layout === "pumeiLandscape") {
return [
...commonRows.map((text, index) =>
createCertificateText(text, 224, 317 + index * 69, {
fontSize: index === 1 ? 26 : 30,
fontWeight: "600",
lineHeight: 32,
}),
),
...commonRightRows.map((text, index) =>
createCertificateText(text, 754, 317 + index * 69, {
fontSize: index === 3 ? 28 : 30,
fontWeight: "600",
lineHeight: 32,
}),
),
];
}
if (template.layout === "pumeiPortrait") {
return [
...commonRows.map((text, index) =>
createCertificateText(text, 224, 882 + index * 69, {
fontSize: 26,
fontWeight: "500",
lineHeight: 32,
}),
),
...commonRightRows.map((text, index) =>
createCertificateText(text, 754, 882 + index * 69, {
fontSize: 26,
fontWeight: "500",
lineHeight: 32,
}),
),
];
}
if (template.layout === "sofiaCertificate") {
return [productLabel, record.skuCode, specification, standard, "QC01", last10Day].map(
(text, index) =>
createCertificateText(text, 310, 398 + index * 66, {
fontSize: 26,
lineHeight: 32,
}),
);
}
if (template.layout === "sofiaCarton") {
return [
createCertificateText(productLabel, 500, 321, {
fontSize: 40,
fontWeight: "bold",
lineHeight: 40,
}),
createCertificateText(record.barcode, 500, 391, {
fontSize: 40,
fontWeight: "bold",
lineHeight: 40,
}),
createCertificateText(record.color, 634, 454, { fontSize: 24, fontWeight: "bold" }),
];
}
if (template.layout === "commonWithMaker") {
const leftRows = [
productLabel,
record.barcode,
specification,
material,
getCommonMakerText(batchNumber),
];
const rightRows = [standard, "合格", "B", "常州经济开发区横林镇红赵路62号", "19941684327"];
return [
...leftRows.map((text, index) =>
createCertificateText(text, 224, 317 + index * 69, {
fontSize: 30,
fontWeight: "600",
lineHeight: 32,
}),
),
...rightRows.map((text, index) =>
createCertificateText(text, 754, 317 + index * 69, {
fontSize: index === 3 ? 28 : 30,
fontWeight: "600",
lineHeight: 32,
}),
),
];
}
if (template.layout === "commonWithoutMaker") {
const leftRows = [productLabel, record.barcode, specification, material];
const rightRows = [standard, "合格", "B"];
return [
...leftRows.map((text, index) =>
createCertificateText(text, 224, 317 + index * 69, {
fontSize: 30,
fontWeight: "600",
lineHeight: 32,
}),
),
...rightRows.map((text, index) =>
createCertificateText(text, 754, 317 + index * 69, {
fontSize: 30,
fontWeight: "600",
lineHeight: 32,
}),
),
];
}
const gujiaRows = [
productLabel,
record.brandSkuCode,
specification,
material,
"顾家家居股份有限公司",
"B",
"合格",
last10Day,
"",
];
return gujiaRows.map((text, index) =>
createCertificateText(text, 455, 530 + index * 101, {
fontSize: 42,
lineHeight: 48,
}),
);
}
function pxToPrintMm(px: number, totalPx: number, totalMm: string) {
const printSize = Number.parseFloat(totalMm);
if (!Number.isFinite(printSize)) return "0mm";
return `${(px / totalPx) * printSize}mm`;
}
function pxToPrintFontPt(px: number, template: CertificatePrintTemplate) {
const printHeightMm = Number.parseFloat(template.height);
if (!Number.isFinite(printHeightMm)) return px;
const fontHeightMm = (px / template.canvasHeight) * printHeightMm;
return Number(((fontHeightMm / 25.4) * 72).toFixed(1));
}
function getCertificateBackgroundUrl(template: CertificatePrintTemplate) {
if (typeof window === "undefined") return template.background;
return new URL(template.background, window.location.origin).toString();
}
function addCertificateTemplateText(
lodop: Lodop,
template: CertificatePrintTemplate,
item: CertificateCanvasText,
) {
lodop.ADD_PRINT_TEXT(
pxToPrintMm(item.y, template.canvasHeight, template.height),
pxToPrintMm(item.x, template.canvasWidth, template.width),
pxToPrintMm(template.canvasWidth - item.x, template.canvasWidth, template.width),
pxToPrintMm(item.lineHeight, template.canvasHeight, template.height),
item.text,
);
lodop.SET_PRINT_STYLEA(0, "FontName", "Microsoft YaHei");
lodop.SET_PRINT_STYLEA(0, "FontSize", pxToPrintFontPt(item.fontSize, template));
lodop.SET_PRINT_STYLEA(
0,
"Bold",
item.fontWeight === "bold" || Number.parseInt(item.fontWeight, 10) >= 600
? 1
: 0,
);
}
function printCertificateLayout(
record: CertificateLodopRecord,
lodop: Lodop,
template: CertificatePrintTemplate,
options: { batchNumber?: string } = {},
) {
const backgroundUrl = getCertificateBackgroundUrl(template);
lodop.PRINT_INIT("合格证打印");
lodop.SET_PRINT_PAGESIZE?.(template.orientation, template.width, template.height, "");
lodop.ADD_PRINT_IMAGE(
"0mm",
"0mm",
template.width,
template.height,
`<img border="0" src="${backgroundUrl}" style="width:100%;height:100%;" />`,
);
lodop.SET_PRINT_STYLEA(0, "Stretch", 2);
for (const item of getCertificateCanvasTexts(template, record, options.batchNumber)) {
addCertificateTemplateText(lodop, template, item);
}
if (template.qr) {
const qrLeft = pxToPrintMm(template.qr.x, template.canvasWidth, template.width);
const qrTop = pxToPrintMm(template.qr.y, template.canvasHeight, template.height);
const qrWidth = pxToPrintMm(template.qr.size, template.canvasWidth, template.width);
const qrHeight = pxToPrintMm(template.qr.size, template.canvasHeight, template.height);
lodop.ADD_PRINT_BARCODE(qrTop, qrLeft, qrWidth, qrHeight, "QRCode", record.barcode);
lodop.SET_PRINT_STYLEA(0, "ShowBarText", 0);
}
}
export async function printCertificateRow(
row: BarcodePrintRow,
templateTitle?: CertificatePrintTemplateTitle,
options: { batchNumber?: string } = {},
) {
const lodop = await getLodop();
const record = buildCertificateRecord(row);
const template = templateTitle
? CERTIFICATE_PRINT_TEMPLATES.find((item) => item.title === templateTitle)
: CERTIFICATE_PRINT_TEMPLATES[0];
if (!template) {
throw new Error("请选择有效的合格证模板。");
}
lodop.NEWPAGE();
lodop.SET_PRINT_MODE("PRINT_PAGE_PERCENT", "Full-Page");
printCertificateLayout(record, lodop, template, options);
lodop.PREVIEW();
return template.title;
}

View File

@ -0,0 +1,35 @@
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { listSharedNonIntegratedProductPage } from "@/app/intake/non-integrated/nonIntegratedApi";
const BARCODE_PRINT_PRODUCT_REFRESH_PAGE_SIZE = 1000;
export async function refreshBarcodePrintProducts(): Promise<
NonIntegratedProductRecord[]
> {
const firstPage = await listSharedNonIntegratedProductPage({
page: 1,
pageSize: BARCODE_PRINT_PRODUCT_REFRESH_PAGE_SIZE,
productKind: "all",
quickScope: "all",
});
if (firstPage.pageInfo.totalPages <= 1) {
return firstPage.products;
}
const restPages = await Promise.all(
Array.from({ length: firstPage.pageInfo.totalPages - 1 }, (_, index) =>
listSharedNonIntegratedProductPage({
page: index + 2,
pageSize: BARCODE_PRINT_PRODUCT_REFRESH_PAGE_SIZE,
productKind: "all",
quickScope: "all",
}),
),
);
return [
...firstPage.products,
...restPages.flatMap((page) => page.products),
];
}

View File

@ -0,0 +1,13 @@
import type { ImportProgressState } from "./barcodePrintTypes";
export const EMPTY_IMPORT_PROGRESS: ImportProgressState = {
active: false,
label: "",
percent: 0,
status: "idle",
message: "",
};
export function waitForProgressFrame() {
return new Promise((resolve) => window.setTimeout(resolve, 120));
}

View File

@ -0,0 +1,220 @@
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
export type BarcodePrintRow = {
id: string;
importRecordIds?: string[];
spu: string;
model: string;
specCode: string;
setCount: string;
skuCode: string;
brandSkuCode: string;
productName: string;
materialNo: string;
specification: string;
certificateMaterial: string;
barcode: string;
factoryModel: string;
attributeCode: string;
drawingId: string;
barcodeSpecName: string;
productCategory: string;
salesCategory: string;
materialType: string;
size: string;
color: string;
material: string;
executeStandard: string;
tagMaterial: string;
supplierName: string;
warehouse: string;
installationService: string;
lengthCm: string;
widthCm: string;
heightCm: string;
volumeCm3: string;
weightKg: string;
costPrice: string;
productPlanning: string;
visualPlanning: string;
spuOwner: string;
operationsOwner: string;
purchaseOwner: string;
productionType: string;
shippingMode: string;
supplierCategory: string;
purchaseOrderMethod: string;
assemblyDetail: string;
createdAt: string;
dataSource: string;
};
function firstText(...values: Array<string | undefined | null>) {
return values.find((value) => value?.trim())?.trim() ?? "";
}
function calculateVolumeCm3(lengthCm: string, widthCm: string, heightCm: string) {
const length = Number.parseFloat(lengthCm);
const width = Number.parseFloat(widthCm);
const height = Number.parseFloat(heightCm);
if (!Number.isFinite(length) || !Number.isFinite(width) || !Number.isFinite(height)) {
return "";
}
const volume = length * width * height;
if (!Number.isFinite(volume)) return "";
return Number.isInteger(volume) ? String(volume) : volume.toFixed(2);
}
function normalizeCreatedAt(value: string) {
if (!value.trim()) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toISOString().slice(0, 10);
}
function isTwoPieceSet(value: string) {
return Number.parseInt(value.trim(), 10) === 2;
}
function stripPackageSuffix(skuCode: string, packageSuffix: string) {
const normalizedSkuCode = skuCode.trim();
const normalizedPackageSuffix = packageSuffix.trim().toUpperCase();
if (
normalizedPackageSuffix &&
normalizedSkuCode.toUpperCase().endsWith(normalizedPackageSuffix)
) {
return normalizedSkuCode.slice(0, -normalizedPackageSuffix.length);
}
const fallbackMatch = normalizedSkuCode.match(/^(.+\d)[A-Z]$/i);
return fallbackMatch?.[1] ?? normalizedSkuCode;
}
function buildSpecCode({
packageSuffix,
setCount,
skuCode,
}: {
packageSuffix: string;
setCount: string;
skuCode: string;
}) {
if (!skuCode.trim() || !isTwoPieceSet(setCount)) return "";
return stripPackageSuffix(skuCode, packageSuffix);
}
function buildFactoryModel(model: string, supplierCode: string) {
const normalizedModel = model.trim();
const normalizedSupplierCode = supplierCode.trim().toUpperCase();
if (
!normalizedSupplierCode ||
normalizedModel.toUpperCase().includes(normalizedSupplierCode)
) {
return normalizedModel;
}
return `${normalizedModel}${normalizedSupplierCode}`;
}
export function buildBarcodePrintRows(
products: NonIntegratedProductRecord[],
): BarcodePrintRow[] {
return products.flatMap((product) => {
const enteredPricingRowBySkuId = new Map(
product.pricingRows
.filter((row) => row.systemEnteredAt?.trim())
.map((row) => [row.skuId, row]),
);
const skuItems = product.skuItems.filter((sku) =>
enteredPricingRowBySkuId.has(sku.id),
);
return skuItems.map((sku, index) => {
const skuId = sku.id;
const factoryRow = product.factoryRows.find((row) => row.skuId === skuId);
const measurementRow = product.sampleMeasurementRows.find(
(row) => row.skuId === skuId,
);
const pricingRow = enteredPricingRowBySkuId.get(skuId);
const lengthCm = measurementRow?.lengthCm?.trim() ?? "";
const widthCm = measurementRow?.widthCm?.trim() ?? "";
const heightCm = measurementRow?.heightCm?.trim() ?? "";
const barcode = firstText(factoryRow?.barcode, sku?.barcode);
const skuCode = firstText(factoryRow?.skuCode, sku?.skuCode);
const setCount = firstText(factoryRow?.setCount, sku?.setCount);
const model = firstText(sku?.model, factoryRow?.model, product.productCode);
const supplierCode = firstText(
factoryRow?.supplierCode,
sku?.manufacturerCode,
);
const material = firstText(sku?.material, product.openingReference.material);
const grabbedCategory = firstText(
product.openingReference.productCategory,
product.productCategory,
);
return {
id: `${product.id}:${skuId}:${index}`,
spu: product.spu,
model,
specCode: buildSpecCode({
packageSuffix: sku?.packageSuffix ?? "",
setCount,
skuCode,
}),
setCount,
skuCode,
brandSkuCode: "",
productName: "",
materialNo: "",
specification: "",
certificateMaterial: "",
barcode,
factoryModel: buildFactoryModel(model, supplierCode),
attributeCode: sku?.attributeCode?.trim() ?? "",
drawingId: sku?.drawingId?.trim() ?? "",
barcodeSpecName: firstText(factoryRow?.specName, sku?.specName),
productCategory: grabbedCategory,
salesCategory: grabbedCategory,
materialType: firstText(product.materialType, sku?.boardMaterialType),
size: firstText(sku?.size, product.size),
color: firstText(sku?.color, product.color),
material,
executeStandard: firstText(factoryRow?.executeStandard, sku?.executeStandard),
tagMaterial: material,
supplierName: firstText(factoryRow?.supplierName, sku?.manufacturer),
warehouse: factoryRow?.warehouse?.trim() ?? "",
installationService: factoryRow?.installationService?.trim() ?? "",
lengthCm,
widthCm,
heightCm,
volumeCm3: calculateVolumeCm3(lengthCm, widthCm, heightCm),
weightKg: measurementRow?.weightKg?.trim() ?? "",
costPrice: pricingRow?.costPrice?.trim() ?? "",
productPlanning: product.productPlanning,
visualPlanning: product.visualPlanning,
spuOwner: product.spuOwner,
operationsOwner: firstText(product.operationsOwner, product.operator),
purchaseOwner: firstText(product.purchaseOwner, product.purchaser),
productionType: firstText(factoryRow?.productionType, product.productionType),
shippingMode: firstText(factoryRow?.shippingMethod, product.shippingMode),
supplierCategory: factoryRow?.supplierCategory?.trim() ?? "",
purchaseOrderMethod: factoryRow?.purchaseOrderMethod?.trim() ?? "",
assemblyDetail: firstText(
factoryRow?.installMethod,
sku?.installMethod,
product.installMethod,
),
createdAt: normalizeCreatedAt(pricingRow?.systemEnteredAt ?? ""),
dataSource: firstText(sku?.sourceTable, sku?.source, "流程"),
};
});
});
}

View File

@ -0,0 +1,73 @@
import type {
BarcodePrintColumn,
BarcodePrintColumnKey,
BarcodePrintColumnWidths,
} from "./barcodePrintTypes";
export const BARCODE_PRINT_PAGE_SIZE = 20;
export const SELECTION_COLUMN_WIDTH = 34;
export const ROW_NUMBER_COLUMN_WIDTH = 46;
export const ROW_NUMBER_COLUMN_MIN_WIDTH = 46;
export const OPERATION_COLUMN_WIDTH = 132;
export const BARCODE_PRINT_COLUMN_MIN_WIDTH = 56;
export const BARCODE_PRINT_COLUMNS: BarcodePrintColumn[] = [
{ key: "spu", label: "SPU", width: 92 },
{ key: "model", label: "型号", width: 104 },
{ key: "specCode", label: "规格码", width: 112 },
{ key: "setCount", label: "套件量", width: 68 },
{ key: "skuCode", label: "货号", width: 112 },
{ key: "brandSkuCode", label: "品牌货号", width: 112 },
{ key: "productName", label: "品名", width: 120 },
{ key: "materialNo", label: "料号", width: 112 },
{ key: "specification", label: "规格(长*宽*高)", width: 136 },
{ key: "certificateMaterial", label: "合格证的主材*基材", width: 150 },
{ key: "barcode", label: "条码", width: 132 },
{ key: "factoryModel", label: "工厂型号", width: 120 },
{ key: "attributeCode", label: "属性代码", width: 84 },
{ key: "drawingId", label: "图纸ID", width: 124 },
{ key: "barcodeSpecName", label: "条码规格名称", width: 170 },
{ key: "productCategory", label: "商品类目", width: 96 },
{ key: "salesCategory", label: "销售类目", width: 96 },
{ key: "materialType", label: "材质类型", width: 96 },
{ key: "size", label: "尺寸", width: 112 },
{ key: "color", label: "颜色", width: 104 },
{ key: "material", label: "材质", width: 150 },
{ key: "executeStandard", label: "执行标准", width: 126 },
{ key: "tagMaterial", label: "吊牌材质", width: 104 },
{ key: "supplierName", label: "供应商名称", width: 126 },
{ key: "warehouse", label: "发货仓库", width: 126 },
{ key: "installationService", label: "安装服务", width: 88 },
{ key: "lengthCm", label: "长(CM)", width: 78 },
{ key: "widthCm", label: "宽(CM)", width: 78 },
{ key: "heightCm", label: "高(CM)", width: 78 },
{ key: "volumeCm3", label: "体积", width: 96 },
{ key: "weightKg", label: "重量(KG)", width: 88 },
{ key: "costPrice", label: "成本价", width: 92 },
{ key: "productPlanning", label: "商品策划", width: 112 },
{ key: "visualPlanning", label: "视觉策划", width: 112 },
{ key: "spuOwner", label: "SPU负责人", width: 112 },
{ key: "operationsOwner", label: "运营负责人", width: 112 },
{ key: "purchaseOwner", label: "采购负责人", width: 112 },
{ key: "productionType", label: "产品生产类型", width: 112 },
{ key: "shippingMode", label: "发货模式", width: 92 },
{ key: "supplierCategory", label: "供应商分类", width: 112 },
{ key: "purchaseOrderMethod", label: "采购下单方式", width: 112 },
{ key: "assemblyDetail", label: "整装/拆装", width: 92 },
{ key: "createdAt", label: "创建时间", width: 112 },
{ key: "dataSource", label: "数据源", width: 90 },
];
export function buildInitialBarcodePrintColumnWidths(): BarcodePrintColumnWidths {
return Object.fromEntries(
BARCODE_PRINT_COLUMNS.map((column) => [column.key, column.width]),
) as BarcodePrintColumnWidths;
}
export function getBarcodePrintColumnMinWidth(key: BarcodePrintColumnKey) {
const column = BARCODE_PRINT_COLUMNS.find((item) => item.key === key);
return Math.min(
column?.width ?? BARCODE_PRINT_COLUMN_MIN_WIDTH,
BARCODE_PRINT_COLUMN_MIN_WIDTH,
);
}

View File

@ -0,0 +1,39 @@
import type { BarcodePrintRow } from "./barcodePrintRows";
export type BarcodePrintFilterState = {
spu: string;
model: string;
skuCode: string;
brandSkuCode: string;
barcode: string;
operationsOwner: string;
spuOwner: string;
productPlanning: string;
purchaseOwner: string;
productCategory: string;
salesCategory: string;
supplierName: string;
createdFrom: string;
createdTo: string;
};
export type BarcodePrintColumnKey = Exclude<
keyof BarcodePrintRow,
"id" | "importRecordIds"
>;
export type BarcodePrintColumn = {
key: BarcodePrintColumnKey;
label: string;
width: number;
};
export type BarcodePrintColumnWidths = Record<BarcodePrintColumnKey, number>;
export type ImportProgressState = {
active: boolean;
label: string;
percent: number;
status: "idle" | "reading" | "validating" | "importing" | "success" | "error";
message: string;
};

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AccessoryLibraryPanel from '@/app/intake/AccessoryLibraryPanel'
import PackagingMaterialLibraryPanel from '@/app/intake/PackagingMaterialLibraryPanel'
import AccessoryLibraryPanel from '@/app/intake/bom-library/AccessoryLibraryPanel'
import PackagingMaterialLibraryPanel from '@/app/intake/bom-library/PackagingMaterialLibraryPanel'
const LEGACY_PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY =
'formal-intake-packaging-material-library-items'
@ -102,6 +102,10 @@ function readLastReferenceLibraryPutBody(fetchSpy: jest.SpyInstance) {
}
}
function readSavedAccessoryItems(fetchSpy: jest.SpyInstance) {
const savedBody = readLastReferenceLibraryPutBody(fetchSpy)
return (savedBody.items ?? []).map((item) => item.payload?.item ?? {})
}
async function waitAccessoryLibraryReady() {
return screen.findByText(/已接入服务端资料库|服务端暂无配件资料/)
}
@ -270,6 +274,7 @@ describe('alphabet library panels', () => {
it('uses embedded nut rules for E accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -319,9 +324,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('M6*13 无槽 白色 塑料 胶粒螺母')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'E',
code: 'E0',
@ -340,6 +343,7 @@ describe('alphabet library panels', () => {
it('uses hinge rules for F accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -380,9 +384,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('3寸 银色 304不锈钢 合页')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'F',
code: 'F0',
@ -399,6 +401,7 @@ describe('alphabet library panels', () => {
it('uses slide rail rules for G accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -444,9 +447,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('18寸/450mm 缓冲反弹 托底滑轨')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'G',
code: 'G0',
@ -461,6 +462,7 @@ describe('alphabet library panels', () => {
it('uses nut and cap nut rules for H accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -510,9 +512,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('M6 20mm 镀锌 碳钢 套筒螺母')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'H',
code: 'H0',
@ -533,6 +533,7 @@ describe('alphabet library panels', () => {
it('uses caster wheel rules for I accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -593,9 +594,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('3寸/75mm 平板 无刹车 橡胶 定向轮')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'I',
code: 'I0',
@ -652,9 +651,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('无螺杆 150mm 金色 铝合金 家具脚')).toBeInTheDocument()
})
const savedItemsAfterLeg = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItemsAfterLeg = readSavedAccessoryItems(fetchSpy)
expect(savedItemsAfterLeg[1]).toMatchObject({
letter: 'I',
code: 'I1',
@ -674,6 +671,7 @@ describe('alphabet library panels', () => {
it('uses handle rules for J accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -724,9 +722,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('单孔 30mm 金色 锌合金 拉手')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'J',
code: 'J0',
@ -746,6 +742,7 @@ describe('alphabet library panels', () => {
it('uses shelf support rules for K accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -798,9 +795,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('6mm 20mm 透明 塑料 层板托')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'K',
code: 'K0',
@ -820,6 +815,7 @@ describe('alphabet library panels', () => {
it('uses angle bracket and connector plate rules for L accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -877,9 +873,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('120*25 2.2mm 一字 黑色 铁 连接片')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'L',
code: 'L0',
@ -899,6 +893,7 @@ describe('alphabet library panels', () => {
it('uses door catch and rebounder rules for M accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -957,9 +952,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('短款 灰色 塑料 反弹器')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'M',
code: 'M0',
@ -979,6 +972,7 @@ describe('alphabet library panels', () => {
it('uses sliding door wheel rules inside M accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1019,9 +1013,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('上轮 25mm 白色 塑料 移门轮')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'M',
code: 'M0',
@ -1041,6 +1033,7 @@ describe('alphabet library panels', () => {
it('uses plug and sticker rules for N accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1102,9 +1095,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('18mm 长方形 木纹色 PVC 孔贴')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'N',
code: 'N0',
@ -1122,6 +1113,7 @@ describe('alphabet library panels', () => {
it('uses wooden dowel rules for O accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1173,9 +1165,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('10mm*50mm 榉木 直纹 圆木榫')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'O',
code: 'O0',
@ -1196,6 +1186,7 @@ describe('alphabet library panels', () => {
it('uses flap door and support rules for P accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1272,9 +1263,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('双层 260*320mm 黑色 铁 翻斗铰链')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'P',
code: 'P0',
@ -1295,6 +1284,7 @@ describe('alphabet library panels', () => {
it('uses cable box and vent hole rules for Q accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1348,9 +1338,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('60mm 银色 铁 圆形 透气孔')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'Q',
code: 'Q0',
@ -1371,6 +1359,7 @@ describe('alphabet library panels', () => {
it('uses hook rules for R accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1422,9 +1411,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('中号 S型 黑色 铁 挂入 挂钩')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'R',
code: 'R0',
@ -1445,6 +1432,7 @@ describe('alphabet library panels', () => {
it('uses clip and block rules for S accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1511,9 +1499,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('门挡 小号 黑色 铁 挡块')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'S',
code: 'S0',
@ -1534,6 +1520,7 @@ describe('alphabet library panels', () => {
it('uses gasket and foot pad rules for T accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1589,9 +1576,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('防滑 圆形 12*4mm 黑色 橡胶 脚垫')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'T',
code: 'T0',
@ -1612,6 +1597,7 @@ describe('alphabet library panels', () => {
it('uses auxiliary fastener rules for U accessories', async () => {
const actor = userEvent.setup()
const fetchSpy = mockReferenceLibraryFetchWithState()
render(<AccessoryLibraryPanel />)
@ -1668,9 +1654,7 @@ describe('alphabet library panels', () => {
expect(screen.getByText('粘贴 3M 透明 胶 粘贴件')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
const savedItems = readSavedAccessoryItems(fetchSpy)
expect(savedItems[0]).toMatchObject({
letter: 'U',
code: 'U0',

View File

@ -0,0 +1,145 @@
"use client";
import { Layers3, Package, Palette } from "lucide-react";
import MaterialLibraryClient from "@/app/asset-center/material-library/MaterialLibraryClient";
import AccessoryLibraryPanel from "./AccessoryLibraryPanel";
import EdgeBandingLibraryPanel from "./EdgeBandingLibraryPanel";
import PackagingMaterialLibraryPanel from "./PackagingMaterialLibraryPanel";
import SteelFrameLibraryPanel from "./SteelFrameLibraryPanel";
import {
isIntakeNonIntegratedDesignDepartmentUser,
} from "@/lib/non-integrated-permissions";
import type {
MaterialCategoryDTO,
MaterialDTO,
SubstrateDictionaryBundleDTO,
SubstrateDTO,
} from "@/server/services";
export type BomLibraryKey =
| "materialLibrary"
| "finishLibrary"
| "edgeBandingLibrary"
| "steelFrameLibrary"
| "packagingLibrary"
| "packagingMaterialLibrary";
type BomLibraryDomainProps = {
user: {
role?: string;
businessRoles?: string[] | null;
department?: { name?: string | null; fullPath?: string | null } | null;
departmentPaths?: string[] | null;
};
canEditLibraries?: boolean;
activeBomLibrary: BomLibraryKey;
materialLibrarySubstrates: SubstrateDTO[];
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
onMaterialLibraryFinishesChange?: (finishes: MaterialDTO[]) => void;
};
export function BomLibraryDomain({
user,
canEditLibraries,
activeBomLibrary,
materialLibrarySubstrates,
materialLibraryFinishes,
materialLibraryCategories,
materialLibraryDictionaries,
onMaterialLibraryFinishesChange,
}: BomLibraryDomainProps) {
const canEditBomLibraries =
canEditLibraries ??
(user.role === "SUPER_ADMIN" ||
user.role === "ADMIN" ||
isIntakeNonIntegratedDesignDepartmentUser(user));
if (activeBomLibrary === "materialLibrary" || activeBomLibrary === "finishLibrary") {
const visibleTab = activeBomLibrary === "materialLibrary" ? "substrate" : "finish";
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<MaterialLibraryClient
key={activeBomLibrary}
embedded
canEditOverride={canEditBomLibraries}
initialActiveTab={visibleTab}
visibleTabs={[visibleTab]}
initialSubstrates={materialLibrarySubstrates}
initialFinishes={materialLibraryFinishes}
initialCategories={materialLibraryCategories}
initialDictionaries={materialLibraryDictionaries}
onFinishesChange={onMaterialLibraryFinishesChange}
user={user}
metrics={[
...(visibleTab === "substrate"
? [
{
label: "板材基材",
value: materialLibrarySubstrates.length,
icon: <Package className="h-4 w-4" />,
},
]
: [
{
label: "饰面花色",
value: materialLibraryFinishes.length,
icon: <Palette className="h-4 w-4" />,
},
{
label: "花色分类",
value: materialLibraryCategories.length,
icon: <Layers3 className="h-4 w-4" />,
},
]),
]}
/>
</div>
);
}
if (activeBomLibrary === "steelFrameLibrary") {
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex-1 overflow-y-auto">
<SteelFrameLibraryPanel canEditItems={canEditBomLibraries} />
</div>
</div>
);
}
if (activeBomLibrary === "edgeBandingLibrary") {
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex-1 overflow-y-auto">
<EdgeBandingLibraryPanel canEditItems={canEditBomLibraries} />
</div>
</div>
);
}
if (activeBomLibrary === "packagingMaterialLibrary") {
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex-1 overflow-y-auto">
<PackagingMaterialLibraryPanel canEditItems={canEditBomLibraries} />
</div>
</div>
);
}
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex-1 overflow-y-auto">
<AccessoryLibraryPanel
canEditItems={canEditBomLibraries}
canDeleteItems={
canEditBomLibraries &&
(user.role === "SUPER_ADMIN" || user.role === "ADMIN")
}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { FileSpreadsheet } from "lucide-react";
import { AccordionSection } from "@/components/platform/AccordionSection";
import {
FORMAL_SIDEBAR_SECTION_ICON_CLASS,
FormalSidebarIcon,
} from "@/app/intake/components/FormalSidebarIcon";
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import { BOM_LIBRARY_OPTIONS } from "@/app/intake/bom-library/bomLibraryConfig";
import type { IntakeSection } from "@/app/intake/intakeWorkspaceTypes";
export function BomLibraryWorkbenchSection({
activeSection,
activeBomLibrary,
canViewBomLibrary,
onToggle,
onBomLibraryChange,
}: {
activeSection: IntakeSection;
activeBomLibrary: BomLibraryKey;
canViewBomLibrary: boolean;
onToggle: (section: Exclude<IntakeSection, null>) => void;
onBomLibraryChange: (library: BomLibraryKey) => void;
}) {
if (!canViewBomLibrary) return null;
return (
<AccordionSection
title="BOM资料库"
compact
icon={
<FormalSidebarIcon
icon={FileSpreadsheet}
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
}
expanded={activeSection === "bom"}
onToggle={() => onToggle("bom")}
>
<div className="space-y-2">
{BOM_LIBRARY_OPTIONS.map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
onBomLibraryChange(option.key);
}}
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "bom" && activeBomLibrary === option.key
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
<span>{option.label}</span>
</button>
))}
</div>
</AccordionSection>
);
}

View File

@ -0,0 +1,58 @@
"use client";
import { BomLibraryDomain } from "@/app/intake/bom-library/BomLibraryDomain";
import type { IntakeWorkspaceTab } from "@/app/intake/intakeWorkspaceTypes";
import type {
MaterialCategoryDTO,
MaterialDTO,
SubstrateDictionaryBundleDTO,
SubstrateDTO,
} from "@/server/services";
type BomLibraryWorkspaceTab = Extract<IntakeWorkspaceTab, { kind: "bomLibrary" }>;
type BomLibraryWorkspaceUser = {
role?: string;
businessRoles?: string[] | null;
department?: { name?: string | null; fullPath?: string | null } | null;
departmentPaths?: string[] | null;
};
export function isBomLibraryWorkspaceTab(
tab: IntakeWorkspaceTab,
): tab is BomLibraryWorkspaceTab {
return tab.kind === "bomLibrary";
}
export function BomLibraryWorkspaceContent({
activeWorkspaceTab,
currentUser,
canEditBomLibrary,
materialLibrarySubstrates,
materialLibraryFinishes,
materialLibraryCategories,
materialLibraryDictionaries,
onMaterialLibraryFinishesChange,
}: {
activeWorkspaceTab: BomLibraryWorkspaceTab;
currentUser: BomLibraryWorkspaceUser;
canEditBomLibrary: boolean;
materialLibrarySubstrates: SubstrateDTO[];
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
onMaterialLibraryFinishesChange: (finishes: MaterialDTO[]) => void;
}) {
return (
<BomLibraryDomain
user={currentUser}
canEditLibraries={canEditBomLibrary}
activeBomLibrary={activeWorkspaceTab.bomLibrary}
materialLibrarySubstrates={materialLibrarySubstrates}
materialLibraryFinishes={materialLibraryFinishes}
materialLibraryCategories={materialLibraryCategories}
materialLibraryDictionaries={materialLibraryDictionaries}
onMaterialLibraryFinishesChange={onMaterialLibraryFinishesChange}
/>
);
}

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EdgeBandingLibraryPanel from '@/app/intake/EdgeBandingLibraryPanel'
import EdgeBandingLibraryPanel from '@/app/intake/bom-library/EdgeBandingLibraryPanel'
import { createEdgeBandingLibraryItem } from '@/lib/edge-banding-library'
const LEGACY_EDGE_BANDING_LIBRARY_STORAGE_KEY =

View File

@ -1,6 +1,6 @@
'use client'
import AlphabetLibraryPanel from '@/app/intake/AlphabetLibraryPanel'
import AlphabetLibraryPanel from './AlphabetLibraryPanel'
import { PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT } from '@/lib/packaging-material-library'
export default function PackagingMaterialLibraryPanel({

View File

@ -0,0 +1,56 @@
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
export const BOM_LIBRARY_OPTIONS: {
key: BomLibraryKey;
label: string;
description: string;
helper: string;
}[] = [
{
key: "materialLibrary",
label: "板材资料库",
description:
"只展示板材基材,方便在产品资料 BOM 下维护板材本体资料。",
helper:
"数据仍复用资产中心基础资料库,系列和饰面花色入口在这里不显示。",
},
{
key: "finishLibrary",
label: "花色资料库",
description:
"只展示饰面花色,方便在产品资料 BOM 下维护花色、贴图和参数。",
helper:
"数据仍复用资产中心基础资料库,板材基材入口在这里不显示。",
},
{
key: "edgeBandingLibrary",
label: "封边资料库",
description:
"按板材厚度维护封边条,细节资料里的封边自动项会按对应厚度筛选并带出单价。",
helper:
"维护规格名称、规格、单位、单价和适配板材厚度;封边行仍可在细节资料里手动改单价。",
},
{
key: "steelFrameLibrary",
label: "钢架资料库",
description:
"不再预置系统钢架规格,只保留每吨钢价与人工新建规格,系统会自动重算重量、支数和每米单价。",
helper:
"当前按名称、长、宽、壁厚录入,保存后的规格会直接参与钢架 BOM 匹配,并随每吨钢价联动刷新。",
},
{
key: "packagingLibrary",
label: "配件资料库",
description:
"产品资料下独立维护的配件资料区,按新的业务口径重新配置,不复用原包材资料逻辑。",
helper: "适合维护配件名称、配件编码、分类规则和后续要补充的独立资料。",
},
{
key: "packagingMaterialLibrary",
label: "包材资料库",
description:
"产品资料下独立维护的包材资料区,沿用和配件资料库相同的字母分组逻辑,但单独保存数据。",
helper:
"适合维护纸箱、泡沫、护角等包材名称、编码、图片和价格,不会和配件资料串在一起。",
},
];

View File

@ -0,0 +1,136 @@
"use client";
import { useEffect, useRef } from "react";
import { BarcodePrintWorkbenchButton } from "@/app/intake/barcode-print/BarcodePrintWorkbenchButton";
import { BomLibraryWorkbenchSection } from "@/app/intake/bom-library/BomLibraryWorkbenchSection";
import type {
IntakeEntryTab,
IntakeSection,
} from "@/app/intake/intakeWorkspaceTypes";
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import { ProductDevelopmentWorkbenchSection } from "@/app/intake/product-development/ProductDevelopmentWorkbenchSection";
import { ProductPatentWorkbenchSection } from "@/app/intake/product-patent/ProductPatentWorkbenchSection";
import type { RawMaterialLibraryKey } from "@/app/intake/raw-material-library/RawMaterialLibraryDomain";
import { RawMaterialLibraryWorkbenchSection } from "@/app/intake/raw-material-library/RawMaterialLibraryWorkbenchSection";
import { ReferenceEstimatorWorkbenchSection } from "@/app/intake/reference-estimator/ReferenceEstimatorWorkbenchSection";
export function FormalIntakeWorkbench({
activeSection,
onToggle,
pendingEntryTab,
onPendingEntryTabChange,
onOpenProgressDashboard,
activeBomLibrary,
onBomLibraryChange,
activeRawMaterialLibrary,
onOpenRawMaterialLibraryWorkspace,
onOpenReferenceEstimatorWorkspace,
onOpenProductPatents,
onOpenProductPatentLedger,
onOpenProductPatentDashboard,
onOpenBarcodePrintWorkspace,
onOpenSpuRuleLibraryWorkspace,
onOpenAdminNodePermissionWorkspace,
canViewProductDevelopmentList,
canViewSeriesDevelopmentList,
canViewProductProgressDashboard,
canViewStockDevelopmentList,
canViewBomLibrary,
canViewRawMaterialLibrary,
canViewReferenceEstimator,
canViewProductPatentLedger,
canViewProductPatentDashboard,
canViewBarcodePrint,
showNodePermissionSettings,
}: {
activeSection: IntakeSection;
onToggle: (section: Exclude<IntakeSection, null>) => void;
pendingEntryTab: IntakeEntryTab;
onPendingEntryTabChange: (tab: IntakeEntryTab) => void;
onOpenProgressDashboard: () => void;
activeBomLibrary: BomLibraryKey;
onBomLibraryChange: (library: BomLibraryKey) => void;
activeRawMaterialLibrary: RawMaterialLibraryKey;
onOpenRawMaterialLibraryWorkspace: (library: RawMaterialLibraryKey) => void;
onOpenReferenceEstimatorWorkspace: () => void;
onOpenProductPatents: () => void;
onOpenProductPatentLedger: () => void;
onOpenProductPatentDashboard: () => void;
onOpenBarcodePrintWorkspace: () => void;
onOpenSpuRuleLibraryWorkspace?: () => void;
onOpenAdminNodePermissionWorkspace?: () => void;
canViewProductDevelopmentList: boolean;
canViewSeriesDevelopmentList: boolean;
canViewProductProgressDashboard: boolean;
canViewStockDevelopmentList: boolean;
canViewBomLibrary: boolean;
canViewRawMaterialLibrary: boolean;
canViewReferenceEstimator: boolean;
canViewProductPatentLedger: boolean;
canViewProductPatentDashboard: boolean;
canViewBarcodePrint: boolean;
showNodePermissionSettings: boolean;
}) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (menuRef.current) {
menuRef.current.scrollTop = 0;
}
}, [activeSection]);
return (
<div
ref={menuRef}
className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto pr-1 pt-1"
>
<ProductDevelopmentWorkbenchSection
activeSection={activeSection}
pendingEntryTab={pendingEntryTab}
onToggle={onToggle}
onPendingEntryTabChange={onPendingEntryTabChange}
onOpenProgressDashboard={onOpenProgressDashboard}
onOpenSpuRuleLibraryWorkspace={onOpenSpuRuleLibraryWorkspace}
onOpenAdminNodePermissionWorkspace={onOpenAdminNodePermissionWorkspace}
canViewProductDevelopmentList={canViewProductDevelopmentList}
canViewSeriesDevelopmentList={canViewSeriesDevelopmentList}
canViewProductProgressDashboard={canViewProductProgressDashboard}
canViewStockDevelopmentList={canViewStockDevelopmentList}
showNodePermissionSettings={showNodePermissionSettings}
/>
<BomLibraryWorkbenchSection
activeSection={activeSection}
activeBomLibrary={activeBomLibrary}
canViewBomLibrary={canViewBomLibrary}
onToggle={onToggle}
onBomLibraryChange={onBomLibraryChange}
/>
<RawMaterialLibraryWorkbenchSection
activeSection={activeSection}
activeRawMaterialLibrary={activeRawMaterialLibrary}
canViewRawMaterialLibrary={canViewRawMaterialLibrary}
onToggle={onToggle}
onOpenRawMaterialLibraryWorkspace={onOpenRawMaterialLibraryWorkspace}
/>
<ReferenceEstimatorWorkbenchSection
activeSection={activeSection}
canViewReferenceEstimator={canViewReferenceEstimator}
onToggle={onToggle}
onOpenReferenceEstimatorWorkspace={onOpenReferenceEstimatorWorkspace}
/>
<ProductPatentWorkbenchSection
activeSection={activeSection}
canViewProductPatentLedger={canViewProductPatentLedger}
canViewProductPatentDashboard={canViewProductPatentDashboard}
onOpenProductPatents={onOpenProductPatents}
onOpenProductPatentLedger={onOpenProductPatentLedger}
onOpenProductPatentDashboard={onOpenProductPatentDashboard}
/>
<BarcodePrintWorkbenchButton
activeSection={activeSection}
canViewBarcodePrint={canViewBarcodePrint}
onOpenBarcodePrintWorkspace={onOpenBarcodePrintWorkspace}
/>
</div>
);
}

View File

@ -0,0 +1,15 @@
"use client";
import type { LucideIcon } from "lucide-react";
export const FORMAL_SIDEBAR_SECTION_ICON_CLASS = "h-4 w-4";
export function FormalSidebarIcon({
icon: Icon,
className,
}: {
icon: LucideIcon;
className?: string;
}) {
return <Icon aria-hidden="true" className={className ?? "h-3.5 w-3.5 shrink-0"} />;
}

View File

@ -0,0 +1,156 @@
"use client";
import type * as React from "react";
import Button from "@/components/ui/Button";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/core/nonIntegratedStorage";
const INTAKE_PRIMARY_BUTTON_CLASS =
"!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400";
export function DetailPanel({
title,
description,
children,
}: {
title: string;
description: string;
children?: React.ReactNode;
}) {
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div>
<h3 className="text-base font-apple-semibold text-apple-label-primary">
{title}
</h3>
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
{description}
</p>
</div>
<div className="mt-3 flex-1 overflow-y-auto">{children}</div>
</div>
);
}
export function ImagePreviewDialog({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
role="dialog"
aria-label="成品预览图"
onClick={onClose}
>
<div
className="relative w-full max-w-3xl rounded-apple-xl bg-apple-bg-primary p-4 shadow-apple-lg"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
{alt}
</p>
<Button type="button" variant="plain" size="small" onClick={onClose}>
关闭预览
</Button>
</div>
<div className="overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-secondary">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className="max-h-[70vh] w-full object-contain"
/>
</div>
</div>
</div>
);
}
export function CopyProductDialog({
product,
targetBrand,
brandOptions,
errorMessage,
isSubmitting,
onChangeBrand,
onConfirm,
onClose,
}: {
product: NonIntegratedProductRecord;
targetBrand: string;
brandOptions: string[];
errorMessage: string;
isSubmitting: boolean;
onChangeBrand: (value: string) => void;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
role="dialog"
aria-label="复制产品"
onClick={onClose}
>
<div
className="w-full max-w-[420px] rounded-apple-xl bg-apple-bg-primary p-5 shadow-apple-lg"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-apple-semibold text-apple-label-primary">
复制产品
</h3>
<p className="mt-1 text-sm text-apple-label-secondary">
当前 SPU:{product.spu}
。选择目标品牌后,会先带出整套资料,再进入待确认流程。
</p>
</div>
<Button type="button" variant="plain" size="small" onClick={onClose}>
关闭
</Button>
</div>
<label className="mt-4 block space-y-1.5 text-sm text-apple-label-secondary">
<span>目标品牌</span>
<select
aria-label="目标品牌"
value={targetBrand}
onChange={(event) => onChangeBrand(event.target.value)}
className="h-10 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-sm text-apple-label-primary outline-none"
>
{brandOptions.map((brand) => (
<option key={brand} value={brand}>
{brand}
</option>
))}
</select>
</label>
{errorMessage ? (
<p className="mt-3 text-xs text-[#d94b45]">{errorMessage}</p>
) : null}
<div className="mt-5 flex justify-end gap-2">
<Button type="button" variant="plain" size="small" onClick={onClose}>
取消
</Button>
<Button
type="button"
size="small"
className={INTAKE_PRIMARY_BUTTON_CLASS}
onClick={onConfirm}
disabled={isSubmitting || brandOptions.length === 0}
>
{isSubmitting ? "复制中..." : "确认复制"}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { IntakeWorkspaceTabBar } from "@/app/intake/components/IntakeWorkspaceTabBar";
export function IntakeWorkspaceShell({
tabs,
activeTabId,
activeContent,
onActivate,
onClose,
}: {
tabs: Array<{ id: string; label: string }>;
activeTabId: string | null;
activeContent: ReactNode;
onActivate: (tabId: string) => void;
onClose: (tabId: string) => void;
}) {
return (
<div className="flex h-full min-h-0 flex-col gap-2">
<IntakeWorkspaceTabBar
tabs={tabs}
activeTabId={activeTabId}
onActivate={onActivate}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-hidden" role="tabpanel">
{activeContent}
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { X as XIcon } from "lucide-react";
export function IntakeWorkspaceTabBar({
tabs,
activeTabId,
onActivate,
onClose,
}: {
tabs: Array<{ id: string; label: string }>;
activeTabId: string | null;
onActivate: (tabId: string) => void;
onClose: (tabId: string) => void;
}) {
return (
<div
className="flex min-h-[32px] items-center gap-1 overflow-x-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-2 py-1 shadow-apple-xs"
role="tablist"
aria-label="已打开工作页"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={`flex h-6 shrink-0 items-center overflow-hidden rounded-apple-md border transition ${
isActive
? "border-zinc-950 bg-zinc-950 text-white"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary hover:text-apple-label-primary"
}`}
>
<button
type="button"
role="tab"
aria-selected={isActive}
className="h-full max-w-[180px] truncate px-3 text-xs"
onClick={() => onActivate(tab.id)}
>
{tab.label}
</button>
<button
type="button"
aria-label={`关闭${tab.label}`}
title="关闭"
className={`flex h-full w-6 items-center justify-center border-l ${
isActive ? "border-white/20" : "border-apple-separator"
}`}
onClick={(event) => {
event.stopPropagation();
onClose(tab.id);
}}
>
<XIcon className="h-3 w-3" aria-hidden="true" />
</button>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,7 @@
export function SpuLabel() {
return (
<span translate="no" className="notranslate">
SPU
</span>
);
}

View File

@ -0,0 +1,41 @@
import {
isIntakeClientEditorMode as isEditorMode,
type IntakeClientProps,
} from "@/app/intake/intakeClientTypes";
import {
getDraftGroupSeriesNameOptions,
getDraftGroupSeriesSkuOptions,
} from "@/app/intake/intakeSeriesOptions";
import {
buildIntegratedSeriesRows,
} from "@/app/intake/product-development/integrated-pending/integratedSeriesRows";
export function buildIntakeClientDerivedData(props: IntakeClientProps) {
const selectedRawMaterialName = props.initialRawMaterialName?.trim() ?? "";
const seriesNameOptions = isEditorMode(props)
? getDraftGroupSeriesNameOptions({
detail: props.detail,
initialSeriesNameOptions: props.initialSeriesNameOptions,
})
: getDraftGroupSeriesNameOptions({
eligibleDraftGroups: props.eligibleDraftGroups,
eligibleSkuList: props.eligibleSkuList,
initialSeriesNameOptions: props.initialSeriesNameOptions,
});
const seriesDraftSkuOptions = isEditorMode(props)
? getDraftGroupSeriesSkuOptions({ detail: props.detail })
: getDraftGroupSeriesSkuOptions({ eligibleSkuList: props.eligibleSkuList });
const skuList = isEditorMode(props)
? buildIntegratedSeriesRows({ detail: props.detail })
: buildIntegratedSeriesRows({
eligibleDraftGroups: props.eligibleDraftGroups,
eligibleSkuList: props.eligibleSkuList,
});
return {
selectedRawMaterialName,
seriesDraftSkuOptions,
seriesNameOptions,
skuList,
};
}

View File

@ -0,0 +1,31 @@
import {
canDeleteNonIntegratedProductByIntakePermission,
isIntakeNonIntegratedDesignDepartmentUser,
type IntakeNonIntegratedPermissionUser,
} from "@/lib/non-integrated-permissions";
type IntakeProductPermissionUser = IntakeNonIntegratedPermissionUser & {
role: string;
};
export function canDeleteIntakeProducts(role: string): boolean {
return canDeleteNonIntegratedProductByIntakePermission({ role });
}
export function canManageSpuRuleLibrary(role: string): boolean {
return role === "SUPER_ADMIN" || role === "ADMIN";
}
export function canManageNodePermissionSettings(role: string): boolean {
return role === "SUPER_ADMIN" || role === "ADMIN";
}
export function canAccessDesignControlledProductData(
user: IntakeProductPermissionUser,
): boolean {
if (user.role === "SUPER_ADMIN" || user.role === "ADMIN") {
return true;
}
return isIntakeNonIntegratedDesignDepartmentUser(user);
}

View File

@ -0,0 +1,90 @@
import type { RawMaterialLibraryKey } from "@/app/intake/raw-material-library/RawMaterialLibraryDomain";
import type {
IntakeEntryTab,
IntakeSection,
WorkbenchSku,
WorkspaceProductDetailState,
WorkspaceProductMode,
} from "@/app/intake/intakeWorkspaceTypes";
import type { IntakeNodePermissionOverrideMap } from "@/lib/intake-node-permission-settings";
import type {
CategoryDTO,
DraftGroupDetailDTO,
IntakeProductRecommendationDTO,
MaterialCategoryDTO,
MaterialDTO,
SubstrateDictionaryBundleDTO,
SubstrateDTO,
WarehouseLocationDTO,
} from "@/server/services";
import type { PlatformMetricItem } from "@/components/platform/types";
import type { NonIntegratedProductRecord } from "./non-integrated/nonIntegratedStorage";
type IntakeClientUser = {
id: string;
name: string;
email: string;
role: string;
businessRoles?: string[];
supplierCode?: string | null;
supplierName?: string | null;
avatar: string | null;
department: { id: string; name: string; fullPath?: string } | null;
departmentPaths?: string[];
};
type IntakeOpenProductWorkspaceInput = {
product: NonIntegratedProductRecord;
mode?: WorkspaceProductMode;
detailState?: WorkspaceProductDetailState;
};
type CommonIntakeClientProps = {
user: IntakeClientUser;
metrics: PlatformMetricItem[];
materialLibrarySubstrates: SubstrateDTO[];
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
initialNonIntegratedProducts: NonIntegratedProductRecord[];
initialNodePermissionOverrides?: IntakeNodePermissionOverrideMap;
initialActiveSection?: IntakeSection;
initialActiveRawMaterialLibrary?: RawMaterialLibraryKey;
initialRawMaterialName?: string;
initialOpenNewNonIntegratedProduct?: boolean;
initialOpenProductWorkspace?: IntakeOpenProductWorkspaceInput;
initialOpenProductRequested?: boolean;
initialPendingEntryTab?: IntakeEntryTab;
initialSeriesNameOptions?: string[];
};
export type IntakeClientSelectorProps = CommonIntakeClientProps & {
eligibleDraftGroups: {
id: string;
name: string;
assetSeriesName: string;
assetSeriesCode?: string;
code: string;
}[];
eligibleSkuList?: WorkbenchSku[];
draftGroupId: string | null;
hasIneligibleSelected: boolean;
};
export type IntakeClientEditorProps = CommonIntakeClientProps & {
detail: DraftGroupDetailDTO;
recommendedProducts: IntakeProductRecommendationDTO[];
locations: WarehouseLocationDTO[];
categories: CategoryDTO[];
defaultAttachProductId: string | null;
};
export type IntakeClientProps =
| IntakeClientSelectorProps
| IntakeClientEditorProps;
export function isIntakeClientEditorMode(
props: IntakeClientProps,
): props is IntakeClientEditorProps {
return "detail" in props && props.detail !== undefined;
}

View File

@ -0,0 +1,285 @@
"use client";
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import { IntakeEmptyDetail } from "@/app/intake/IntakeMainLayout";
import type { IntakePrimaryDomainContentProps } from "@/app/intake/IntakePrimaryDomainContent";
import type { IntakeRightWorkbenchPanelProps } from "@/app/intake/IntakeRightWorkbenchPanel";
import type { IntakeWorkspaceContentPanelProps } from "@/app/intake/IntakeWorkspaceContentPanel";
import type { IntakeClientProps } from "@/app/intake/intakeClientTypes";
import type {
IntegratedPendingSeriesRow,
IntakeEntryTab,
IntakeSection,
IntakeWorkspaceTab,
ProductDevelopmentKind,
WorkbenchSku,
WorkspaceProductDetailState,
WorkspaceProductMode,
} from "@/app/intake/intakeWorkspaceTypes";
import type { NonIntegratedProductKindFilter } from "@/app/intake/product-development/nonIntegratedListState";
import type { ProductPatentLedgerTarget } from "@/app/intake/product-patent/ProductPatentDomain";
import type { RawMaterialLibraryKey } from "@/app/intake/raw-material-library/RawMaterialLibraryDomain";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import type {
MaterialCategoryDTO,
MaterialDTO,
SubstrateDictionaryBundleDTO,
SubstrateDTO,
} from "@/server/services";
type IntakeViewUser = IntakeClientProps["user"];
export function buildIntakeWorkspacePanelProps(params: {
workspaceTabs: IntakeWorkspaceTab[];
activeWorkspaceTabId: string | null;
activeWorkspaceTab: IntakeWorkspaceTab | null;
user: IntakeViewUser;
canManageSpuRuleLibrary: boolean;
canManageNodePermissionSettings: boolean;
canEditBomLibrary: boolean;
canViewProductPatentLedger: boolean;
canEditProductPatentLedger: boolean;
canViewProductPatentDashboard: boolean;
activeProductWorkspaceNeedsFullLoad: boolean;
activeProductWorkspaceLoadError: string;
seriesNameOptions: string[];
existingSeriesNames: string[];
existingSeriesComponentDetailNames: string[];
seriesDraftSkuOptions: WorkbenchSku[];
materialLibrarySubstrates: SubstrateDTO[];
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
selectedRawMaterialName: string;
nonIntegratedProducts: NonIntegratedProductRecord[];
onRetryProductWorkspaceLoad: (tabId: string) => void;
onProductSaved: (product: NonIntegratedProductRecord) => void;
onProductWorkspaceDetailStateChange: (
detailState: WorkspaceProductDetailState,
) => void;
onOpenProductPatentLedger: (target: ProductPatentLedgerTarget) => void;
onOpenProductWorkspace: (
product: NonIntegratedProductRecord,
mode: WorkspaceProductMode,
) => void;
onOpenRawMaterialProcessConfig: (materialName: string) => void;
onActivatePendingEntryListWorkspace: (
productDevelopmentKind?: ProductDevelopmentKind,
entryTabOverride?: IntakeEntryTab,
) => void;
onCloseNewProductWorkspace: (tabId: string) => void;
onMaterialLibraryFinishesChange: (finishes: MaterialDTO[]) => void;
onActivate: (tabId: string) => void;
onClose: (tabId: string) => void;
}): Omit<IntakeWorkspaceContentPanelProps, "mainContent"> {
return {
tabs: params.workspaceTabs,
activeTabId: params.activeWorkspaceTabId,
activeWorkspaceTab: params.activeWorkspaceTab,
currentUser: params.user,
canManageSpuRuleLibrary: params.canManageSpuRuleLibrary,
canManageNodePermissionSettings: params.canManageNodePermissionSettings,
canEditBomLibrary: params.canEditBomLibrary,
canViewProductPatentLedger: params.canViewProductPatentLedger,
canEditProductPatentLedger: params.canEditProductPatentLedger,
canViewProductPatentDashboard: params.canViewProductPatentDashboard,
activeProductWorkspaceNeedsFullLoad:
params.activeProductWorkspaceNeedsFullLoad,
activeProductWorkspaceLoadError: params.activeProductWorkspaceLoadError,
seriesNameOptions: params.seriesNameOptions,
existingSeriesNames: params.existingSeriesNames,
existingSeriesComponentDetailNames:
params.existingSeriesComponentDetailNames,
seriesDraftSkuOptions: params.seriesDraftSkuOptions,
materialLibrarySubstrates: params.materialLibrarySubstrates,
materialLibraryFinishes: params.materialLibraryFinishes,
materialLibraryCategories: params.materialLibraryCategories,
materialLibraryDictionaries: params.materialLibraryDictionaries,
selectedRawMaterialName: params.selectedRawMaterialName,
nonIntegratedProducts: params.nonIntegratedProducts,
onRetryProductWorkspaceLoad: params.onRetryProductWorkspaceLoad,
onProductSaved: params.onProductSaved,
onProductWorkspaceDetailStateChange:
params.onProductWorkspaceDetailStateChange,
onOpenProductPatentLedger: params.onOpenProductPatentLedger,
onOpenProductWorkspace: params.onOpenProductWorkspace,
onOpenRawMaterialProcessConfig: params.onOpenRawMaterialProcessConfig,
onActivatePendingEntryListWorkspace:
params.onActivatePendingEntryListWorkspace,
onCloseNewProductWorkspace: params.onCloseNewProductWorkspace,
onMaterialLibraryFinishesChange: params.onMaterialLibraryFinishesChange,
onActivate: params.onActivate,
onClose: params.onClose,
};
}
export function buildIntakePrimaryDomainProps(params: {
activeSection: IntakeSection;
pendingEntryTab: IntakeEntryTab;
skuList: IntegratedPendingSeriesRow[];
nonIntegratedProducts: NonIntegratedProductRecord[];
canDeleteNonIntegratedProducts: boolean;
canCreateNonIntegratedProducts: boolean;
canUseDesignControlledProductData: boolean;
canViewProductDevelopmentList: boolean;
canViewSeriesDevelopmentList: boolean;
canViewStockDevelopmentList: boolean;
canViewProductProgressDashboard: boolean;
user: IntakeViewUser;
canOpenProductForEdit: (product: NonIntegratedProductRecord) => boolean;
onProductsChange: (
productDevelopmentKind: ProductDevelopmentKind,
nextProducts: NonIntegratedProductRecord[],
productKindFilter?: NonIntegratedProductKindFilter,
) => void;
onOpenProductWorkspace: (
product: NonIntegratedProductRecord,
mode: WorkspaceProductMode,
) => void;
onOpenNewProductWorkspace: (
productDevelopmentKind: ProductDevelopmentKind,
) => void;
canViewBomLibrary: boolean;
canEditBomLibrary: boolean;
activeBomLibrary: BomLibraryKey;
materialLibrarySubstrates: SubstrateDTO[];
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
onMaterialLibraryFinishesChange: (finishes: MaterialDTO[]) => void;
canViewRawMaterialLibrary: boolean;
activeRawMaterialLibrary: RawMaterialLibraryKey;
selectedRawMaterialName: string;
canViewReferenceEstimator: boolean;
canViewProductPatentLedger: boolean;
canEditProductPatentLedger: boolean;
canViewBarcodePrint: boolean;
}): IntakePrimaryDomainContentProps {
return {
activeSection: params.activeSection,
fallback: <IntakeEmptyDetail />,
productDevelopment: {
pendingEntryTab: params.pendingEntryTab,
integratedRows: params.skuList,
nonIntegratedProducts: params.nonIntegratedProducts,
canDeleteProducts: params.canDeleteNonIntegratedProducts,
canCreateProducts: params.canCreateNonIntegratedProducts,
canUseDesignControlledProductData:
params.canUseDesignControlledProductData,
canViewProductDevelopmentList: params.canViewProductDevelopmentList,
canViewSeriesDevelopmentList: params.canViewSeriesDevelopmentList,
canViewStockDevelopmentList: params.canViewStockDevelopmentList,
canViewProductProgressDashboard:
params.canViewProductProgressDashboard,
currentUser: params.user,
fallback: <IntakeEmptyDetail />,
canOpenProductForEdit: params.canOpenProductForEdit,
onProductsChange: params.onProductsChange,
onOpenProductWorkspace: params.onOpenProductWorkspace,
onOpenNewProductWorkspace: params.onOpenNewProductWorkspace,
},
canViewBomLibrary: params.canViewBomLibrary,
bomLibrary: {
user: params.user,
canEditLibraries: params.canEditBomLibrary,
activeBomLibrary: params.activeBomLibrary,
materialLibrarySubstrates: params.materialLibrarySubstrates,
materialLibraryFinishes: params.materialLibraryFinishes,
materialLibraryCategories: params.materialLibraryCategories,
materialLibraryDictionaries: params.materialLibraryDictionaries,
onMaterialLibraryFinishesChange:
params.onMaterialLibraryFinishesChange,
},
canViewRawMaterialLibrary: params.canViewRawMaterialLibrary,
rawMaterialLibrary: {
activeRawMaterialLibrary: params.activeRawMaterialLibrary,
selectedMaterialName: params.selectedRawMaterialName,
},
canViewReferenceEstimator: params.canViewReferenceEstimator,
referenceEstimator: {
materialLibrarySubstrates: params.materialLibrarySubstrates,
},
canViewProductPatentLedger: params.canViewProductPatentLedger,
productPatent: {
products: params.nonIntegratedProducts,
user: params.user,
readOnly: !params.canEditProductPatentLedger,
},
canViewBarcodePrint: params.canViewBarcodePrint,
barcodePrint: {
products: params.nonIntegratedProducts,
user: params.user,
},
};
}
export function buildIntakeRightWorkbenchProps(params: {
activeWorkspaceTabKind: IntakeWorkspaceTab["kind"] | null;
activeSection: IntakeSection;
onToggle: (section: Exclude<IntakeSection, null>) => void;
pendingEntryTab: IntakeEntryTab;
onPendingEntryTabChange: (entryTab: IntakeEntryTab) => void;
onOpenProgressDashboard: () => void;
activeBomLibrary: BomLibraryKey;
onBomLibraryChange: (library: BomLibraryKey) => void;
activeRawMaterialLibrary: RawMaterialLibraryKey;
onOpenRawMaterialLibraryWorkspace: (
library: RawMaterialLibraryKey,
selectedMaterialName?: string,
) => void;
onOpenReferenceEstimatorWorkspace: () => void;
onOpenProductPatents: () => void;
onOpenProductPatentLedger: () => void;
onOpenProductPatentDashboard: () => void;
onOpenBarcodePrintWorkspace: () => void;
onOpenSpuRuleLibraryWorkspace?: () => void;
onOpenAdminNodePermissionWorkspace?: () => void;
showNodePermissionSettings: boolean;
canViewProductDevelopmentList: boolean;
canViewSeriesDevelopmentList: boolean;
canViewProductProgressDashboard: boolean;
canViewStockDevelopmentList: boolean;
canViewBomLibrary: boolean;
canViewRawMaterialLibrary: boolean;
canViewReferenceEstimator: boolean;
canViewProductPatentLedger: boolean;
canViewProductPatentDashboard: boolean;
canViewBarcodePrint: boolean;
}): IntakeRightWorkbenchPanelProps {
return {
activeWorkspaceTabKind: params.activeWorkspaceTabKind,
workbench: {
activeSection: params.activeSection,
onToggle: params.onToggle,
pendingEntryTab: params.pendingEntryTab,
onPendingEntryTabChange: params.onPendingEntryTabChange,
onOpenProgressDashboard: params.onOpenProgressDashboard,
activeBomLibrary: params.activeBomLibrary,
onBomLibraryChange: params.onBomLibraryChange,
activeRawMaterialLibrary: params.activeRawMaterialLibrary,
onOpenRawMaterialLibraryWorkspace:
params.onOpenRawMaterialLibraryWorkspace,
onOpenReferenceEstimatorWorkspace:
params.onOpenReferenceEstimatorWorkspace,
onOpenProductPatents: params.onOpenProductPatents,
onOpenProductPatentLedger: params.onOpenProductPatentLedger,
onOpenProductPatentDashboard: params.onOpenProductPatentDashboard,
onOpenBarcodePrintWorkspace: params.onOpenBarcodePrintWorkspace,
onOpenSpuRuleLibraryWorkspace: params.onOpenSpuRuleLibraryWorkspace,
onOpenAdminNodePermissionWorkspace:
params.onOpenAdminNodePermissionWorkspace,
showNodePermissionSettings: params.showNodePermissionSettings,
canViewProductDevelopmentList: params.canViewProductDevelopmentList,
canViewSeriesDevelopmentList: params.canViewSeriesDevelopmentList,
canViewProductProgressDashboard:
params.canViewProductProgressDashboard,
canViewStockDevelopmentList: params.canViewStockDevelopmentList,
canViewBomLibrary: params.canViewBomLibrary,
canViewRawMaterialLibrary: params.canViewRawMaterialLibrary,
canViewReferenceEstimator: params.canViewReferenceEstimator,
canViewProductPatentLedger: params.canViewProductPatentLedger,
canViewProductPatentDashboard: params.canViewProductPatentDashboard,
canViewBarcodePrint: params.canViewBarcodePrint,
},
};
}

View File

@ -0,0 +1,5 @@
export { BARCODE_PRINT_LABEL } from "@/app/intake/barcode-print/barcodePrintConfig";
export { BOM_LIBRARY_OPTIONS } from "@/app/intake/bom-library/bomLibraryConfig";
export { PRODUCT_PATENT_DASHBOARD_LABEL, PRODUCT_PATENT_LEDGER_LABEL } from "@/app/intake/product-patent/productPatentConfig";
export { RAW_MATERIAL_LIBRARY_OPTIONS } from "@/app/intake/raw-material-library/rawMaterialLibraryConfig";
export { REFERENCE_ESTIMATOR_GROUP_LABEL, REFERENCE_ESTIMATOR_WORKSPACE_LABEL } from "@/app/intake/reference-estimator/referenceEstimatorConfig";

View File

@ -0,0 +1,158 @@
import { readPreSkuTextField } from "@/lib/open-product/platform-view";
import type { DraftGroupDetailDTO } from "@/server/services";
import {
getNonIntegratedProductDisplayName,
type NonIntegratedProductRecord,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
import {
readPreSkuComponentDetails,
readPreSkuSkuFields,
} from "@/app/intake/product-development/series-components/seriesComponentDetails";
import type { WorkbenchSku } from "@/app/intake/intakeWorkspaceTypes";
import {
getProductDevelopmentKind,
isSeriesComponentDetail,
readSeriesComponentDetailSourceSeriesName,
} from "@/app/intake/product-development/nonIntegratedProductIdentity";
import { buildSkuHolePointsFromVariantSelection } from "@/app/intake/product-development/series-components/seriesComponentHolePoints";
import { mapStatus } from "@/app/intake/product-development/integrated-pending/integratedSeriesRows";
type DraftGroupSeriesSource = {
assetSeriesName?: string | null;
assetSeriesCode?: string | null;
seriesTemplateName?: string | null;
name?: string | null;
};
type DraftGroupSeriesNameOptionsInput = {
initialSeriesNameOptions?: string[];
detail?: DraftGroupDetailDTO;
eligibleDraftGroups?: DraftGroupSeriesSource[];
eligibleSkuList?: WorkbenchSku[];
};
type DraftGroupSeriesSkuOptionsInput = {
detail?: DraftGroupDetailDTO;
eligibleSkuList?: WorkbenchSku[];
};
function getUniqueNonEmptyValues(values: Array<string | undefined | null>) {
return Array.from(
new Set(
values
.map((value) => value?.trim() ?? "")
.filter((value) => value.length > 0),
),
);
}
export function readDraftGroupSeriesName(draftGroup: DraftGroupSeriesSource) {
const seriesName =
draftGroup.assetSeriesName?.trim() ||
draftGroup.seriesTemplateName?.trim() ||
"";
const seriesCode = draftGroup.assetSeriesCode?.trim() || "";
if (seriesName && seriesCode) return `${seriesName} ${seriesCode}`;
return seriesName || seriesCode || draftGroup.name?.trim() || "";
}
export function getDraftGroupSeriesNameOptions({
initialSeriesNameOptions,
detail,
eligibleDraftGroups = [],
eligibleSkuList = [],
}: DraftGroupSeriesNameOptionsInput) {
if (detail) {
return getUniqueNonEmptyValues([
...(initialSeriesNameOptions ?? []),
readDraftGroupSeriesName(detail.draftGroup),
detail.draftGroup.name,
...detail.preSkus.map((preSku) =>
readPreSkuTextField(preSku, "seriesName"),
),
]);
}
return getUniqueNonEmptyValues([
...(initialSeriesNameOptions ?? []),
...eligibleDraftGroups.flatMap((draftGroup) => [
readDraftGroupSeriesName(draftGroup),
draftGroup.name,
]),
...eligibleSkuList.map((sku) => sku.seriesName),
]);
}
export function getDraftGroupSeriesSkuOptions({
detail,
eligibleSkuList = [],
}: DraftGroupSeriesSkuOptionsInput): WorkbenchSku[] {
if (!detail) return eligibleSkuList;
return detail.preSkus.map((preSku, index) => {
const skuFields = readPreSkuSkuFields(preSku);
return {
id: preSku.id,
draftGroupId: detail.draftGroup.id,
spuCode:
readPreSkuTextField(preSku, "spuCode") ||
readPreSkuTextField(preSku, "spu") ||
detail.draftGroup.code ||
`SPU-${String(index + 1).padStart(3, "0")}`,
code: skuFields.skuCode || preSku.customName || preSku.defaultName,
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, "seriesName") ||
readDraftGroupSeriesName(detail.draftGroup),
draftGroupName: detail.draftGroup.name,
status: mapStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
packagingPlan: preSku.packagingPlan,
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields,
model3dPreviewNodes: preSku.previewNodes.length
? preSku.previewNodes
: preSku.assemblyWorkspace?.nodes ?? [],
};
});
}
function isActiveNonIntegratedProductRecord(product: NonIntegratedProductRecord) {
const deletedAt = (product as unknown as Record<string, unknown>).deletedAt;
return deletedAt === undefined || deletedAt === null || deletedAt === "";
}
export function getExistingSeriesDevelopmentNames(
products: NonIntegratedProductRecord[],
) {
return getUniqueNonEmptyValues(
products
.filter(isActiveNonIntegratedProductRecord)
.filter((product) => getProductDevelopmentKind(product) === "series")
.filter(
(product) =>
!isSeriesComponentDetail(product as unknown as Record<string, unknown>),
)
.map(
(product) =>
product.openingReference.productName ||
getNonIntegratedProductDisplayName(product),
),
);
}
export function getExistingSeriesComponentDetailNames(
products: NonIntegratedProductRecord[],
) {
return getUniqueNonEmptyValues(
products
.filter(isActiveNonIntegratedProductRecord)
.filter((product) => getProductDevelopmentKind(product) === "series")
.filter((product) =>
isSeriesComponentDetail(product as unknown as Record<string, unknown>),
)
.map(readSeriesComponentDetailSourceSeriesName),
);
}

View File

@ -0,0 +1,166 @@
import type {
IntakeWorkspaceTab,
PersistedIntakeWorkspaceState,
PersistedIntakeWorkspaceTab,
} from "@/app/intake/intakeWorkspaceTypes";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
const INTAKE_WORKSPACE_STATE_STORAGE_KEY = "intake:workspace-state:v2";
export function getIntakeWorkspaceStateStorageKey(userId: string) {
return `${INTAKE_WORKSPACE_STATE_STORAGE_KEY}:${userId || "anonymous"}`;
}
function createWorkspaceProductRestoreSnapshot(
product: NonIntegratedProductRecord,
): NonIntegratedProductRecord {
const fallbackReviewSheet: NonIntegratedProductRecord["reviewSheet"] = {
version: "",
factory: "",
reviewDate: "",
reviewMethod: "",
productName: "",
productImage: "",
productImageName: "",
size: "",
material: "",
barcode: "",
reviewResult: "",
color: "",
issues: [],
};
const reviewSheet =
(product as Partial<NonIntegratedProductRecord>).reviewSheet ??
fallbackReviewSheet;
const fallbackOpeningReference: NonIntegratedProductRecord["openingReference"] = {
productImage: "",
productImageName: "",
productName: "",
temporaryCode: "",
brand: "",
adaptableBrands: [],
productCategory: "",
salesCategory: "",
designer: "",
planningOwner: "",
structureDesigner: "",
currentStage: "",
year: "",
competitorLink: "",
targetPriceBand: "",
size: "",
material: "",
urgent: "",
reason: "",
};
const openingReference =
(product as Partial<NonIntegratedProductRecord>).openingReference ??
fallbackOpeningReference;
const clearReviewSheetImages = (
sheet: NonIntegratedProductRecord["reviewSheet"],
): NonIntegratedProductRecord["reviewSheet"] => ({
...sheet,
productImage: "",
issues: (sheet.issues ?? []).map((issue) => ({
...issue,
issueImage: "",
issueImages: [],
referenceImage: "",
referenceImages: [],
})),
});
return {
...product,
previewImage: "",
model3dDataUrl: undefined,
openingReference: {
...openingReference,
productImage: "",
},
reviewSheet: clearReviewSheetImages(reviewSheet),
reviewSheets: product.reviewSheets?.map((round) => ({
...round,
reviewSheet: clearReviewSheetImages(round.reviewSheet ?? fallbackReviewSheet),
})),
styleDetails: [],
bomDetails: [],
skuItems: [],
factoryRows: [],
pricingRows: [],
planningLayoutRows: [],
productLimitRows: [],
sampleMeasurementRows: [],
finishedSampleShipmentRows: [],
firstQualityRows: [],
qualityInspectionSheet: {
rows: [],
},
uploadFiles: [],
};
}
export function serializeIntakeWorkspaceTab(
tab: IntakeWorkspaceTab,
): PersistedIntakeWorkspaceTab {
if (tab.kind === "pendingEntry") {
return { id: tab.id, kind: tab.kind, entryTab: tab.entryTab };
}
if (tab.kind === "nonIntegratedProduct") {
return {
id: tab.id,
kind: tab.kind,
productId: tab.productId,
mode: tab.mode,
productDevelopmentKind: tab.productDevelopmentKind,
detailState: tab.detailState,
productSnapshot: createWorkspaceProductRestoreSnapshot(tab.product),
};
}
if (tab.kind === "newNonIntegratedProduct") {
return {
id: tab.id,
kind: tab.kind,
productDevelopmentKind: tab.productDevelopmentKind,
initialCreateProductKind: tab.initialCreateProductKind,
};
}
if (tab.kind === "bomLibrary") {
return { id: tab.id, kind: tab.kind, bomLibrary: tab.bomLibrary };
}
if (tab.kind === "rawMaterialLibrary") {
return {
id: tab.id,
kind: tab.kind,
rawMaterialLibrary: tab.rawMaterialLibrary,
selectedMaterialName: tab.selectedMaterialName,
};
}
if (tab.kind === "productPatentLedger") {
return { id: tab.id, kind: tab.kind, target: tab.target };
}
return { id: tab.id, kind: tab.kind };
}
export function persistIntakeWorkspaceState(params: {
activeTabId: string | null;
shouldPersist: boolean;
tabs: IntakeWorkspaceTab[];
userId: string;
}) {
if (!params.shouldPersist || typeof window === "undefined") return;
try {
const state: PersistedIntakeWorkspaceState = {
activeTabId: params.activeTabId,
tabs: params.tabs.map(serializeIntakeWorkspaceTab),
};
window.sessionStorage.setItem(
getIntakeWorkspaceStateStorageKey(params.userId),
JSON.stringify(state),
);
} catch {
// Ignore quota/private-mode failures; the UI can still rebuild the default tab.
}
}

View File

@ -0,0 +1,138 @@
import {
createNonIntegratedProductWorkspaceTab,
getProductWorkspaceRenderKey,
} from "@/app/intake/intakeWorkspaceState";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import type { IntakeNonIntegratedPermissionUser } from "@/lib/non-integrated-permissions";
function createProduct(
overrides: Partial<NonIntegratedProductRecord> = {},
): NonIntegratedProductRecord {
return {
id: "DBZ-SPJ-1780300716699",
spu: "DBZ-SPJ",
brand: "Brand A",
adaptableBrands: ["Brand A"],
designer: "Designer A",
operator: "Operator A",
purchaser: "Buyer A",
previewImage: "",
previewImageName: "",
productCode: "",
size: "",
color: "",
installMethod: "",
installSteps: "",
tagMaterial: "",
packageDetail: "",
craftDetail: "",
styleDetails: [],
bomDetails: [],
reviewSheet: {
version: "v0.0.1",
factory: "",
reviewDate: "",
reviewMethod: "现场审样",
productName: "",
productImage: "",
productImageName: "",
size: "",
material: "",
color: "",
issues: [],
},
qualityInspectionSheet: {
rows: [],
},
skuItems: [],
factoryRows: [],
pricingRows: [],
planningLayoutRows: [],
productLimitRows: [],
sampleMeasurementRows: [],
finishedSampleShipmentRows: [],
firstQualityRows: [],
uploadFiles: [],
documentWorkspaceNotes: {},
documentWorkspaceConfirmations: {},
createdAt: "2026-06-15T10:00:00.000Z",
updatedAt: "2026-06-15T10:00:00.000Z",
...overrides,
};
}
const USER: IntakeNonIntegratedPermissionUser = {
id: "user-1",
name: "Tester",
role: "ADMIN",
businessRoles: ["DESIGN"],
};
describe("getProductWorkspaceRenderKey", () => {
it("does not change when only updatedAt changes", () => {
const originalProduct = createProduct();
const updatedTimestampProduct = createProduct({
updatedAt: "2026-06-15T12:00:00.000Z",
});
expect(getProductWorkspaceRenderKey(updatedTimestampProduct)).toBe(
getProductWorkspaceRenderKey(originalProduct),
);
});
it("changes when structured detail rows change even if updatedAt and sku count stay the same", () => {
const shallowProduct = createProduct();
const fullProduct = createProduct({
factoryRows: [
{
id: "factory-row-1",
skuId: "sku-1",
supplier: "供应商A",
supplierId: "supplier-1",
factoryCode: "F001",
} as NonIntegratedProductRecord["factoryRows"][number],
],
pricingRows: [
{
skuId: "sku-1",
costPrice: "88",
productTag: "主推",
} as NonIntegratedProductRecord["pricingRows"][number],
],
uploadFiles: [
{
id: "file-1",
name: "factory-sheet.pdf",
category: "产品定厂",
dataUrl: "https://example.com/factory-sheet.pdf",
mimeType: "application/pdf",
},
],
});
expect(getProductWorkspaceRenderKey(fullProduct)).not.toBe(
getProductWorkspaceRenderKey(shallowProduct),
);
});
it("is applied when creating a product workspace tab", () => {
const product = createProduct({
factoryRows: [
{
id: "factory-row-1",
skuId: "sku-1",
supplier: "供应商A",
} as NonIntegratedProductRecord["factoryRows"][number],
],
});
const tab = createNonIntegratedProductWorkspaceTab({
id: "non-integrated-product:DBZ-SPJ-1780300716699:edit:1",
product,
mode: "edit",
user: USER,
});
expect(tab.renderKey).toBe(getProductWorkspaceRenderKey(product));
});
});

View File

@ -0,0 +1,801 @@
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import { BOM_LIBRARY_OPTIONS } from "@/app/intake/bom-library/bomLibraryConfig";
import { BARCODE_PRINT_LABEL } from "@/app/intake/barcode-print/barcodePrintConfig";
import type { ProductPatentLedgerTarget } from "@/app/intake/product-patent/ProductPatentDomain";
import {
PRODUCT_PATENT_DASHBOARD_LABEL,
PRODUCT_PATENT_LEDGER_LABEL,
} from "@/app/intake/product-patent/productPatentConfig";
import type { RawMaterialLibraryKey } from "@/app/intake/raw-material-library/RawMaterialLibraryDomain";
import { RAW_MATERIAL_LIBRARY_OPTIONS } from "@/app/intake/raw-material-library/rawMaterialLibraryConfig";
import {
REFERENCE_ESTIMATOR_WORKSPACE_LABEL,
} from "@/app/intake/reference-estimator/referenceEstimatorConfig";
import type {
InitialIntakeWorkspaceState,
IntakeEntryTab,
IntakeSection,
IntakeWorkspaceTab,
NewNonIntegratedCreateKind,
PersistedIntakeWorkspaceState,
PersistedIntakeWorkspaceTab,
ProductDevelopmentKind,
WorkspaceProductDetailState,
WorkspaceProductMode,
} from "@/app/intake/intakeWorkspaceTypes";
import { getIntakeWorkspaceStateStorageKey } from "@/app/intake/intakeWorkspacePersistence";
import {
getMaxWorkspaceTabSequence,
limitWorkspaceTabsPerPage,
} from "@/app/intake/intakeWorkspaceTabUtils";
import {
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
type NonIntegratedDocumentWorkspaceCategory,
type NonIntegratedProductRecord,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
import {
getPendingEntryTabFromNewProductTab,
getPendingEntryTabFromProduct,
getPendingEntryTabLabel,
getProductDevelopmentDetailLabel,
getProductDevelopmentKind,
} from "@/app/intake/product-development/nonIntegratedProductIdentity";
import { canOpenNonIntegratedProductForEdit } from "@/app/intake/product-development/nonIntegratedProductAccess";
import {
getDocumentWorkspaceCountByCategory,
} from "@/app/intake/product-development/nonIntegratedDocumentCounts";
import {
getCurrentNodeCategoryForList,
} from "@/app/intake/product-development/nonIntegratedCurrentNode";
import {
resolveIntakeNodePermissionAccess,
resolveIntakeNodePermissionSubjectKeys,
type IntakeNodePermissionOverrideMap,
type IntakeNodePermissionTargetKey,
} from "@/lib/intake-node-permission-settings";
import type { IntakeNonIntegratedPermissionUser } from "@/lib/non-integrated-permissions";
import { isSupplierAccount } from "@/lib/supplier-account";
export type InitialOpenProductWorkspaceInput = {
product: NonIntegratedProductRecord;
mode?: WorkspaceProductMode;
detailState?: WorkspaceProductDetailState;
};
type NonIntegratedProductWorkspaceTab = Extract<
IntakeWorkspaceTab,
{ kind: "nonIntegratedProduct" }
>;
export function isIntakeAdminUser(user: IntakeNonIntegratedPermissionUser) {
return user.role === "SUPER_ADMIN" || user.role === "ADMIN";
}
function resolveIntakeFormalWorkspaceAccess(
overrides: IntakeNodePermissionOverrideMap,
targetKey: IntakeNodePermissionTargetKey,
user: IntakeNonIntegratedPermissionUser,
) {
if (isIntakeAdminUser(user)) {
return { view: true, download: true, edit: true };
}
return resolveIntakeNodePermissionAccess(
overrides,
targetKey,
resolveIntakeNodePermissionSubjectKeys(user),
);
}
export function canViewIntakeFormalWorkspace(
overrides: IntakeNodePermissionOverrideMap,
targetKey: IntakeNodePermissionTargetKey,
user: IntakeNonIntegratedPermissionUser,
) {
const access = resolveIntakeFormalWorkspaceAccess(overrides, targetKey, user);
return Boolean(access.view || access.download || access.edit);
}
export function canEditIntakeFormalWorkspace(
overrides: IntakeNodePermissionOverrideMap,
targetKey: IntakeNodePermissionTargetKey,
user: IntakeNonIntegratedPermissionUser,
) {
return Boolean(
resolveIntakeFormalWorkspaceAccess(overrides, targetKey, user).edit,
);
}
export function resolveIntakeFormalWorkspaceAccessMap(
overrides: IntakeNodePermissionOverrideMap,
user: IntakeNonIntegratedPermissionUser,
) {
if (isSupplierAccount(user)) {
return {
canViewProductDevelopmentList: false,
canViewSeriesDevelopmentList: false,
canViewProductProgressDashboard: false,
canViewStockDevelopmentList: false,
canViewBomLibrary: false,
canEditBomLibrary: false,
canViewRawMaterialLibrary: false,
canViewReferenceEstimator: false,
canViewProductPatentLedger: false,
canEditProductPatentLedger: false,
canViewProductPatentDashboard: false,
canViewBarcodePrint: true,
};
}
const canView = (targetKey: IntakeNodePermissionTargetKey) =>
canViewIntakeFormalWorkspace(overrides, targetKey, user);
const canEdit = (targetKey: IntakeNodePermissionTargetKey) =>
canEditIntakeFormalWorkspace(overrides, targetKey, user);
return {
canViewProductDevelopmentList: canView("productDevelopmentList"),
canViewSeriesDevelopmentList: canView("seriesDevelopmentList"),
canViewProductProgressDashboard: canView("productProgressDashboard"),
canViewStockDevelopmentList: canView("stockDevelopmentList"),
canViewBomLibrary: canView("bomLibrary"),
canEditBomLibrary: canEdit("bomLibrary"),
canViewRawMaterialLibrary: canView("rawMaterialLibrary"),
canViewReferenceEstimator: canView("referenceEstimator"),
canViewProductPatentLedger: canView("productPatentLedger"),
canEditProductPatentLedger: canEdit("productPatentLedger"),
canViewProductPatentDashboard: canView("productPatentDashboard"),
canViewBarcodePrint: canView("barcodePrint"),
};
}
const DEFAULT_WORKSPACE_TAB_ID = "pending-entry:nonIntegrated:default";
export function createPendingEntryWorkspaceTab(
entryTab: IntakeEntryTab,
id = DEFAULT_WORKSPACE_TAB_ID,
): IntakeWorkspaceTab {
return {
id,
kind: "pendingEntry",
label: getPendingEntryTabLabel(entryTab),
entryTab,
};
}
export function createNewNonIntegratedProductWorkspaceTab(
id = "new-non-integrated:default",
productDevelopmentKind: ProductDevelopmentKind = "product",
initialCreateProductKind: NewNonIntegratedCreateKind = "new",
): IntakeWorkspaceTab {
return {
id,
kind: "newNonIntegratedProduct",
label:
initialCreateProductKind === "seriesComponents"
? "新增部件"
: productDevelopmentKind === "series"
? "新增系列产品"
: "新增产品",
productDevelopmentKind,
initialCreateProductKind,
};
}
export function createProgressDashboardWorkspaceTab(
id: string,
): IntakeWorkspaceTab {
return {
id,
kind: "progressDashboard",
label: "产品开发进度",
};
}
export function createSpuRuleLibraryWorkspaceTab(
id: string,
): IntakeWorkspaceTab {
return {
id,
kind: "spuRuleLibrary",
label: "SPU规则库",
};
}
export function createAdminNodePermissionWorkspaceTab(
id: string,
): IntakeWorkspaceTab {
return {
id,
kind: "adminNodePermission",
label: "节点权限开放",
};
}
export function createNonIntegratedProductWorkspaceTab({
id,
product,
mode,
user,
detailState,
}: {
id: string;
product: NonIntegratedProductRecord;
mode: WorkspaceProductMode;
user: IntakeNonIntegratedPermissionUser;
detailState?: WorkspaceProductDetailState;
}): NonIntegratedProductWorkspaceTab {
const effectiveMode: WorkspaceProductMode =
mode === "edit" && !canOpenNonIntegratedProductForEdit(product, user)
? "view"
: mode;
return {
id,
kind: "nonIntegratedProduct",
label: getProductDevelopmentDetailLabel(product),
productId: product.id,
product,
mode: effectiveMode,
productDevelopmentKind: getProductDevelopmentKind(product),
detailState,
renderKey: getProductWorkspaceRenderKey(product),
};
}
export function createNonIntegratedProductWorkspaceTabForCurrentNode({
id,
product,
mode,
user,
}: {
id: string;
product: NonIntegratedProductRecord;
mode: WorkspaceProductMode;
user: IntakeNonIntegratedPermissionUser;
}): NonIntegratedProductWorkspaceTab {
const currentCategory = getCurrentNodeCategoryForList(
product,
getDocumentWorkspaceCountByCategory(product),
);
const currentDocumentCategory =
currentCategory &&
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(
currentCategory as NonIntegratedDocumentWorkspaceCategory,
)
? (currentCategory as NonIntegratedDocumentWorkspaceCategory)
: null;
return createNonIntegratedProductWorkspaceTab({
id,
product,
mode,
user,
detailState: currentDocumentCategory
? { view: "document", category: currentDocumentCategory }
: undefined,
});
}
export function createBomLibraryWorkspaceTab(
id: string,
library: BomLibraryKey,
): IntakeWorkspaceTab {
return {
id,
kind: "bomLibrary",
label: getBomLibraryLabel(library),
bomLibrary: library,
};
}
export function createRawMaterialLibraryWorkspaceTab({
id,
library,
selectedMaterialName,
}: {
id: string;
library: RawMaterialLibraryKey;
selectedMaterialName?: string;
}): IntakeWorkspaceTab {
const normalizedMaterialName = selectedMaterialName?.trim();
return {
id,
kind: "rawMaterialLibrary",
label: getRawMaterialLibraryLabel(library),
rawMaterialLibrary: library,
selectedMaterialName: normalizedMaterialName || undefined,
};
}
export function createReferenceEstimatorWorkspaceTab(
id: string,
): IntakeWorkspaceTab {
return {
id,
kind: "referenceEstimator",
label: REFERENCE_ESTIMATOR_WORKSPACE_LABEL,
};
}
export function createProductPatentDashboardWorkspaceTab(
id: string,
): IntakeWorkspaceTab {
return {
id,
kind: "productPatentDashboard",
label: PRODUCT_PATENT_DASHBOARD_LABEL,
};
}
export function createProductPatentLedgerWorkspaceTab({
id,
target,
}: {
id: string;
target?: ProductPatentLedgerTarget;
}): IntakeWorkspaceTab {
return {
id,
kind: "productPatentLedger",
label: PRODUCT_PATENT_LEDGER_LABEL,
target,
};
}
export function createBarcodePrintWorkspaceTab(
id: string,
): IntakeWorkspaceTab {
return {
id,
kind: "barcodePrint",
label: BARCODE_PRINT_LABEL,
};
}
export function createDefaultSupplierBarcodePrintWorkspaceState(): InitialIntakeWorkspaceState {
const defaultTab = createBarcodePrintWorkspaceTab("barcode-print:supplier:default");
return {
tabs: [defaultTab],
activeTabId: defaultTab.id,
sequence: 0,
};
}
export function createDefaultIntakeWorkspaceState(
entryTab: IntakeEntryTab = "nonIntegrated",
): InitialIntakeWorkspaceState {
const defaultTabId =
entryTab === "nonIntegrated"
? DEFAULT_WORKSPACE_TAB_ID
: `pending-entry:${entryTab}:default`;
return {
tabs: [createPendingEntryWorkspaceTab(entryTab, defaultTabId)],
activeTabId: defaultTabId,
sequence: 0,
};
}
export function createInitialOpenProductWorkspaceState(
initialWorkspace: InitialOpenProductWorkspaceInput,
user: IntakeNonIntegratedPermissionUser,
): InitialIntakeWorkspaceState {
const product = initialWorkspace.product;
const requestedMode = initialWorkspace.mode ?? "edit";
const mode: WorkspaceProductMode =
requestedMode === "edit" && !canOpenNonIntegratedProductForEdit(product, user)
? "view"
: requestedMode;
const tabId = `non-integrated-product:${product.id}:${mode}:initial`;
const tab: IntakeWorkspaceTab = {
id: tabId,
kind: "nonIntegratedProduct",
label: getProductDevelopmentDetailLabel(product),
productId: product.id,
product,
mode,
productDevelopmentKind: getProductDevelopmentKind(product),
detailState: initialWorkspace.detailState,
renderKey: getProductWorkspaceRenderKey(product),
};
return {
tabs: [tab],
activeTabId: tabId,
sequence: 0,
};
}
function normalizeWorkspaceProductDetailState(
value: unknown,
): WorkspaceProductDetailState | undefined {
if (!value || typeof value !== "object") return undefined;
const record = value as Partial<WorkspaceProductDetailState>;
if (
(record.view === "detail" || record.view === "document") &&
record.category &&
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(record.category)
) {
return {
view: record.view,
category: record.category,
};
}
return undefined;
}
function restoreIntakeWorkspaceTab(
tab: PersistedIntakeWorkspaceTab,
productMap: Map<string, NonIntegratedProductRecord>,
user: IntakeNonIntegratedPermissionUser,
): IntakeWorkspaceTab | null {
if (!tab.id || typeof tab.id !== "string") return null;
if (tab.kind === "pendingEntry") {
if (
tab.entryTab !== "integrated" &&
tab.entryTab !== "nonIntegrated" &&
tab.entryTab !== "stock"
) {
return null;
}
return createPendingEntryWorkspaceTab(tab.entryTab, tab.id);
}
if (tab.kind === "progressDashboard") {
return { id: tab.id, kind: tab.kind, label: "产品开发进度" };
}
if (tab.kind === "spuRuleLibrary") {
return { id: tab.id, kind: tab.kind, label: "SPU规则库" };
}
if (tab.kind === "adminNodePermission") {
return { id: tab.id, kind: tab.kind, label: "节点权限开放" };
}
if (tab.kind === "productPatentLedger") {
return {
id: tab.id,
kind: tab.kind,
label: PRODUCT_PATENT_LEDGER_LABEL,
target: tab.target,
};
}
if (tab.kind === "productPatentDashboard") {
return {
id: tab.id,
kind: tab.kind,
label: PRODUCT_PATENT_DASHBOARD_LABEL,
};
}
if (tab.kind === "barcodePrint") {
return {
id: tab.id,
kind: tab.kind,
label: BARCODE_PRINT_LABEL,
};
}
if (tab.kind === "nonIntegratedProduct") {
const product =
productMap.get(tab.productId) ??
(tab.productSnapshot?.id === tab.productId
? tab.productSnapshot
: undefined);
if (!product) return null;
const productDevelopmentKind = getProductDevelopmentKind(product);
const restoredMode: WorkspaceProductMode =
tab.mode === "view" && canOpenNonIntegratedProductForEdit(product, user)
? "edit"
: tab.mode === "view"
? "view"
: "edit";
return {
id: tab.id,
kind: tab.kind,
label: getProductDevelopmentDetailLabel(product),
productId: product.id,
product,
mode: restoredMode,
productDevelopmentKind,
detailState: normalizeWorkspaceProductDetailState(tab.detailState),
renderKey: getProductWorkspaceRenderKey(product),
};
}
if (tab.kind === "newNonIntegratedProduct") {
const productDevelopmentKind =
tab.productDevelopmentKind === "series" ? "series" : "product";
return createNewNonIntegratedProductWorkspaceTab(
tab.id,
productDevelopmentKind,
tab.initialCreateProductKind === "seriesComponents"
? "seriesComponents"
: tab.initialCreateProductKind === "stock"
? "stock"
: "new",
);
}
if (tab.kind === "bomLibrary") {
const option = BOM_LIBRARY_OPTIONS.find(
(item) => item.key === tab.bomLibrary,
);
if (!option) return null;
return {
id: tab.id,
kind: tab.kind,
label: option.label,
bomLibrary: option.key,
};
}
if (tab.kind === "rawMaterialLibrary") {
const option = RAW_MATERIAL_LIBRARY_OPTIONS.find(
(item) => item.key === tab.rawMaterialLibrary,
);
if (!option) return null;
return {
id: tab.id,
kind: tab.kind,
label: option.label,
rawMaterialLibrary: option.key,
selectedMaterialName: tab.selectedMaterialName,
};
}
if (tab.kind === "referenceEstimator") {
return {
id: tab.id,
kind: tab.kind,
label: REFERENCE_ESTIMATOR_WORKSPACE_LABEL,
};
}
return null;
}
export function readInitialIntakeWorkspaceState(params: {
products: NonIntegratedProductRecord[];
user: IntakeNonIntegratedPermissionUser;
userId: string;
shouldRestore: boolean;
defaultEntryTab?: IntakeEntryTab;
}): InitialIntakeWorkspaceState {
if (isSupplierAccount(params.user)) {
return createDefaultSupplierBarcodePrintWorkspaceState();
}
if (!params.shouldRestore || typeof window === "undefined") {
return createDefaultIntakeWorkspaceState(params.defaultEntryTab);
}
try {
const rawState = window.sessionStorage.getItem(
getIntakeWorkspaceStateStorageKey(params.userId),
);
if (!rawState) {
return createDefaultIntakeWorkspaceState(params.defaultEntryTab);
}
const parsed = JSON.parse(rawState) as Partial<PersistedIntakeWorkspaceState>;
if (!Array.isArray(parsed.tabs)) {
return createDefaultIntakeWorkspaceState(params.defaultEntryTab);
}
const productMap = new Map(
params.products.map((product) => [product.id, product]),
);
const restoredTabs = parsed.tabs
.map((tab) =>
restoreIntakeWorkspaceTab(
tab as PersistedIntakeWorkspaceTab,
productMap,
params.user,
),
)
.filter((tab): tab is IntakeWorkspaceTab => Boolean(tab));
const tabs = limitWorkspaceTabsPerPage(restoredTabs);
if (tabs.length === 0) {
return createDefaultIntakeWorkspaceState(params.defaultEntryTab);
}
const activeTabId = tabs.some((tab) => tab.id === parsed.activeTabId)
? parsed.activeTabId ?? tabs[0]?.id ?? null
: tabs[0]?.id ?? null;
return {
tabs,
activeTabId,
sequence: getMaxWorkspaceTabSequence(tabs),
};
} catch {
return createDefaultIntakeWorkspaceState(params.defaultEntryTab);
}
}
function isDesignControlledWorkspaceTab(tab: IntakeWorkspaceTab) {
if (tab.kind === "newNonIntegratedProduct") return true;
if (tab.kind === "adminNodePermission") return true;
if (tab.kind === "spuRuleLibrary") return true;
if (tab.kind === "productPatentDashboard") return true;
return tab.kind === "productPatentLedger" && !tab.target;
}
function isAdminOnlyWorkspaceTab(tab: IntakeWorkspaceTab) {
return tab.kind === "adminNodePermission" || tab.kind === "spuRuleLibrary";
}
export function restrictWorkspaceStateForBusinessUser(
state: InitialIntakeWorkspaceState,
): InitialIntakeWorkspaceState {
const tabs = state.tabs.filter((tab) => !isDesignControlledWorkspaceTab(tab));
const safeTabs =
tabs.length > 0 ? tabs : createDefaultIntakeWorkspaceState().tabs;
const safeActiveTabId = safeTabs.some((tab) => tab.id === state.activeTabId)
? state.activeTabId
: (safeTabs[0]?.id ?? null);
return {
tabs: safeTabs,
activeTabId: safeActiveTabId,
sequence: getMaxWorkspaceTabSequence(safeTabs),
};
}
export function restrictSectionForBusinessUser(section: IntakeSection): IntakeSection {
if (
section === "patents" ||
section === "adminNodePermission"
) {
return "pending";
}
return section;
}
export function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) {
if (!tab) return "pending";
if (
tab.kind === "pendingEntry" ||
tab.kind === "spuRuleLibrary" ||
tab.kind === "nonIntegratedProduct" ||
tab.kind === "newNonIntegratedProduct"
) {
return "pending";
}
if (tab.kind === "progressDashboard") return "progress";
if (tab.kind === "adminNodePermission") return "adminNodePermission";
if (tab.kind === "productPatentLedger") return "patents";
if (tab.kind === "productPatentDashboard") return "patents";
if (tab.kind === "barcodePrint") return "barcodePrint";
if (tab.kind === "bomLibrary") return "bom";
if (tab.kind === "rawMaterialLibrary") return "rawMaterialLibrary";
return "referenceEstimator";
}
export function getWorkspaceTabInitialPendingEntryTab(tab: IntakeWorkspaceTab | null) {
if (tab?.kind === "pendingEntry") return tab.entryTab;
if (tab?.kind === "nonIntegratedProduct") {
return getPendingEntryTabFromProduct(tab.product);
}
if (tab?.kind === "newNonIntegratedProduct") {
return getPendingEntryTabFromNewProductTab(tab);
}
return "nonIntegrated";
}
export function resolveWorkspaceTabActivationState(
tab: IntakeWorkspaceTab | null,
): {
activeSection: IntakeSection;
pendingEntryTab?: IntakeEntryTab;
bomLibrary?: BomLibraryKey;
rawMaterialLibrary?: RawMaterialLibraryKey;
} {
if (!tab) {
return { activeSection: null };
}
const activation = {
activeSection: getWorkspaceTabInitialSection(tab),
} as {
activeSection: IntakeSection;
pendingEntryTab?: IntakeEntryTab;
bomLibrary?: BomLibraryKey;
rawMaterialLibrary?: RawMaterialLibraryKey;
};
if (
tab.kind === "pendingEntry" ||
tab.kind === "spuRuleLibrary" ||
tab.kind === "nonIntegratedProduct" ||
tab.kind === "newNonIntegratedProduct"
) {
activation.pendingEntryTab = getWorkspaceTabInitialPendingEntryTab(tab);
}
if (tab.kind === "bomLibrary") {
activation.bomLibrary = tab.bomLibrary;
}
if (tab.kind === "rawMaterialLibrary") {
activation.rawMaterialLibrary = tab.rawMaterialLibrary;
}
return activation;
}
export function getBomLibraryLabel(library: BomLibraryKey) {
return (
BOM_LIBRARY_OPTIONS.find((option) => option.key === library)?.label ??
"BOM资料库"
);
}
export function getRawMaterialLibraryLabel(library: RawMaterialLibraryKey) {
return (
RAW_MATERIAL_LIBRARY_OPTIONS.find((option) => option.key === library)
?.label ?? "原材料资料库"
);
}
export function getFormalWorkspaceTargetKeyForTab(tab: IntakeWorkspaceTab) {
if (tab.kind === "pendingEntry") {
if (tab.entryTab === "integrated") return "seriesDevelopmentList";
if (tab.entryTab === "stock") return "stockDevelopmentList";
return "productDevelopmentList";
}
if (tab.kind === "progressDashboard") return "productProgressDashboard";
if (tab.kind === "bomLibrary") return "bomLibrary";
if (tab.kind === "rawMaterialLibrary") return "rawMaterialLibrary";
if (tab.kind === "referenceEstimator") return "referenceEstimator";
if (tab.kind === "productPatentLedger") {
return tab.target ? null : "productPatentLedger";
}
if (tab.kind === "productPatentDashboard") return "productPatentDashboard";
if (tab.kind === "barcodePrint") return "barcodePrint";
return null;
}
export function filterWorkspaceStateByFormalWorkspacePermissions(
state: InitialIntakeWorkspaceState,
user: IntakeNonIntegratedPermissionUser,
overrides: IntakeNodePermissionOverrideMap,
): InitialIntakeWorkspaceState {
if (isSupplierAccount(user)) {
return createDefaultSupplierBarcodePrintWorkspaceState();
}
const tabs = state.tabs.filter((tab) => {
if (isAdminOnlyWorkspaceTab(tab) && !isIntakeAdminUser(user)) return false;
const targetKey = getFormalWorkspaceTargetKeyForTab(tab);
return (
!targetKey ||
canViewIntakeFormalWorkspace(overrides, targetKey, user)
);
});
const safeActiveTabId = tabs.some((tab) => tab.id === state.activeTabId)
? state.activeTabId
: (tabs[0]?.id ?? null);
return {
tabs,
activeTabId: safeActiveTabId,
sequence: getMaxWorkspaceTabSequence(tabs),
};
}
export function getProductWorkspaceRenderKey(product: NonIntegratedProductRecord) {
const qualityInspectionRowCount = product.qualityInspectionSheet?.rows?.length ?? 0;
return [
product.styleDetails?.length ?? 0,
product.bomDetails?.length ?? 0,
product.skuItems?.length ?? 0,
product.factoryRows?.length ?? 0,
product.pricingRows?.length ?? 0,
product.planningLayoutRows?.length ?? 0,
product.productLimitRows?.length ?? 0,
product.sampleMeasurementRows?.length ?? 0,
product.finishedSampleShipmentRows?.length ?? 0,
product.firstQualityRows?.length ?? 0,
qualityInspectionRowCount,
product.uploadFiles?.length ?? 0,
].join(":");
}

Some files were not shown because too many files have changed in this diff Show More