feat(intake): add barcode print workbench

This commit is contained in:
WuYongJie 2026-06-15 19:15:22 +08:00
parent 6b4dcbdd31
commit 2a84f66951
32 changed files with 5017 additions and 22 deletions

View File

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

View File

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

View File

@ -100,6 +100,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",
@ -109,6 +110,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",
@ -121,9 +123,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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View File

@ -215,7 +215,12 @@ export function IntakeActiveWorkspaceContent({
}
if (activeWorkspaceTab.kind === "barcodePrint") {
return <BarcodePrintWorkspace products={nonIntegratedProducts} />;
return (
<BarcodePrintWorkspace
products={nonIntegratedProducts}
user={currentUser}
/>
);
}
return <>{fallback}</>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,688 @@
"use client";
import { Barcode } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { BARCODE_PRINT_LABEL } from "./barcodePrintConfig";
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 BarcodePrintWorkspaceProps = {
products?: NonIntegratedProductRecord[];
type BarcodePrintWorkspaceUser = {
id: string;
businessRoles?: string[];
supplierCode?: string | null;
};
export function BarcodePrintWorkspace(_props: BarcodePrintWorkspaceProps) {
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-[420px] items-center justify-center bg-[var(--obs-shell-bg)] px-6 py-8">
<div className="flex w-full max-w-[520px] flex-col items-center rounded-[8px] border border-[var(--obs-shell-border)] bg-white px-8 py-10 text-center shadow-sm">
<span className="flex h-11 w-11 items-center justify-center rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-title)]">
<Barcode className="h-5 w-5" aria-hidden="true" />
</span>
<h2 className="mt-4 text-[18px] font-apple-semibold text-[var(--obs-shell-title)]">
{BARCODE_PRINT_LABEL}
</h2>
<p className="mt-2 max-w-[360px] text-[13px] leading-6 text-[var(--obs-shell-muted)]">
入口已预留,完整条码打印工作区等待后续合并。
</p>
</div>
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[0_1px_2px_rgba(17,19,21,0.04)]">
<input
ref={barcodeImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("barcode", file);
}}
/>
<input
ref={brandSkuImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("brandSku", file);
}}
/>
<BarcodePrintHeader
draftFilters={draftFilters}
productCategoryOptions={productCategoryOptions}
salesCategoryOptions={salesCategoryOptions}
statusMessage={statusMessage}
actionDisabled={isUnboundSupplier}
onBarcodeImport={() => {
if (isUnboundSupplier) setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
else barcodeImportInputRef.current?.click();
}}
onBrandSkuImport={() => {
if (isUnboundSupplier) setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
else brandSkuImportInputRef.current?.click();
}}
onDownloadTemplate={(kind) => void handleDownloadTemplate(kind)}
onFilterChange={updateDraftFilter}
onSearch={() => setAppliedFilters(draftFilters)}
onReset={resetFilters}
/>
<BarcodePrintTable
pagedRows={pagedRows}
filteredRowCount={filteredRows.length}
currentPage={currentPage}
selectedRowIds={selectedRowIds}
allVisibleSelected={allVisibleSelected}
tableWidth={tableWidth}
rowNumberColumnWidth={rowNumberColumnWidth}
columnWidths={columnWidths}
emptyMessage={isUnboundSupplier ? UNBOUND_SUPPLIER_MESSAGE : undefined}
onToggleVisibleRows={toggleVisibleRows}
onToggleRow={toggleRow}
onStartRowNumberColumnResize={startRowNumberColumnResize}
onStartDataColumnResize={startDataColumnResize}
onBarcodePrint={(row) => void handleBarcodePrint(row.id)}
onCertificatePrint={(row) => handleCertificatePrint(row.id)}
onDeleteImportedData={deleteImportedData}
/>
<BarcodePrintPagination
currentPage={currentPage}
totalPages={totalPages}
totalRows={filteredRows.length}
onPageChange={setCurrentPage}
/>
{pendingPrintJob && pendingPrintRow ? (
<BarcodePrintTemplateDialog
key={`${pendingPrintJob.mode}:${pendingPrintJob.rowId}`}
isOpen
mode={pendingPrintJob.mode}
templates={
pendingPrintJob.mode === "certificate"
? CERTIFICATE_PRINT_TEMPLATE_OPTIONS
: BARCODE_PRINT_TEMPLATE_OPTIONS
}
rowSummary={buildPrintRowSummary(pendingPrintRow)}
onClose={() => setPendingPrintJob(null)}
onSubmit={(value) => void handleTemplatePrint(value)}
/>
) : null}
{pendingCertificateEdit && pendingCertificateEditRow ? (
<BarcodePrintCertificateEditDialog
key={`${pendingCertificateEdit.templateTitle}:${pendingCertificateEdit.rowId}`}
isOpen
editMode={pendingCertificateEdit.editMode}
templateTitle={pendingCertificateEdit.templateTitle}
initialSofiaCartonForm={
pendingCertificateEdit.editMode === "sofiaCarton"
? buildSofiaCartonCertificateForm(pendingCertificateEditRow)
: undefined
}
initialCommonForm={
pendingCertificateEdit.editMode === "commonWithMaker" ||
pendingCertificateEdit.editMode === "commonWithoutMaker"
? buildCommonCertificateForm(pendingCertificateEditRow)
: undefined
}
onClose={() => setPendingCertificateEdit(null)}
onPrint={(options) => void handleCertificateEditPrint(options)}
/>
) : null}
{certificatePreview ? (
<BarcodePrintCertificatePreviewDialog
isOpen
title={certificatePreview.title}
pageSize={certificatePreview.pageSize}
imageDataUrl={certificatePreview.imageDataUrl}
onClose={() => setCertificatePreview(null)}
onPrint={() => void printCertificatePreview(certificatePreview)}
/>
) : null}
<BarcodePrintProgressToast progress={importProgress} />
</section>
);
}

