Compare commits
77 Commits
main
...
codex/xuxi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e31941a25d | ||
|
|
dbdf0d9cb5 | ||
|
|
d88e6d4648 | ||
|
|
778ea00e62 | ||
|
|
549c2752ea | ||
|
|
f09f6f42e9 | ||
|
|
17a93e31a7 | ||
|
|
742daca700 | ||
|
|
93b9e7d585 | ||
|
|
b13a59b6e4 | ||
|
|
527dda4bfa | ||
|
|
a2838e5afb | ||
|
|
d3f9ae03ba | ||
|
|
a9036521c7 | ||
|
|
5e3e752b8c | ||
|
|
280b010550 | ||
|
|
0fa3dbf005 | ||
|
|
0e9fab6cae | ||
|
|
c8cf3b7bef | ||
|
|
2b6ef354fb | ||
|
|
69662a3df6 | ||
|
|
0ea6d6027a | ||
|
|
6f0c55d142 | ||
|
|
49676923d5 | ||
|
|
e057b4746c | ||
|
|
6b16e5e7a3 | ||
|
|
fd0ebc6d4d | ||
|
|
cd93abca0d | ||
|
|
a4b2653652 | ||
|
|
fe582bad4a | ||
|
|
d01c5a9510 | ||
|
|
fc38f444e8 | ||
|
|
17a4634318 | ||
|
|
36003c529d | ||
|
|
9c2311c3af | ||
|
|
9ceeb837be | ||
|
|
12ed5f553d | ||
|
|
0a5587ebc6 | ||
|
|
eb65bba6d8 | ||
|
|
c4989d9cf8 | ||
|
|
d268a14471 | ||
|
|
613c5aa77d | ||
|
|
d978ca4358 | ||
|
|
751907d4fa | ||
|
|
2d0349d7e5 | ||
|
|
c9ef57cd96 | ||
|
|
42ceb252f2 | ||
|
|
2443811b52 | ||
|
|
1522259c9a | ||
|
|
acddbf79de | ||
|
|
15942404be | ||
|
|
669d522cfa | ||
|
|
5bb042e16f | ||
|
|
cb591ca6c8 | ||
|
|
e80e7f5286 | ||
|
|
9f1ef015f6 | ||
|
|
2735b6910c | ||
|
|
83ecca86d0 | ||
|
|
c9c5989a86 | ||
|
|
0f58eeec03 | ||
|
|
28a2767f0f | ||
|
|
6b31adbbdd | ||
|
|
7bcae029f7 | ||
|
|
3c3d551504 | ||
|
|
e6dba9a855 | ||
|
|
9ec8d2f978 | ||
|
|
02d90417fc | ||
|
|
bcec80f8f4 | ||
|
|
5bf670679b | ||
|
|
0328966812 | ||
|
|
eebf1aaf7a | ||
|
|
9e665aa1bd | ||
|
|
ce30eca2bb | ||
|
|
68d16eaae3 | ||
|
|
8a34ad3c00 | ||
|
|
1a5f9238f3 | ||
|
|
a9aef3f018 |
@ -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:",
|
||||
|
||||
280
furniture-3d-system/package-lock.json
generated
280
furniture-3d-system/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
ALTER TABLE "User"
|
||||
ADD COLUMN "supplierCode" TEXT,
|
||||
ADD COLUMN "supplierName" TEXT;
|
||||
|
||||
CREATE INDEX "User_supplierCode_idx" ON "User"("supplierCode");
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
BIN
furniture-3d-system/public/barcode-print/templates/hgz.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_easy.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_easy.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_gj.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_gj.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 738 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_sfy.png
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_sfy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_v2.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_v2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/wxtz_sfy.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/wxtz_sfy.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
)
|
||||
}
|
||||
20
furniture-3d-system/src/app/ai-render-workbench/page.tsx
Normal file
20
furniture-3d-system/src/app/ai-render-workbench/page.tsx
Normal 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} />
|
||||
}
|
||||
@ -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: '深色材质款',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
93
furniture-3d-system/src/app/ai-render-workbench/types.ts
Normal file
93
furniture-3d-system/src/app/ai-render-workbench/types.ts
Normal 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
|
||||
}
|
||||
107
furniture-3d-system/src/app/ai-render-workbench/utils.test.ts
Normal file
107
furniture-3d-system/src/app/ai-render-workbench/utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
386
furniture-3d-system/src/app/ai-render-workbench/utils.ts
Normal file
386
furniture-3d-system/src/app/ai-render-workbench/utils.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
22
furniture-3d-system/src/app/api/ai-render-workbench/route.ts
Normal file
22
furniture-3d-system/src/app/api/ai-render-workbench/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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' }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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, '账号或密码错误'),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -58,6 +58,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return jsonSuccess({ url }, 201, requestId)
|
||||
},
|
||||
{ requireAuth: true },
|
||||
{ requireAuth: true, requireDesignWorkspaceWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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}</>;
|
||||
}
|
||||
@ -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
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
furniture-3d-system/src/app/intake/IntakeMainLayout.tsx
Normal file
36
furniture-3d-system/src/app/intake/IntakeMainLayout.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}</>;
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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: ["*"],
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export const BARCODE_PRINT_LABEL = "条码打印";
|
||||
@ -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"));
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
// 本地存储满或被浏览器拦截时,不影响当前页面导入结果。
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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, "流程"),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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 =
|
||||
@ -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({
|
||||
@ -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:
|
||||
"适合维护纸箱、泡沫、护角等包材名称、编码、图片和价格,不会和配件资料串在一起。",
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
export function SpuLabel() {
|
||||
return (
|
||||
<span translate="no" className="notranslate">
|
||||
SPU
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
90
furniture-3d-system/src/app/intake/intakeClientTypes.ts
Normal file
90
furniture-3d-system/src/app/intake/intakeClientTypes.ts
Normal 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;
|
||||
}
|
||||
285
furniture-3d-system/src/app/intake/intakeClientViewProps.tsx
Normal file
285
furniture-3d-system/src/app/intake/intakeClientViewProps.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
5
furniture-3d-system/src/app/intake/intakeDomainConfig.ts
Normal file
5
furniture-3d-system/src/app/intake/intakeDomainConfig.ts
Normal 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";
|
||||
158
furniture-3d-system/src/app/intake/intakeSeriesOptions.ts
Normal file
158
furniture-3d-system/src/app/intake/intakeSeriesOptions.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
166
furniture-3d-system/src/app/intake/intakeWorkspacePersistence.ts
Normal file
166
furniture-3d-system/src/app/intake/intakeWorkspacePersistence.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
138
furniture-3d-system/src/app/intake/intakeWorkspaceState.test.ts
Normal file
138
furniture-3d-system/src/app/intake/intakeWorkspaceState.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
801
furniture-3d-system/src/app/intake/intakeWorkspaceState.ts
Normal file
801
furniture-3d-system/src/app/intake/intakeWorkspaceState.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user