View File

@ -0,0 +1,778 @@
"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 = 309;
const LANDSCAPE_FIELD_ROW_GAP = 69;
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 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) => {
appendItem(container, `item_${index + 1} left item`, text, {
position: "absolute",
fontWeight: "600",
fontSize: `${index === 1 ? (options.leftBarcodeFontSize ?? 30) : 30}px`,
color: "#000000",
lineHeight: "32px",
top: `${LANDSCAPE_FIELD_TOP + index * LANDSCAPE_FIELD_ROW_GAP}px`,
left: "224px",
});
});
rightRows.forEach((text, index) => {
appendItem(container, `item_${index + 1} right item`, text, {
position: "absolute",
fontWeight: "600",
fontSize: index === 3 ? "28px" : "30px",
color: "#000000",
lineHeight: "32px",
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) => {
appendItem(container, `item_${index + 1} left item`, text, {
position: "absolute",
fontWeight: "500",
fontSize: "26px",
color: "#000000",
lineHeight: "32px",
top: `${882 + index * 69}px`,
left: "224px",
});
});
rightRows.forEach((text, index) => {
appendItem(container, `item_${index + 1} right item`, text, {
position: "absolute",
fontWeight: "500",
fontSize: "26px",
color: "#000000",
lineHeight: "32px",
top: `${882 + index * 69}px`,
left: "754px",
});
});
return container;
}
function renderSofiaCertificate(
record: CertificateRecord,
_qrCodeBase64: string,
_options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main_sfy", backgroundDataUrl, 874, 1181);
[
getProductLabel(record),
record.procode || record.brandProcode,
record.size,
record.executionStandard,
"QC01",
getLast10DayText(),
].forEach((text, index) => {
appendItem(container, `item_${index + 1} item`, text, {
position: "absolute",
fontSize: "26px",
color: "#000000",
lineHeight: "32px",
left: "310px",
top: `${398 + index * 66}px`,
});
});
return container;
}
function renderGujiaCertificate(
record: CertificateRecord,
_qrCodeBase64: string,
_options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main_gj", backgroundDataUrl, 1323, 1559);
[
getProductLabel(record),
record.brandProcode || record.procode,
record.productSpec || record.size,
record.hangtagMaterial || record.qualifiedCertMainMaterial,
"顾家家居股份有限公司",
"B",
"合格",
getLast10DayText(),
"",
].forEach((text, index) => {
appendItem(container, `item_${index + 1} item`, text, {
position: "absolute",
fontSize: "42px",
color: "#000000",
lineHeight: "48px",
left: "455px",
top: `${530 + index * 101}px`,
});
});
return container;
}
function appendSofiaCartonItem(
container: HTMLElement,
text: string,
top: number,
left = 634,
) {
appendItem(container, "item", text, {
position: "absolute",
left: `${left}px`,
top: `${top}px`,
fontSize: "24px",
lineHeight: "1",
fontWeight: "bold",
});
}
function renderSofiaCarton(
record: CertificateRecord,
_qrCodeBase64: string,
options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const form =
options.sofiaCartonForm ?? buildSofiaCartonCertificateFormFromRecord(record);
const productLabel = [form.productCategory, form.color]
.map((value) => value.trim())
.filter(Boolean)
.join("-");
const container = createCertificateMain("certificate_main_sfy_wxtz", backgroundDataUrl, 1181, 827);
if (form.lineArtUrl) {
const lineArtContainer = createElement("div", "blob-container");
const image = document.createElement("img");
image.src = form.lineArtUrl;
Object.assign(lineArtContainer.style, {
position: "absolute",
width: "446px",
height: "354px",
display: "flex",
alignItems: "center",
justifyContent: "center",
left: "10px",
top: "228px",
zIndex: "1",
} satisfies Partial<CSSStyleDeclaration>);
Object.assign(image.style, {
display: "block",
maxWidth: "80%",
maxHeight: "80%",
} satisfies Partial<CSSStyleDeclaration>);
lineArtContainer.appendChild(image);
container.appendChild(lineArtContainer);
}
appendItem(container, "title productCategory", productLabel, {
position: "absolute",
left: "500px",
top: "321px",
lineHeight: "1",
fontSize: "40px",
fontWeight: "bold",
});
appendItem(container, "title barcode", form.barcode, {
position: "absolute",
left: "500px",
top: "391px",
lineHeight: "1",
fontSize: "40px",
fontWeight: "bold",
});
appendSofiaCartonItem(container, form.color, 454);
appendSofiaCartonItem(container, form.JZ, 500);
appendSofiaCartonItem(container, form.MZ, 500, 952);
appendSofiaCartonItem(container, form.WXTJ, 546);
appendSofiaCartonItem(container, form.WXCC, 592);
appendSofiaCartonItem(container, form.XH, 638);
appendSofiaCartonItem(container, form.ZXSL, 684);
appendSofiaCartonItem(container, form.CD, 730);
return container;
}
function renderCommonWithMaker(
record: CertificateRecord,
qrCodeBase64: string,
options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main", backgroundDataUrl, 1181, 827);
const form = options.commonForm ?? buildCommonCertificateFormFromRecord(record);
const maker = options.batchNumber?.trim()
? `${form.maker}(${options.batchNumber.trim()})`
: form.maker;
container.appendChild(createQrCode(qrCodeBase64));
addLandscapeRows(
container,
[form.productName, form.barcode, form.size, form.hangtagMaterial, maker],
[form.executionStandard, form.level, form.auther, form.address, form.phone],
);
return container;
}
function renderCommonWithoutMaker(
record: CertificateRecord,
qrCodeBase64: string,
options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain(
"certificate_main certificate_main_easy",
backgroundDataUrl,
1181,
827,
);
const form = options.commonForm ?? buildCommonCertificateFormFromRecord(record);
container.appendChild(createQrCode(qrCodeBase64));
addLandscapeRows(
container,
[form.productName, form.barcode, form.size, form.hangtagMaterial],
[form.executionStandard, form.level, form.auther],
);
return container;
}
const CERTIFICATE_TEMPLATES: CertificateTemplate[] = [
{
title: PUMEI_LANDSCAPE_TITLE,
pageSize: "100mm 70mm",
background: "hgz.jpg",
render: renderPumeiLandscape,
},
{
title: PUMEI_PORTRAIT_TITLE,
pageSize: "100mm 130mm",
background: "hgz_v2.jpg",
render: renderPumeiPortrait,
},
{
title: SOFIA_CERTIFICATE_TITLE,
pageSize: "74mm 100mm",
background: "hgz_sfy.png",
render: renderSofiaCertificate,
},
{
title: SOFIA_CARTON_TITLE,
pageSize: "100mm 70mm",
background: "wxtz_sfy.jpg",
editMode: "sofiaCarton",
render: renderSofiaCarton,
},
{
title: COMMON_WITH_MAKER_TITLE,
pageSize: "100mm 70mm",
background: "hgz.jpg",
editMode: "commonWithMaker",
render: renderCommonWithMaker,
},
{
title: COMMON_WITHOUT_MAKER_TITLE,
pageSize: "100mm 70mm",
background: "hgz_easy.jpg",
editMode: "commonWithoutMaker",
render: renderCommonWithoutMaker,
},
{
title: GUJIA_CERTIFICATE_TITLE,
pageSize: "110mm 130mm",
background: "hgz_gj.jpg",
render: renderGujiaCertificate,
},
];
export const CERTIFICATE_PRINT_TEMPLATE_OPTIONS = CERTIFICATE_TEMPLATES.map(
({ editMode, title }) => ({ editMode, title }),
);
function findCertificateTemplate(title: string) {
const template = CERTIFICATE_TEMPLATES.find((item) => item.title === title);
if (!template) {
throw new Error("请选择有效的合格证模板。");
}
return template;
}
async function createQrCodeBase64(barcode: string) {
if (!barcode) return "";
return new Promise<string>((resolve, reject) => {
QRCode.toDataURL(
barcode,
{
width: 182,
margin: 0,
},
(error, url) => {
if (error) {
reject(error);
return;
}
resolve(url);
},
);
});
}
function appendHiddenRenderRoot(element: HTMLElement) {
const root = document.createElement("div");
Object.assign(root.style, {
position: "fixed",
left: "-20000px",
top: "0",
width: element.style.width || "auto",
height: element.style.height || "auto",
overflow: "visible",
pointerEvents: "none",
} satisfies Partial<CSSStyleDeclaration>);
root.appendChild(element);
document.body.appendChild(root);
return root;
}
function waitForImages(root: HTMLElement) {
const images = Array.from(root.querySelectorAll("img"));
return Promise.all(
images.map(
(image) =>
new Promise<void>((resolve, reject) => {
if (image.complete) {
if (image.naturalWidth > 0) {
resolve();
return;
}
reject(new Error("合格证图片加载失败。"));
return;
}
image.onload = () => {
if (image.naturalWidth > 0) {
resolve();
return;
}
reject(new Error("合格证图片加载失败。"));
};
image.onerror = () => reject(new Error("合格证图片加载失败。"));
}),
),
);
}
function waitForImageUrl(url: string) {
return new Promise<void>((resolve, reject) => {
const image = new Image();
image.onload = () => {
if (image.naturalWidth > 0) {
resolve();
return;
}
reject(new Error("合格证背景图片加载失败。"));
};
image.onerror = () => reject(new Error("合格证背景图片加载失败。"));
image.src = url;
});
}
async function imageUrlToDataUrl(url: string) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error("合格证背景图片加载失败。");
}
const blob = await response.blob();
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string" && reader.result.startsWith("data:")) {
resolve(reader.result);
return;
}
reject(new Error("合格证背景图片读取失败。"));
};
reader.onerror = () => reject(new Error("合格证背景图片读取失败。"));
reader.readAsDataURL(blob);
});
}
export async function createCertificatePrintPreview(
row: BarcodePrintRow,
templateTitle: string,
options: CertificatePrintOptions = {},
): Promise<CertificatePrintPreview> {
const template = findCertificateTemplate(templateTitle);
const record = buildCertificateRecord(row);
const qrCodeBase64 = await createQrCodeBase64(record.barcode);
const backgroundUrl = assetUrl(`${TEMPLATE_BASE}/${template.background}`);
const backgroundDataUrl = await imageUrlToDataUrl(backgroundUrl);
const element = template.render(record, qrCodeBase64, options, backgroundDataUrl);
const root = appendHiddenRenderRoot(element);
try {
await Promise.all([waitForImages(root), waitForImageUrl(backgroundDataUrl)]);
const canvas = await html2canvas(element, {
backgroundColor: null,
logging: false,
scale: 1,
useCORS: true,
});
return {
title: template.title,
pageSize: template.pageSize,
imageDataUrl: canvas.toDataURL("image/png", 1),
};
} finally {
root.remove();
}
}
export async function printCertificatePreview(preview: CertificatePrintPreview) {
const printJS = (await import("print-js")).default;
printJS({
printable: preview.imageDataUrl,
type: "image",
style: `
@page {
size: ${preview.pageSize};
margin: 0;
}
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
img {
max-width: 100%;
max-height: 100%;
display: block;
}
`,
honorMarginPadding: false,
targetStyles: ["*"],
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -210,6 +210,7 @@ export function buildIntakePrimaryDomainProps(params: {
canViewBarcodePrint: params.canViewBarcodePrint,
barcodePrint: {
products: params.nonIntegratedProducts,
user: params.user,
},
};
}