feat(intake): add barcode print workbench
This commit is contained in:
parent
6b4dcbdd31
commit
2a84f66951
@ -56,6 +56,14 @@ const connectSources = [
|
||||
...(!isProduction ? ['ws:', 'http://localhost:*', 'http://127.0.0.1:*'] : []),
|
||||
]
|
||||
|
||||
const lodopScriptSources = [
|
||||
'http://127.0.0.1:8000',
|
||||
'http://127.0.0.1:18000',
|
||||
'http://localhost:8000',
|
||||
'http://localhost:18000',
|
||||
'https://localhost.lodop.net:8443',
|
||||
]
|
||||
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
@ -65,7 +73,7 @@ const contentSecurityPolicy = [
|
||||
"img-src 'self' data: blob: http: https:",
|
||||
"font-src 'self' data:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
`script-src 'self' 'unsafe-inline'${isProduction ? '' : " 'unsafe-eval'"}`,
|
||||
`script-src 'self' 'unsafe-inline' ${lodopScriptSources.join(' ')}${isProduction ? '' : " 'unsafe-eval'"}`,
|
||||
`connect-src ${connectSources.join(' ')}`,
|
||||
"worker-src 'self' blob:",
|
||||
"media-src 'self' blob:",
|
||||
|
||||
280
furniture-3d-system/package-lock.json
generated
280
furniture-3d-system/package-lock.json
generated
@ -20,6 +20,7 @@
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -29,6 +30,7 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"gsap": "^3.12.5",
|
||||
"html2canvas": "^1.4.1",
|
||||
"ioredis": "^5.9.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
@ -41,9 +43,11 @@
|
||||
"pg": "^8.17.2",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"postcss": "^8.5.6",
|
||||
"print-js": "^1.6.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
"qiniu": "^7.15.2",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
@ -6242,6 +6246,15 @@
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
@ -7785,6 +7798,15 @@
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@ -8186,7 +8208,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@ -8751,6 +8772,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz",
|
||||
@ -8896,6 +8926,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@ -9154,6 +9193,12 @@
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/docker-modem": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/docker-modem/-/docker-modem-5.0.6.tgz",
|
||||
@ -11276,6 +11321,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@ -14926,7 +14984,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@ -15024,7 +15081,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -15540,6 +15596,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/print-js": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/print-js/-/print-js-1.6.0.tgz",
|
||||
"integrity": "sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.3.0.tgz",
|
||||
@ -15826,6 +15888,182 @@
|
||||
"@types/offscreencanvas": "^2019.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
@ -16119,6 +16357,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
|
||||
@ -16395,6 +16639,12 @@
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@ -17468,6 +17718,15 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
|
||||
@ -18247,6 +18506,15 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
|
||||
@ -18459,6 +18727,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
furniture-3d-system/public/barcode-print/templates/hgz.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_easy.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_easy.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_gj.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_gj.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 738 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_sfy.png
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_sfy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/hgz_v2.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/hgz_v2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
BIN
furniture-3d-system/public/barcode-print/templates/wxtz_sfy.jpg
Normal file
BIN
furniture-3d-system/public/barcode-print/templates/wxtz_sfy.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
@ -215,7 +215,12 @@ export function IntakeActiveWorkspaceContent({
|
||||
}
|
||||
|
||||
if (activeWorkspaceTab.kind === "barcodePrint") {
|
||||
return <BarcodePrintWorkspace products={nonIntegratedProducts} />;
|
||||
return (
|
||||
<BarcodePrintWorkspace
|
||||
products={nonIntegratedProducts}
|
||||
user={currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BarcodePrintCertificateEditDialog } from './BarcodePrintCertificateEditDialog'
|
||||
|
||||
describe('BarcodePrintCertificateEditDialog', () => {
|
||||
it('shows Sofia carton editable fields before printing', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const onPrint = jest.fn()
|
||||
|
||||
render(
|
||||
<BarcodePrintCertificateEditDialog
|
||||
isOpen
|
||||
editMode="sofiaCarton"
|
||||
templateTitle="索菲亚外箱贴纸(10cm*7cm)"
|
||||
initialSofiaCartonForm={{
|
||||
color: '奶油白',
|
||||
barcode: 'WPH-SZ015713N1170V01',
|
||||
productCategory: '餐椅',
|
||||
lineArtUrl: '',
|
||||
JZ: '',
|
||||
MZ: '',
|
||||
WXTJ: '',
|
||||
WXCC: '',
|
||||
XH: '',
|
||||
ZXSL: '',
|
||||
CD: '',
|
||||
}}
|
||||
onClose={jest.fn()}
|
||||
onPrint={onPrint}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('索菲亚外箱贴纸预览')
|
||||
expect(screen.getByRole('button', { name: '上传线稿图' })).toBeInTheDocument()
|
||||
|
||||
await actor.type(screen.getByLabelText('净重'), '10kg')
|
||||
await actor.type(screen.getByLabelText('毛重'), '12kg')
|
||||
await actor.type(screen.getByLabelText('外箱体积'), '0.5')
|
||||
await actor.type(screen.getByLabelText('外箱尺寸'), '100*70*40')
|
||||
await actor.type(screen.getByLabelText('箱号'), '1/2')
|
||||
await actor.type(screen.getByLabelText('装箱数量'), '2')
|
||||
await actor.type(screen.getByLabelText('产地'), '中国')
|
||||
await actor.click(screen.getByRole('button', { name: '打印' }))
|
||||
|
||||
expect(onPrint).toHaveBeenCalledWith({
|
||||
sofiaCartonForm: expect.objectContaining({
|
||||
JZ: '10kg',
|
||||
MZ: '12kg',
|
||||
WXTJ: '0.5',
|
||||
WXCC: '100*70*40',
|
||||
XH: '1/2',
|
||||
ZXSL: '2',
|
||||
CD: '中国',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps maker address and phone hidden for common template without supplier', () => {
|
||||
render(
|
||||
<BarcodePrintCertificateEditDialog
|
||||
isOpen
|
||||
editMode="commonWithoutMaker"
|
||||
templateTitle="通用横版贴纸-不带供应商(10cm*7cm)"
|
||||
initialCommonForm={{
|
||||
productName: '餐椅-奶油白',
|
||||
barcode: 'WPH-SZ015713N1170V01',
|
||||
size: '100*70*40',
|
||||
hangtagMaterial: '科技布',
|
||||
maker: '常州朴美家居有限公司',
|
||||
executionStandard: 'GB/T 3324',
|
||||
level: '合格',
|
||||
auther: 'B',
|
||||
address: '常州经济开发区横林镇红赵路62号',
|
||||
phone: '19941684327',
|
||||
}}
|
||||
onClose={jest.fn()}
|
||||
onPrint={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('通用外箱贴纸预览')
|
||||
expect(screen.getByLabelText('产品名称')).toHaveValue('餐椅-奶油白')
|
||||
expect(screen.queryByLabelText('制造商')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('地址')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('联系电话')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import { Printer, Upload, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import type {
|
||||
CertificatePrintOptions,
|
||||
CertificateSpecialEditMode,
|
||||
CommonCertificateForm,
|
||||
SofiaCartonCertificateForm,
|
||||
} from "./barcodePrintCertificate";
|
||||
|
||||
const TEMPLATE_BASE = "/barcode-print/templates";
|
||||
|
||||
type BarcodePrintCertificateEditDialogProps = {
|
||||
isOpen: boolean;
|
||||
editMode: CertificateSpecialEditMode;
|
||||
templateTitle: string;
|
||||
initialSofiaCartonForm?: SofiaCartonCertificateForm;
|
||||
initialCommonForm?: CommonCertificateForm;
|
||||
onClose: () => void;
|
||||
onPrint: (options: CertificatePrintOptions) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function miniInputStyle({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
}: {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}) {
|
||||
return {
|
||||
position: "absolute",
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height: 20,
|
||||
padding: "0 6px",
|
||||
border: "1px solid #dcdfe6",
|
||||
borderRadius: 4,
|
||||
color: "#606266",
|
||||
fontSize: 10,
|
||||
lineHeight: "20px",
|
||||
outline: "none",
|
||||
background: "#ffffff",
|
||||
} satisfies CSSProperties;
|
||||
}
|
||||
|
||||
function commonInputStyle({
|
||||
left,
|
||||
top,
|
||||
}: {
|
||||
left: number;
|
||||
top: number;
|
||||
}) {
|
||||
return miniInputStyle({ left, top, width: 170 });
|
||||
}
|
||||
|
||||
function titleStyle(top: number) {
|
||||
return {
|
||||
position: "absolute",
|
||||
left: 250,
|
||||
top,
|
||||
color: "#000000",
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
} satisfies CSSProperties;
|
||||
}
|
||||
|
||||
function textValue(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function BarcodePrintCertificateEditDialog({
|
||||
isOpen,
|
||||
editMode,
|
||||
templateTitle,
|
||||
initialSofiaCartonForm,
|
||||
initialCommonForm,
|
||||
onClose,
|
||||
onPrint,
|
||||
}: BarcodePrintCertificateEditDialogProps) {
|
||||
const lineArtInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const ownedLineArtUrlRef = useRef("");
|
||||
const [sofiaCartonForm, setSofiaCartonForm] = useState(
|
||||
initialSofiaCartonForm,
|
||||
);
|
||||
const [commonForm, setCommonForm] = useState(initialCommonForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (ownedLineArtUrlRef.current) {
|
||||
URL.revokeObjectURL(ownedLineArtUrlRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
function updateSofiaField<K extends keyof SofiaCartonCertificateForm>(
|
||||
key: K,
|
||||
value: SofiaCartonCertificateForm[K],
|
||||
) {
|
||||
setSofiaCartonForm((current) =>
|
||||
current ? { ...current, [key]: value } : current,
|
||||
);
|
||||
}
|
||||
|
||||
function updateCommonField<K extends keyof CommonCertificateForm>(
|
||||
key: K,
|
||||
value: CommonCertificateForm[K],
|
||||
) {
|
||||
setCommonForm((current) => (current ? { ...current, [key]: value } : current));
|
||||
}
|
||||
|
||||
function handleLineArtFile(file: File | null) {
|
||||
if (!file) return;
|
||||
if (ownedLineArtUrlRef.current) {
|
||||
URL.revokeObjectURL(ownedLineArtUrlRef.current);
|
||||
}
|
||||
|
||||
const nextUrl = URL.createObjectURL(file);
|
||||
ownedLineArtUrlRef.current = nextUrl;
|
||||
updateSofiaField("lineArtUrl", nextUrl);
|
||||
}
|
||||
|
||||
function submitPrint() {
|
||||
if (editMode === "sofiaCarton" && sofiaCartonForm) {
|
||||
void onPrint({ sofiaCartonForm });
|
||||
return;
|
||||
}
|
||||
|
||||
if (commonForm) {
|
||||
void onPrint({ commonForm });
|
||||
}
|
||||
}
|
||||
|
||||
const isSofiaCarton = editMode === "sofiaCarton";
|
||||
const isCommonWithoutMaker = editMode === "commonWithoutMaker";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="barcode-print-certificate-edit-title"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 px-4 py-[8vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-[660px] overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-white shadow-[0_30px_80px_rgba(0,0,0,0.3)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-5 py-3">
|
||||
<h2
|
||||
id="barcode-print-certificate-edit-title"
|
||||
className="text-[15px] font-apple-semibold text-[var(--obs-shell-title)]"
|
||||
>
|
||||
{isSofiaCarton ? "索菲亚外箱贴纸预览" : "通用外箱贴纸预览"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto px-5 py-5">
|
||||
{isSofiaCarton && sofiaCartonForm ? (
|
||||
<div
|
||||
className="mx-auto"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 590,
|
||||
height: 423,
|
||||
background: `url(${TEMPLATE_BASE}/wxtz_sfy.jpg) no-repeat center center`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
>
|
||||
<span style={titleStyle(160.5)}>
|
||||
{[sofiaCartonForm.productCategory, sofiaCartonForm.color]
|
||||
.map(textValue)
|
||||
.filter(Boolean)
|
||||
.join("-")}
|
||||
</span>
|
||||
<span style={titleStyle(195.5)}>{sofiaCartonForm.barcode}</span>
|
||||
<input
|
||||
ref={lineArtInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.png"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleLineArtFile(event.currentTarget.files?.[0] ?? null);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
{sofiaCartonForm.lineArtUrl ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 5,
|
||||
top: 114,
|
||||
display: "flex",
|
||||
width: 223,
|
||||
height: 167,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- 本地线稿图需要即时预览。 */}
|
||||
<img
|
||||
src={sofiaCartonForm.lineArtUrl}
|
||||
alt="线稿图"
|
||||
style={{ display: "block", maxWidth: "80%", maxHeight: "80%" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lineArtInputRef.current?.click()}
|
||||
className="absolute inline-flex h-6 -translate-x-1/2 items-center gap-1 rounded-[4px] border border-[#8cc5ff] bg-[#ecf5ff] px-2 text-[12px] font-apple-medium text-[#409eff]"
|
||||
style={{ left: 113, top: 186 }}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
上传线稿图
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
aria-label="颜色"
|
||||
readOnly
|
||||
value={sofiaCartonForm.color}
|
||||
style={miniInputStyle({ left: 314, top: 229, width: 90 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="净重"
|
||||
placeholder="请填写净重"
|
||||
value={sofiaCartonForm.JZ}
|
||||
onChange={(event) => updateSofiaField("JZ", event.target.value)}
|
||||
style={miniInputStyle({ left: 314, top: 252, width: 90 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="毛重"
|
||||
placeholder="请填写毛重"
|
||||
value={sofiaCartonForm.MZ}
|
||||
onChange={(event) => updateSofiaField("MZ", event.target.value)}
|
||||
style={miniInputStyle({ left: 476, top: 252, width: 90 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="外箱体积"
|
||||
placeholder="请填写外箱体积"
|
||||
value={sofiaCartonForm.WXTJ}
|
||||
onChange={(event) => updateSofiaField("WXTJ", event.target.value)}
|
||||
style={miniInputStyle({ left: 314, top: 275, width: 90 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="外箱尺寸"
|
||||
placeholder="请填写外箱尺寸"
|
||||
value={sofiaCartonForm.WXCC}
|
||||
onChange={(event) => updateSofiaField("WXCC", event.target.value)}
|
||||
style={miniInputStyle({ left: 314, top: 298, width: 90 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="箱号"
|
||||
placeholder="请填箱号"
|
||||
value={sofiaCartonForm.XH}
|
||||
onChange={(event) => updateSofiaField("XH", event.target.value)}
|
||||
style={miniInputStyle({ left: 314, top: 321, width: 90 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="装箱数量"
|
||||
placeholder="请填写装箱数量"
|
||||
value={sofiaCartonForm.ZXSL}
|
||||
onChange={(event) => updateSofiaField("ZXSL", event.target.value)}
|
||||
style={miniInputStyle({ left: 314, top: 344, width: 90 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="产地"
|
||||
placeholder="请填写产地"
|
||||
value={sofiaCartonForm.CD}
|
||||
onChange={(event) => updateSofiaField("CD", event.target.value)}
|
||||
style={miniInputStyle({ left: 314, top: 367, width: 90 })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isSofiaCarton && commonForm ? (
|
||||
<div
|
||||
className="mx-auto"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 590,
|
||||
height: 413,
|
||||
background: `url(${TEMPLATE_BASE}/${
|
||||
isCommonWithoutMaker ? "hgz_easy.jpg" : "hgz.jpg"
|
||||
}) no-repeat center center`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label="产品名称"
|
||||
placeholder="请填写产品名称"
|
||||
value={commonForm.productName}
|
||||
onChange={(event) => updateCommonField("productName", event.target.value)}
|
||||
style={commonInputStyle({ left: 112, top: 154 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="条码"
|
||||
placeholder="请填写条码"
|
||||
value={commonForm.barcode}
|
||||
onChange={(event) => updateCommonField("barcode", event.target.value)}
|
||||
style={commonInputStyle({ left: 112, top: 188 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="产品规格"
|
||||
placeholder="请填写产品规格"
|
||||
value={commonForm.size}
|
||||
onChange={(event) => updateCommonField("size", event.target.value)}
|
||||
style={commonInputStyle({ left: 112, top: 222 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="主要材质"
|
||||
placeholder="请填写主要材质"
|
||||
value={commonForm.hangtagMaterial}
|
||||
onChange={(event) => updateCommonField("hangtagMaterial", event.target.value)}
|
||||
style={commonInputStyle({ left: 112, top: 256 })}
|
||||
/>
|
||||
{!isCommonWithoutMaker ? (
|
||||
<input
|
||||
aria-label="制造商"
|
||||
placeholder="请填写制造商"
|
||||
value={commonForm.maker}
|
||||
onChange={(event) => updateCommonField("maker", event.target.value)}
|
||||
style={commonInputStyle({ left: 112, top: 290 })}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
aria-label="执行标准"
|
||||
placeholder="请填写执行标准"
|
||||
value={commonForm.executionStandard}
|
||||
onChange={(event) =>
|
||||
updateCommonField("executionStandard", event.target.value)
|
||||
}
|
||||
style={commonInputStyle({ left: 377, top: 154 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="质量等级"
|
||||
placeholder="请填写质量等级"
|
||||
value={commonForm.level}
|
||||
onChange={(event) => updateCommonField("level", event.target.value)}
|
||||
style={commonInputStyle({ left: 377, top: 188 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="检验员"
|
||||
placeholder="请填写检验员"
|
||||
value={commonForm.auther}
|
||||
onChange={(event) => updateCommonField("auther", event.target.value)}
|
||||
style={commonInputStyle({ left: 377, top: 222 })}
|
||||
/>
|
||||
{!isCommonWithoutMaker ? (
|
||||
<>
|
||||
<input
|
||||
aria-label="地址"
|
||||
placeholder="地址"
|
||||
value={commonForm.address}
|
||||
onChange={(event) => updateCommonField("address", event.target.value)}
|
||||
style={commonInputStyle({ left: 377, top: 256 })}
|
||||
/>
|
||||
<input
|
||||
aria-label="联系电话"
|
||||
placeholder="联系电话"
|
||||
value={commonForm.phone}
|
||||
onChange={(event) => updateCommonField("phone", event.target.value)}
|
||||
style={commonInputStyle({ left: 377, top: 290 })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitPrint}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
title={templateTitle}
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
打印
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { Printer, X } from "lucide-react";
|
||||
|
||||
export type BarcodePrintCertificatePreviewDialogProps = {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
pageSize: string;
|
||||
imageDataUrl: string;
|
||||
onClose: () => void;
|
||||
onPrint: () => void;
|
||||
};
|
||||
|
||||
export function BarcodePrintCertificatePreviewDialog({
|
||||
isOpen,
|
||||
title,
|
||||
pageSize: _pageSize,
|
||||
imageDataUrl,
|
||||
onClose,
|
||||
onPrint,
|
||||
}: BarcodePrintCertificatePreviewDialogProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="barcode-print-certificate-preview-title"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 px-4 py-[8vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-[min(660px,96vw)] flex-col overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-white shadow-[0_30px_80px_rgba(0,0,0,0.3)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-5 py-3">
|
||||
<h2
|
||||
id="barcode-print-certificate-preview-title"
|
||||
className="text-[15px] font-apple-semibold text-[var(--obs-shell-title)]"
|
||||
title={title}
|
||||
>
|
||||
合格证预览
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[72vh] overflow-auto bg-white">
|
||||
<div className="flex justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- 打印预览使用 html2canvas 生成的 data URL。 */}
|
||||
<img
|
||||
src={imageDataUrl}
|
||||
alt={title}
|
||||
className="block w-full select-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrint}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>打印</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const FILTER_FIELD_CLASS =
|
||||
"flex h-7 shrink-0 items-center gap-1.5 text-[12px] text-[var(--obs-shell-muted)]";
|
||||
const FILTER_LABEL_CLASS =
|
||||
"shrink-0 whitespace-nowrap font-apple-semibold text-[var(--obs-shell-title)]";
|
||||
const FILTER_CONTROL_CLASS =
|
||||
"h-7 w-[132px] min-w-[132px] rounded-[5px] border border-[var(--obs-shell-border)] bg-white px-2 text-[11px] font-apple-medium text-[var(--obs-shell-title)] outline-none transition placeholder:text-[var(--obs-shell-subtle)] focus:border-[var(--obs-shell-title)]";
|
||||
|
||||
export function BarcodePrintInput({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={FILTER_FIELD_CLASS}>
|
||||
<span className={FILTER_LABEL_CLASS}>{label}</span>
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={FILTER_CONTROL_CLASS}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodePrintSelect({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
options: string[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={FILTER_FIELD_CLASS}>
|
||||
<span className={FILTER_LABEL_CLASS}>{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={FILTER_CONTROL_CLASS}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodePrintDateInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={FILTER_FIELD_CLASS}>
|
||||
<span className={FILTER_LABEL_CLASS}>{label}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={FILTER_CONTROL_CLASS}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodePrintActionButton({
|
||||
disabled = false,
|
||||
children,
|
||||
intent = "default",
|
||||
onClick,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
intent?: "default" | "primary" | "danger";
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const className =
|
||||
intent === "primary"
|
||||
? "border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] text-[var(--obs-shell-action-text)] hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
: intent === "danger"
|
||||
? "border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] text-[var(--obs-shell-danger)] hover:bg-[rgba(179,69,61,0.11)]"
|
||||
: "border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-control-text)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]";
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[6px] border px-2.5 text-[11px] font-apple-semibold transition disabled:cursor-not-allowed disabled:opacity-55 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { Download, RotateCcw, Search, Upload } from "lucide-react";
|
||||
import type { BarcodePrintImportKind } from "./barcodePrintImport";
|
||||
import {
|
||||
BarcodePrintActionButton,
|
||||
BarcodePrintDateInput,
|
||||
BarcodePrintInput,
|
||||
BarcodePrintSelect,
|
||||
} from "./BarcodePrintControls";
|
||||
import type { BarcodePrintFilterState } from "./barcodePrintTypes";
|
||||
|
||||
const CLODOP_DOWNLOAD_URL = "https://www.lodop.net/download.html";
|
||||
|
||||
function isCLodopStatusMessage(message: string) {
|
||||
return /C-?Lodop|LODOP/i.test(message);
|
||||
}
|
||||
|
||||
export function BarcodePrintHeader({
|
||||
draftFilters,
|
||||
productCategoryOptions,
|
||||
salesCategoryOptions,
|
||||
statusMessage,
|
||||
onBarcodeImport,
|
||||
actionDisabled = false,
|
||||
onBrandSkuImport,
|
||||
onDownloadTemplate,
|
||||
onFilterChange,
|
||||
onSearch,
|
||||
onReset,
|
||||
}: {
|
||||
draftFilters: BarcodePrintFilterState;
|
||||
productCategoryOptions: string[];
|
||||
salesCategoryOptions: string[];
|
||||
statusMessage: string;
|
||||
actionDisabled?: boolean;
|
||||
onBarcodeImport: () => void;
|
||||
onBrandSkuImport: () => void;
|
||||
onDownloadTemplate: (kind: BarcodePrintImportKind) => void;
|
||||
onFilterChange: <K extends keyof BarcodePrintFilterState>(
|
||||
key: K,
|
||||
value: BarcodePrintFilterState[K],
|
||||
) => void;
|
||||
onSearch: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="shrink-0 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBarcodeImport}>
|
||||
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>条码导入</span>
|
||||
</BarcodePrintActionButton>
|
||||
<BarcodePrintActionButton onClick={() => onDownloadTemplate("barcode")}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>条码导入模板下载</span>
|
||||
</BarcodePrintActionButton>
|
||||
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBrandSkuImport}>
|
||||
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>品牌货号导入</span>
|
||||
</BarcodePrintActionButton>
|
||||
<BarcodePrintActionButton onClick={() => onDownloadTemplate("brandSku")}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>品牌货号导入模板下载</span>
|
||||
</BarcodePrintActionButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<BarcodePrintInput
|
||||
label="SPU"
|
||||
value={draftFilters.spu}
|
||||
placeholder="请输入SPU"
|
||||
onChange={(value) => onFilterChange("spu", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="型号"
|
||||
value={draftFilters.model}
|
||||
placeholder="请输入型号"
|
||||
onChange={(value) => onFilterChange("model", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="货号"
|
||||
value={draftFilters.skuCode}
|
||||
placeholder="请输入货号"
|
||||
onChange={(value) => onFilterChange("skuCode", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="品牌货号"
|
||||
value={draftFilters.brandSkuCode}
|
||||
placeholder="请输入品牌货号"
|
||||
onChange={(value) => onFilterChange("brandSkuCode", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="条码"
|
||||
value={draftFilters.barcode}
|
||||
placeholder="请输入条码"
|
||||
onChange={(value) => onFilterChange("barcode", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="运营负责人"
|
||||
value={draftFilters.operationsOwner}
|
||||
placeholder="请输入关键词进行过滤"
|
||||
onChange={(value) => onFilterChange("operationsOwner", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="SPU负责人"
|
||||
value={draftFilters.spuOwner}
|
||||
placeholder="请输入关键词进行过滤"
|
||||
onChange={(value) => onFilterChange("spuOwner", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="商品策划"
|
||||
value={draftFilters.productPlanning}
|
||||
placeholder="请输入关键词进行过滤"
|
||||
onChange={(value) => onFilterChange("productPlanning", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="采购负责人"
|
||||
value={draftFilters.purchaseOwner}
|
||||
placeholder="请输入关键词进行过滤"
|
||||
onChange={(value) => onFilterChange("purchaseOwner", value)}
|
||||
/>
|
||||
<BarcodePrintSelect
|
||||
label="商品类目"
|
||||
value={draftFilters.productCategory}
|
||||
placeholder="请选择商品类目"
|
||||
options={productCategoryOptions}
|
||||
onChange={(value) => onFilterChange("productCategory", value)}
|
||||
/>
|
||||
<BarcodePrintSelect
|
||||
label="销售类目"
|
||||
value={draftFilters.salesCategory}
|
||||
placeholder="请选择销售类目"
|
||||
options={salesCategoryOptions}
|
||||
onChange={(value) => onFilterChange("salesCategory", value)}
|
||||
/>
|
||||
<BarcodePrintInput
|
||||
label="供应商名称"
|
||||
value={draftFilters.supplierName}
|
||||
placeholder="请输入供应商名称"
|
||||
onChange={(value) => onFilterChange("supplierName", value)}
|
||||
/>
|
||||
<BarcodePrintDateInput
|
||||
label="开始时间"
|
||||
value={draftFilters.createdFrom}
|
||||
onChange={(value) => onFilterChange("createdFrom", value)}
|
||||
/>
|
||||
<BarcodePrintDateInput
|
||||
label="结束时间"
|
||||
value={draftFilters.createdTo}
|
||||
onChange={(value) => onFilterChange("createdTo", value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearch}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-3 text-[11px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>查询</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-3 text-[11px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>重置</span>
|
||||
</button>
|
||||
</div>
|
||||
{statusMessage ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] font-apple-medium text-[var(--obs-shell-muted)]">
|
||||
<span>{statusMessage}</span>
|
||||
{isCLodopStatusMessage(statusMessage) ? (
|
||||
<a
|
||||
href={CLODOP_DOWNLOAD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-6 items-center rounded-[5px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-2 text-[11px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
>
|
||||
下载 C-Lodop 插件
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { BARCODE_PRINT_PAGE_SIZE } from "./barcodePrintTableConfig";
|
||||
|
||||
export function BarcodePrintPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRows,
|
||||
onPageChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalRows: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-center justify-center border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-3 text-[11px] font-apple-medium text-[var(--obs-shell-muted)]">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
className="mr-3 rounded-[5px] px-3 py-1.5 transition hover:bg-[var(--obs-shell-hover-bg)] disabled:cursor-not-allowed disabled:text-[var(--obs-shell-subtle)] disabled:hover:bg-transparent"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>共 {totalRows} 条</span>
|
||||
<span>第</span>
|
||||
<span className="inline-flex h-7 min-w-10 items-center justify-center rounded-[5px] border border-[var(--obs-shell-border)] bg-white px-2 text-[var(--obs-shell-title)]">
|
||||
{currentPage}
|
||||
</span>
|
||||
<span>/ {totalPages} 页</span>
|
||||
<span>每页 {BARCODE_PRINT_PAGE_SIZE} 条</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
className="ml-3 rounded-[5px] px-3 py-1.5 transition hover:bg-[var(--obs-shell-hover-bg)] disabled:cursor-not-allowed disabled:text-[var(--obs-shell-subtle)] disabled:hover:bg-transparent"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { ImportProgressState } from "./barcodePrintTypes";
|
||||
|
||||
export function BarcodePrintProgressToast({
|
||||
progress,
|
||||
}: {
|
||||
progress: ImportProgressState;
|
||||
}) {
|
||||
if (!progress.active) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed right-5 bottom-5 z-50 w-[280px] rounded-[8px] border border-[var(--obs-shell-border)] bg-white px-3 py-2.5 text-[11px] shadow-[0_12px_30px_rgba(17,19,21,0.16)]">
|
||||
<div className="flex items-center justify-between gap-3 font-apple-semibold text-[var(--obs-shell-title)]">
|
||||
<span className="truncate">{progress.label}</span>
|
||||
<span>{progress.percent}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--obs-shell-muted-bg)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
progress.status === "error"
|
||||
? "bg-[var(--obs-shell-danger)]"
|
||||
: "bg-[var(--obs-shell-action-bg)]"
|
||||
}`}
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 line-clamp-2 font-apple-medium ${
|
||||
progress.status === "error"
|
||||
? "text-[var(--obs-shell-danger)]"
|
||||
: "text-[var(--obs-shell-muted)]"
|
||||
}`}
|
||||
>
|
||||
{progress.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { Barcode, FileText, Trash2 } from "lucide-react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { BarcodePrintRow } from "./barcodePrintRows";
|
||||
import {
|
||||
BARCODE_PRINT_COLUMNS,
|
||||
BARCODE_PRINT_PAGE_SIZE,
|
||||
OPERATION_COLUMN_WIDTH,
|
||||
SELECTION_COLUMN_WIDTH,
|
||||
} from "./barcodePrintTableConfig";
|
||||
import type {
|
||||
BarcodePrintColumnKey,
|
||||
BarcodePrintColumnWidths,
|
||||
} from "./barcodePrintTypes";
|
||||
|
||||
function formatCell(value: string) {
|
||||
return value.trim() || "-";
|
||||
}
|
||||
|
||||
export function BarcodePrintTable({
|
||||
pagedRows,
|
||||
filteredRowCount,
|
||||
currentPage,
|
||||
selectedRowIds,
|
||||
allVisibleSelected,
|
||||
tableWidth,
|
||||
rowNumberColumnWidth,
|
||||
columnWidths,
|
||||
onToggleVisibleRows,
|
||||
emptyMessage = "当前没有符合条件的条码资料。",
|
||||
onToggleRow,
|
||||
onStartRowNumberColumnResize,
|
||||
onStartDataColumnResize,
|
||||
onBarcodePrint,
|
||||
onCertificatePrint,
|
||||
onDeleteImportedData,
|
||||
}: {
|
||||
pagedRows: BarcodePrintRow[];
|
||||
filteredRowCount: number;
|
||||
currentPage: number;
|
||||
selectedRowIds: Set<string>;
|
||||
allVisibleSelected: boolean;
|
||||
tableWidth: number;
|
||||
rowNumberColumnWidth: number;
|
||||
columnWidths: BarcodePrintColumnWidths;
|
||||
onToggleVisibleRows: () => void;
|
||||
emptyMessage?: string;
|
||||
onToggleRow: (rowId: string) => void;
|
||||
onStartRowNumberColumnResize: (
|
||||
event: ReactMouseEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
onStartDataColumnResize: (
|
||||
event: ReactMouseEvent<HTMLButtonElement>,
|
||||
key: BarcodePrintColumnKey,
|
||||
) => void;
|
||||
onBarcodePrint: (row: BarcodePrintRow) => void;
|
||||
onCertificatePrint: (row: BarcodePrintRow) => void;
|
||||
onDeleteImportedData: (importRecordIds: string[] | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-white">
|
||||
<table
|
||||
className="table-fixed border-separate border-spacing-0 text-left text-[11px] whitespace-nowrap"
|
||||
aria-label="条码打印列表"
|
||||
style={{ width: tableWidth, minWidth: tableWidth }}
|
||||
>
|
||||
<thead className="sticky top-0 z-20 bg-[var(--obs-shell-surface-bg)] text-[var(--obs-shell-muted)]">
|
||||
<tr>
|
||||
<th
|
||||
className="border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5"
|
||||
style={{
|
||||
width: SELECTION_COLUMN_WIDTH,
|
||||
minWidth: SELECTION_COLUMN_WIDTH,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="选择当前筛选结果"
|
||||
checked={allVisibleSelected}
|
||||
onChange={onToggleVisibleRows}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="relative border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 pr-3 font-apple-semibold"
|
||||
style={{
|
||||
width: rowNumberColumnWidth,
|
||||
minWidth: rowNumberColumnWidth,
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">序号</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="调整序号列宽"
|
||||
title="拖动调整列宽"
|
||||
onMouseDown={onStartRowNumberColumnResize}
|
||||
className="absolute top-0 right-[-4px] z-40 h-full w-2 cursor-col-resize touch-none border-r border-transparent transition hover:border-[var(--obs-shell-title)]"
|
||||
/>
|
||||
</th>
|
||||
{BARCODE_PRINT_COLUMNS.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="relative border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 pr-3 font-apple-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width: columnWidths[column.key],
|
||||
minWidth: columnWidths[column.key],
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">{column.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`调整${column.label}列宽`}
|
||||
title="拖动调整列宽"
|
||||
onMouseDown={(event) =>
|
||||
onStartDataColumnResize(event, column.key)
|
||||
}
|
||||
className="absolute top-0 right-[-4px] z-40 h-full w-2 cursor-col-resize touch-none border-r border-transparent transition hover:border-[var(--obs-shell-title)]"
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
className="sticky right-0 z-30 border-b border-l border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] px-2 py-1.5 text-center font-apple-semibold shadow-[-10px_0_14px_-14px_rgba(17,19,21,0.28)]"
|
||||
style={{
|
||||
width: OPERATION_COLUMN_WIDTH,
|
||||
minWidth: OPERATION_COLUMN_WIDTH,
|
||||
}}
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRowCount === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={BARCODE_PRINT_COLUMNS.length + 3}
|
||||
className="px-6 py-14 text-center text-sm font-apple-medium text-[var(--obs-shell-muted)]"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pagedRows.map((row, index) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="odd:bg-white even:bg-[var(--obs-shell-surface-bg)]"
|
||||
>
|
||||
<td
|
||||
className="border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5"
|
||||
style={{
|
||||
width: SELECTION_COLUMN_WIDTH,
|
||||
minWidth: SELECTION_COLUMN_WIDTH,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择第${
|
||||
(currentPage - 1) * BARCODE_PRINT_PAGE_SIZE + index + 1
|
||||
}行`}
|
||||
checked={selectedRowIds.has(row.id)}
|
||||
onChange={() => onToggleRow(row.id)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 text-[var(--obs-shell-muted)]"
|
||||
style={{
|
||||
width: rowNumberColumnWidth,
|
||||
minWidth: rowNumberColumnWidth,
|
||||
}}
|
||||
>
|
||||
{(currentPage - 1) * BARCODE_PRINT_PAGE_SIZE + index + 1}
|
||||
</td>
|
||||
{BARCODE_PRINT_COLUMNS.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className="max-w-0 border-b border-r border-[var(--obs-shell-border)] px-2 py-1.5 text-[var(--obs-shell-title)] whitespace-nowrap"
|
||||
style={{
|
||||
width: columnWidths[column.key],
|
||||
minWidth: columnWidths[column.key],
|
||||
}}
|
||||
title={formatCell(String(row[column.key]))}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{formatCell(String(row[column.key]))}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
<td
|
||||
className="sticky right-0 z-10 border-b border-l border-[var(--obs-shell-border)] bg-inherit px-1.5 py-1.5 text-center shadow-[-10px_0_14px_-14px_rgba(17,19,21,0.2)]"
|
||||
style={{
|
||||
width: OPERATION_COLUMN_WIDTH,
|
||||
minWidth: OPERATION_COLUMN_WIDTH,
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="条码打印"
|
||||
title="条码打印"
|
||||
onClick={() => onBarcodePrint(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
>
|
||||
<Barcode className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="合格证打印"
|
||||
title="合格证打印"
|
||||
onClick={() => onCertificatePrint(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{row.importRecordIds?.length ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除导入数据"
|
||||
title="删除导入数据"
|
||||
onClick={() => onDeleteImportedData(row.importRecordIds)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] border border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] text-[var(--obs-shell-danger)] transition hover:bg-[rgba(179,69,61,0.11)]"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BarcodePrintTemplateDialog } from './BarcodePrintTemplateDialog'
|
||||
|
||||
describe('BarcodePrintTemplateDialog', () => {
|
||||
it('submits the selected barcode template', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const onSubmit = jest.fn()
|
||||
|
||||
render(
|
||||
<BarcodePrintTemplateDialog
|
||||
isOpen
|
||||
mode="barcode"
|
||||
templates={[
|
||||
{ title: '条码打印_第一套编码(数字开头)&一次性采买条码' },
|
||||
{ title: '顾家' },
|
||||
]}
|
||||
rowSummary="CS0002 / CS000201A / WPH-SZ015713N1170V01"
|
||||
onClose={jest.fn()}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.selectOptions(screen.getByLabelText('打印模板'), '顾家')
|
||||
await actor.type(screen.getByPlaceholderText('可不填'), 'PC01')
|
||||
await actor.click(screen.getByRole('button', { name: '确定' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
templateTitle: '顾家',
|
||||
batchNumber: 'PC01',
|
||||
editMode: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses certificate template wording', () => {
|
||||
render(
|
||||
<BarcodePrintTemplateDialog
|
||||
isOpen
|
||||
mode="certificate"
|
||||
templates={[{ title: '朴美横版贴纸(10cm*7cm)' }]}
|
||||
rowSummary="CS0002 / CS000201A"
|
||||
onClose={jest.fn()}
|
||||
onSubmit={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('请选择合格证模板')
|
||||
expect(screen.getByText('朴美横版贴纸(10cm*7cm)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes edit mode for common certificate supplier template', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const onSubmit = jest.fn()
|
||||
|
||||
render(
|
||||
<BarcodePrintTemplateDialog
|
||||
isOpen
|
||||
mode="certificate"
|
||||
templates={[
|
||||
{ title: '朴美横版贴纸(10cm*7cm)' },
|
||||
{
|
||||
title: '通用横版贴纸-带供应商(10cm*7cm)',
|
||||
editMode: 'commonWithMaker',
|
||||
},
|
||||
]}
|
||||
rowSummary="CS0002 / CS000201A"
|
||||
onClose={jest.fn()}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.selectOptions(
|
||||
screen.getByLabelText('打印模板'),
|
||||
'通用横版贴纸-带供应商(10cm*7cm)',
|
||||
)
|
||||
|
||||
expect(screen.queryByPlaceholderText('可不填')).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '确定' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
templateTitle: '通用横版贴纸-带供应商(10cm*7cm)',
|
||||
batchNumber: '',
|
||||
editMode: 'commonWithMaker',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CertificateSpecialEditMode } from "./barcodePrintCertificate";
|
||||
|
||||
type PrintTemplateOption = {
|
||||
title: string;
|
||||
editMode?: CertificateSpecialEditMode;
|
||||
};
|
||||
|
||||
export type BarcodePrintTemplateDialogMode = "barcode" | "certificate";
|
||||
|
||||
export type BarcodePrintTemplateDialogSubmit = {
|
||||
templateTitle: string;
|
||||
batchNumber?: string;
|
||||
editMode?: CertificateSpecialEditMode;
|
||||
};
|
||||
|
||||
export function BarcodePrintTemplateDialog({
|
||||
isOpen,
|
||||
mode,
|
||||
templates,
|
||||
rowSummary,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
mode: BarcodePrintTemplateDialogMode;
|
||||
templates: PrintTemplateOption[];
|
||||
rowSummary: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (value: BarcodePrintTemplateDialogSubmit) => void;
|
||||
}) {
|
||||
const defaultTemplateTitle = useMemo(
|
||||
() => templates[0]?.title ?? "",
|
||||
[templates],
|
||||
);
|
||||
const [templateTitle, setTemplateTitle] = useState(defaultTemplateTitle);
|
||||
const [batchNumber, setBatchNumber] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const title = mode === "barcode" ? "请选择打印模板" : "请选择合格证模板";
|
||||
const selectedTemplate = templates.find((template) => template.title === templateTitle);
|
||||
const showBatchNumber = mode === "barcode" && templateTitle === "顾家";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
function submit() {
|
||||
if (!templateTitle) {
|
||||
setErrorMessage("请选择打印模板。");
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
templateTitle,
|
||||
batchNumber,
|
||||
editMode: selectedTemplate?.editMode,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="barcode-print-template-dialog-title"
|
||||
className="fixed inset-0 z-50 flex justify-end bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-[min(600px,100vw)] flex-col border-l border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[-24px_0_48px_rgba(17,19,21,0.18)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between border-b border-[var(--obs-shell-border)] px-5">
|
||||
<h2
|
||||
id="barcode-print-template-dialog-title"
|
||||
className="text-[15px] font-apple-semibold"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-5 py-5">
|
||||
<div className="rounded-[6px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] px-3 py-2 text-[12px] font-apple-medium text-[var(--obs-shell-muted)]">
|
||||
{rowSummary}
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1.5 text-[12px] font-apple-semibold text-[var(--obs-shell-title)]">
|
||||
<span>打印模板</span>
|
||||
<select
|
||||
autoFocus
|
||||
value={templateTitle}
|
||||
onChange={(event) => {
|
||||
setTemplateTitle(event.target.value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
className="h-9 w-full rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-2.5 text-[12px] font-apple-medium text-[var(--obs-shell-title)] outline-none transition focus:border-[var(--obs-shell-title)]"
|
||||
>
|
||||
<option value="">请选择打印模板,目前暂不支持模板配置</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.title} value={template.title}>
|
||||
{template.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{showBatchNumber ? (
|
||||
<label className="block space-y-1.5 text-[12px] font-apple-semibold text-[var(--obs-shell-title)]">
|
||||
<span>批次</span>
|
||||
<input
|
||||
value={batchNumber}
|
||||
placeholder="可不填"
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
className="h-9 w-full rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-2.5 text-[12px] font-apple-medium text-[var(--obs-shell-title)] outline-none transition placeholder:text-[var(--obs-shell-subtle)] focus:border-[var(--obs-shell-title)]"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-[6px] border border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] px-3 py-2 text-[12px] font-apple-medium text-[var(--obs-shell-danger)]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: ["*"],
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import type { BarcodePrintRow } from "./barcodePrintRows";
|
||||
import type { BarcodePrintFilterState } from "./barcodePrintTypes";
|
||||
|
||||
export const EMPTY_FILTERS: BarcodePrintFilterState = {
|
||||
spu: "",
|
||||
model: "",
|
||||
skuCode: "",
|
||||
brandSkuCode: "",
|
||||
barcode: "",
|
||||
operationsOwner: "",
|
||||
spuOwner: "",
|
||||
productPlanning: "",
|
||||
purchaseOwner: "",
|
||||
productCategory: "",
|
||||
salesCategory: "",
|
||||
supplierName: "",
|
||||
createdFrom: "",
|
||||
createdTo: "",
|
||||
};
|
||||
|
||||
function textIncludes(value: string, query: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes(query.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function rowMatchesBarcodePrintFilters(
|
||||
row: BarcodePrintRow,
|
||||
filters: BarcodePrintFilterState,
|
||||
) {
|
||||
if (filters.spu && !textIncludes(row.spu, filters.spu)) return false;
|
||||
if (filters.model && !textIncludes(row.model, filters.model)) return false;
|
||||
if (filters.skuCode && !textIncludes(row.skuCode, filters.skuCode)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.brandSkuCode &&
|
||||
!textIncludes(row.brandSkuCode, filters.brandSkuCode)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filters.barcode && !textIncludes(row.barcode, filters.barcode)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.operationsOwner &&
|
||||
!textIncludes(row.operationsOwner, filters.operationsOwner)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filters.spuOwner && !textIncludes(row.spuOwner, filters.spuOwner)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.productPlanning &&
|
||||
!textIncludes(row.productPlanning, filters.productPlanning)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.purchaseOwner &&
|
||||
!textIncludes(row.purchaseOwner, filters.purchaseOwner)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filters.productCategory && row.productCategory !== filters.productCategory) {
|
||||
return false;
|
||||
}
|
||||
if (filters.salesCategory && row.salesCategory !== filters.salesCategory) {
|
||||
return false;
|
||||
}
|
||||
if (filters.supplierName && !textIncludes(row.supplierName, filters.supplierName)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.createdFrom && row.createdAt && row.createdAt < filters.createdFrom) {
|
||||
return false;
|
||||
}
|
||||
if (filters.createdTo && row.createdAt && row.createdAt > filters.createdTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getBarcodePrintUniqueOptions(
|
||||
rows: BarcodePrintRow[],
|
||||
key: keyof BarcodePrintRow,
|
||||
) {
|
||||
return Array.from(
|
||||
new Set(rows.map((row) => String(row[key]).trim()).filter(Boolean)),
|
||||
).sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
}
|
||||
@ -0,0 +1,431 @@
|
||||
import type { BarcodePrintRow } from "./barcodePrintRows";
|
||||
|
||||
export type BarcodePrintImportKind = "barcode" | "brandSku";
|
||||
|
||||
type BarcodePrintImportUpdates = Partial<
|
||||
Omit<BarcodePrintRow, "id" | "importRecordIds">
|
||||
> & {
|
||||
skuCode: string;
|
||||
dataSource: string;
|
||||
};
|
||||
|
||||
export type BarcodePrintImportRecord = {
|
||||
id: string;
|
||||
kind: BarcodePrintImportKind;
|
||||
skuCode: string;
|
||||
sourceFileName: string;
|
||||
rowNumber: number;
|
||||
importedAt: number;
|
||||
updates: BarcodePrintImportUpdates;
|
||||
};
|
||||
|
||||
export type BarcodePrintImportResult = {
|
||||
kind: BarcodePrintImportKind;
|
||||
fileName: string;
|
||||
records: BarcodePrintImportRecord[];
|
||||
};
|
||||
|
||||
export const BARCODE_PRINT_IMPORT_ACCEPT =
|
||||
".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
const BARCODE_IMPORT_HEADERS = [
|
||||
"SPU",
|
||||
"型号",
|
||||
"货号",
|
||||
"条码",
|
||||
"工厂型号",
|
||||
"规格名称",
|
||||
"商品类目",
|
||||
"尺寸",
|
||||
"颜色",
|
||||
"执行标准",
|
||||
"吊牌材质",
|
||||
"供应商名称",
|
||||
];
|
||||
|
||||
const BRAND_SKU_IMPORT_HEADERS = [
|
||||
"货号",
|
||||
"品牌货号",
|
||||
"品名",
|
||||
"料号",
|
||||
"规格(长*宽*高)",
|
||||
"合格证的主材*基材",
|
||||
];
|
||||
|
||||
const IMPORT_LABEL_BY_KIND: Record<BarcodePrintImportKind, string> = {
|
||||
barcode: "条码导入",
|
||||
brandSku: "品牌货号导入",
|
||||
};
|
||||
|
||||
const TEMPLATE_HEADERS_BY_KIND: Record<BarcodePrintImportKind, string[]> = {
|
||||
barcode: BARCODE_IMPORT_HEADERS,
|
||||
brandSku: BRAND_SKU_IMPORT_HEADERS,
|
||||
};
|
||||
|
||||
const TEMPLATE_FILE_NAME_BY_KIND: Record<BarcodePrintImportKind, string> = {
|
||||
barcode: "条码导入模板.xlsx",
|
||||
brandSku: "品牌货号导入模板.xlsx",
|
||||
};
|
||||
|
||||
const EMPTY_STANDALONE_ROW: Omit<BarcodePrintRow, "id"> = {
|
||||
spu: "",
|
||||
model: "",
|
||||
specCode: "",
|
||||
setCount: "",
|
||||
skuCode: "",
|
||||
brandSkuCode: "",
|
||||
productName: "",
|
||||
materialNo: "",
|
||||
specification: "",
|
||||
certificateMaterial: "",
|
||||
barcode: "",
|
||||
factoryModel: "",
|
||||
attributeCode: "",
|
||||
drawingId: "",
|
||||
barcodeSpecName: "",
|
||||
productCategory: "",
|
||||
salesCategory: "",
|
||||
materialType: "",
|
||||
size: "",
|
||||
color: "",
|
||||
material: "",
|
||||
executeStandard: "",
|
||||
tagMaterial: "",
|
||||
supplierName: "",
|
||||
warehouse: "",
|
||||
installationService: "",
|
||||
lengthCm: "",
|
||||
widthCm: "",
|
||||
heightCm: "",
|
||||
volumeCm3: "",
|
||||
weightKg: "",
|
||||
costPrice: "",
|
||||
productPlanning: "",
|
||||
visualPlanning: "",
|
||||
spuOwner: "",
|
||||
operationsOwner: "",
|
||||
purchaseOwner: "",
|
||||
productionType: "",
|
||||
shippingMode: "",
|
||||
supplierCategory: "",
|
||||
purchaseOrderMethod: "",
|
||||
assemblyDetail: "",
|
||||
createdAt: "",
|
||||
dataSource: "导入",
|
||||
};
|
||||
|
||||
function normalizeHeader(value: unknown) {
|
||||
return String(value ?? "").replace(/^\uFEFF/, "").trim();
|
||||
}
|
||||
|
||||
function normalizeSkuCode(value: string) {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
function cellText(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function importRecordKey(record: BarcodePrintImportRecord) {
|
||||
return `${record.kind}:${normalizeSkuCode(record.skuCode)}`;
|
||||
}
|
||||
|
||||
function buildHeaderIndex(headers: unknown[]) {
|
||||
const headerIndex = new Map<string, number>();
|
||||
headers.forEach((header, index) => {
|
||||
const normalizedHeader = normalizeHeader(header);
|
||||
if (normalizedHeader) {
|
||||
headerIndex.set(normalizedHeader, index);
|
||||
}
|
||||
});
|
||||
return headerIndex;
|
||||
}
|
||||
|
||||
function requireHeaders(
|
||||
headerIndex: Map<string, number>,
|
||||
kind: BarcodePrintImportKind,
|
||||
) {
|
||||
const missingHeaders = TEMPLATE_HEADERS_BY_KIND[kind].filter(
|
||||
(header) => !headerIndex.has(header),
|
||||
);
|
||||
|
||||
if (missingHeaders.length > 0) {
|
||||
throw new Error(
|
||||
`${IMPORT_LABEL_BY_KIND[kind]}模板缺少字段:${missingHeaders.join("、")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCell(
|
||||
row: unknown[],
|
||||
headerIndex: Map<string, number>,
|
||||
headerName: string,
|
||||
) {
|
||||
const index = headerIndex.get(headerName);
|
||||
return typeof index === "number" ? cellText(row[index]) : "";
|
||||
}
|
||||
|
||||
function setIfPresent<
|
||||
K extends keyof Omit<BarcodePrintRow, "id" | "importRecordIds">,
|
||||
>(
|
||||
updates: Partial<Record<K, string>>,
|
||||
key: K,
|
||||
value: string,
|
||||
) {
|
||||
if (value.trim()) {
|
||||
updates[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function createRecordId(
|
||||
kind: BarcodePrintImportKind,
|
||||
skuCode: string,
|
||||
rowNumber: number,
|
||||
) {
|
||||
const normalizedSkuCode = normalizeSkuCode(skuCode).replace(
|
||||
/[^A-Z0-9_-]/g,
|
||||
"_",
|
||||
);
|
||||
return `barcode-print-import:${kind}:${Date.now()}:${rowNumber}:${normalizedSkuCode || "row"}`;
|
||||
}
|
||||
|
||||
function buildBarcodeUpdates(
|
||||
row: unknown[],
|
||||
headerIndex: Map<string, number>,
|
||||
) {
|
||||
const skuCode = getCell(row, headerIndex, "货号");
|
||||
const updates: BarcodePrintImportRecord["updates"] = {
|
||||
skuCode,
|
||||
dataSource: "导入",
|
||||
};
|
||||
const importedCategory = getCell(row, headerIndex, "商品类目");
|
||||
const importedMaterial = getCell(row, headerIndex, "吊牌材质");
|
||||
|
||||
setIfPresent(updates, "spu", getCell(row, headerIndex, "SPU"));
|
||||
setIfPresent(updates, "model", getCell(row, headerIndex, "型号"));
|
||||
setIfPresent(updates, "barcode", getCell(row, headerIndex, "条码"));
|
||||
setIfPresent(updates, "factoryModel", getCell(row, headerIndex, "工厂型号"));
|
||||
setIfPresent(
|
||||
updates,
|
||||
"barcodeSpecName",
|
||||
getCell(row, headerIndex, "规格名称"),
|
||||
);
|
||||
setIfPresent(updates, "productCategory", importedCategory);
|
||||
setIfPresent(updates, "salesCategory", importedCategory);
|
||||
setIfPresent(updates, "size", getCell(row, headerIndex, "尺寸"));
|
||||
setIfPresent(updates, "color", getCell(row, headerIndex, "颜色"));
|
||||
setIfPresent(
|
||||
updates,
|
||||
"executeStandard",
|
||||
getCell(row, headerIndex, "执行标准"),
|
||||
);
|
||||
setIfPresent(updates, "material", importedMaterial);
|
||||
setIfPresent(updates, "tagMaterial", importedMaterial);
|
||||
setIfPresent(
|
||||
updates,
|
||||
"supplierName",
|
||||
getCell(row, headerIndex, "供应商名称"),
|
||||
);
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
function buildBrandSkuUpdates(
|
||||
row: unknown[],
|
||||
headerIndex: Map<string, number>,
|
||||
) {
|
||||
const skuCode = getCell(row, headerIndex, "货号");
|
||||
const updates: BarcodePrintImportRecord["updates"] = {
|
||||
skuCode,
|
||||
dataSource: "导入",
|
||||
};
|
||||
|
||||
setIfPresent(
|
||||
updates,
|
||||
"brandSkuCode",
|
||||
getCell(row, headerIndex, "品牌货号"),
|
||||
);
|
||||
setIfPresent(updates, "productName", getCell(row, headerIndex, "品名"));
|
||||
setIfPresent(updates, "materialNo", getCell(row, headerIndex, "料号"));
|
||||
setIfPresent(
|
||||
updates,
|
||||
"specification",
|
||||
getCell(row, headerIndex, "规格(长*宽*高)"),
|
||||
);
|
||||
setIfPresent(
|
||||
updates,
|
||||
"certificateMaterial",
|
||||
getCell(row, headerIndex, "合格证的主材*基材"),
|
||||
);
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
function buildImportRecord({
|
||||
row,
|
||||
headerIndex,
|
||||
kind,
|
||||
fileName,
|
||||
rowNumber,
|
||||
}: {
|
||||
row: unknown[];
|
||||
headerIndex: Map<string, number>;
|
||||
kind: BarcodePrintImportKind;
|
||||
fileName: string;
|
||||
rowNumber: number;
|
||||
}) {
|
||||
const updates =
|
||||
kind === "barcode"
|
||||
? buildBarcodeUpdates(row, headerIndex)
|
||||
: buildBrandSkuUpdates(row, headerIndex);
|
||||
|
||||
if (!updates.skuCode.trim()) {
|
||||
throw new Error(`第 ${rowNumber} 行缺少货号。`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: createRecordId(kind, updates.skuCode, rowNumber),
|
||||
kind,
|
||||
skuCode: updates.skuCode,
|
||||
sourceFileName: fileName,
|
||||
rowNumber,
|
||||
importedAt: Date.now(),
|
||||
updates,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseBarcodePrintImportFile(
|
||||
file: File,
|
||||
kind: BarcodePrintImportKind,
|
||||
): Promise<BarcodePrintImportResult> {
|
||||
const XLSX = await import("xlsx");
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array", cellDates: false });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
|
||||
if (!sheetName) {
|
||||
throw new Error("导入文件里没有可读取的工作表。");
|
||||
}
|
||||
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(worksheet, {
|
||||
header: 1,
|
||||
defval: "",
|
||||
raw: false,
|
||||
blankrows: false,
|
||||
}) as unknown[][];
|
||||
const headerRow = rows[0] ?? [];
|
||||
const headerIndex = buildHeaderIndex(headerRow);
|
||||
|
||||
requireHeaders(headerIndex, kind);
|
||||
|
||||
const dataRows = rows
|
||||
.slice(1)
|
||||
.map((row, index) => ({ row, rowNumber: index + 2 }))
|
||||
.filter(({ row }) => row.some((cell) => cellText(cell)));
|
||||
|
||||
if (dataRows.length === 0) {
|
||||
throw new Error(`${IMPORT_LABEL_BY_KIND[kind]}模板里没有可导入的数据。`);
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
fileName: file.name,
|
||||
records: dataRows.map(({ row, rowNumber }) =>
|
||||
buildImportRecord({
|
||||
row,
|
||||
headerIndex,
|
||||
kind,
|
||||
fileName: file.name,
|
||||
rowNumber,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertBarcodePrintImportRecords(
|
||||
currentRecords: BarcodePrintImportRecord[],
|
||||
incomingRecords: BarcodePrintImportRecord[],
|
||||
) {
|
||||
const incomingKeys = new Set(incomingRecords.map(importRecordKey));
|
||||
|
||||
return [
|
||||
...currentRecords.filter(
|
||||
(record) => !incomingKeys.has(importRecordKey(record)),
|
||||
),
|
||||
...incomingRecords,
|
||||
];
|
||||
}
|
||||
|
||||
export function mergeBarcodePrintImportRows(
|
||||
baseRows: BarcodePrintRow[],
|
||||
importRecords: BarcodePrintImportRecord[],
|
||||
): BarcodePrintRow[] {
|
||||
const recordsBySkuCode = new Map<string, BarcodePrintImportRecord[]>();
|
||||
|
||||
importRecords.forEach((record) => {
|
||||
const skuCodeKey = normalizeSkuCode(record.skuCode);
|
||||
if (!skuCodeKey) return;
|
||||
const records = recordsBySkuCode.get(skuCodeKey) ?? [];
|
||||
records.push(record);
|
||||
recordsBySkuCode.set(skuCodeKey, records);
|
||||
});
|
||||
|
||||
const matchedSkuCodes = new Set<string>();
|
||||
const mergedRows = baseRows.map((row) => {
|
||||
const skuCodeKey = normalizeSkuCode(row.skuCode);
|
||||
const records = skuCodeKey ? recordsBySkuCode.get(skuCodeKey) : undefined;
|
||||
|
||||
if (!records?.length) return row;
|
||||
|
||||
matchedSkuCodes.add(skuCodeKey);
|
||||
|
||||
const updates = Object.assign({}, ...records.map((record) => record.updates));
|
||||
|
||||
return {
|
||||
...row,
|
||||
...updates,
|
||||
id: row.id,
|
||||
skuCode: row.skuCode || updates.skuCode || "",
|
||||
dataSource: "导入",
|
||||
importRecordIds: records.map((record) => record.id),
|
||||
};
|
||||
});
|
||||
|
||||
const standaloneRows = Array.from(recordsBySkuCode.entries())
|
||||
.filter(([skuCodeKey]) => !matchedSkuCodes.has(skuCodeKey))
|
||||
.map(([skuCodeKey, records]) => {
|
||||
const updates = Object.assign(
|
||||
{},
|
||||
...records.map((record) => record.updates),
|
||||
);
|
||||
|
||||
return {
|
||||
...EMPTY_STANDALONE_ROW,
|
||||
...updates,
|
||||
id: `barcode-print-standalone:${skuCodeKey}`,
|
||||
skuCode: updates.skuCode || records.at(-1)?.skuCode || "",
|
||||
dataSource: "导入",
|
||||
importRecordIds: records.map((record) => record.id),
|
||||
};
|
||||
});
|
||||
|
||||
return [...mergedRows, ...standaloneRows];
|
||||
}
|
||||
|
||||
export async function downloadBarcodePrintImportTemplate(
|
||||
kind: BarcodePrintImportKind,
|
||||
) {
|
||||
const XLSX = await import("xlsx");
|
||||
const headers = TEMPLATE_HEADERS_BY_KIND[kind];
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers]);
|
||||
worksheet["!cols"] = headers.map((header) => ({
|
||||
wch: Math.max(12, header.length + 4),
|
||||
}));
|
||||
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "模板");
|
||||
XLSX.writeFile(workbook, TEMPLATE_FILE_NAME_BY_KIND[kind], {
|
||||
bookType: "xlsx",
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import type { BarcodePrintImportRecord } from "./barcodePrintImport";
|
||||
|
||||
const BARCODE_PRINT_IMPORT_STORAGE_KEY =
|
||||
"intake:barcode-print:import-records:v1";
|
||||
|
||||
function getBarcodePrintImportStorageKey(userId?: string) {
|
||||
const normalizedUserId = String(userId ?? "").trim();
|
||||
return normalizedUserId
|
||||
? `${BARCODE_PRINT_IMPORT_STORAGE_KEY}:${normalizedUserId}`
|
||||
: BARCODE_PRINT_IMPORT_STORAGE_KEY;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeImportRecord(
|
||||
value: unknown,
|
||||
): BarcodePrintImportRecord | null {
|
||||
if (!isPlainObject(value)) return null;
|
||||
if (value.kind !== "barcode" && value.kind !== "brandSku") return null;
|
||||
if (typeof value.id !== "string" || !value.id.trim()) return null;
|
||||
if (typeof value.skuCode !== "string" || !value.skuCode.trim()) return null;
|
||||
if (!isPlainObject(value.updates)) return null;
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
kind: value.kind,
|
||||
skuCode: value.skuCode,
|
||||
sourceFileName:
|
||||
typeof value.sourceFileName === "string" ? value.sourceFileName : "",
|
||||
rowNumber:
|
||||
typeof value.rowNumber === "number" && Number.isFinite(value.rowNumber)
|
||||
? value.rowNumber
|
||||
: 0,
|
||||
importedAt:
|
||||
typeof value.importedAt === "number" && Number.isFinite(value.importedAt)
|
||||
? value.importedAt
|
||||
: Date.now(),
|
||||
updates: {
|
||||
...value.updates,
|
||||
skuCode: String(value.updates.skuCode ?? value.skuCode),
|
||||
dataSource: String(value.updates.dataSource ?? "导入"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function loadBarcodePrintImportRecords(
|
||||
userId?: string,
|
||||
): BarcodePrintImportRecord[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
try {
|
||||
const storedValue = window.localStorage.getItem(
|
||||
getBarcodePrintImportStorageKey(userId),
|
||||
);
|
||||
if (!storedValue) return [];
|
||||
|
||||
const parsedValue = JSON.parse(storedValue) as unknown;
|
||||
if (!Array.isArray(parsedValue)) return [];
|
||||
|
||||
return parsedValue
|
||||
.map(normalizeImportRecord)
|
||||
.filter((record): record is BarcodePrintImportRecord => Boolean(record));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBarcodePrintImportRecords(
|
||||
records: BarcodePrintImportRecord[],
|
||||
userId?: string,
|
||||
) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
getBarcodePrintImportStorageKey(userId),
|
||||
JSON.stringify(records),
|
||||
);
|
||||
} catch {
|
||||
// 本地存储满或被浏览器拦截时,不影响当前页面导入结果。
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,810 @@
|
||||
"use client";
|
||||
|
||||
import { pinyin } from "pinyin-pro";
|
||||
import type { BarcodePrintRow } from "./barcodePrintRows";
|
||||
|
||||
type LodopValue = string | number;
|
||||
|
||||
type Lodop = {
|
||||
PRINT_INIT(title: string): void;
|
||||
SET_PRINT_PAGESIZE?(orientation: number, width: string, height: string, name: string): void;
|
||||
NEWPAGE(): void;
|
||||
SET_PRINT_MODE(key: string, value: string): void;
|
||||
ADD_PRINT_BARCODE(
|
||||
top: LodopValue,
|
||||
left: LodopValue,
|
||||
width: LodopValue,
|
||||
height: LodopValue,
|
||||
codeType: string,
|
||||
value: string,
|
||||
): void;
|
||||
ADD_PRINT_TEXT(
|
||||
top: LodopValue,
|
||||
left: LodopValue,
|
||||
width: LodopValue,
|
||||
height: LodopValue,
|
||||
value: string,
|
||||
): void;
|
||||
ADD_PRINT_RECT(
|
||||
top: LodopValue,
|
||||
left: LodopValue,
|
||||
width: LodopValue,
|
||||
height: LodopValue,
|
||||
lineStyle: number,
|
||||
lineWidth: number,
|
||||
): void;
|
||||
ADD_PRINT_IMAGE(
|
||||
top: LodopValue,
|
||||
left: LodopValue,
|
||||
width: LodopValue,
|
||||
height: LodopValue,
|
||||
html: string,
|
||||
): void;
|
||||
SET_PRINT_STYLEA(index: number, key: string, value: LodopValue): void;
|
||||
PREVIEW(): void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
getCLodop?: () => Lodop | undefined;
|
||||
getLodop?: () => Lodop | undefined;
|
||||
LODOP?: Lodop;
|
||||
}
|
||||
}
|
||||
|
||||
type BarcodeLodopRecord = {
|
||||
barcode: string;
|
||||
factoryNumber: string;
|
||||
skuName1: string;
|
||||
productCategory: string;
|
||||
hangtagMaterial: string;
|
||||
executionStandard: string;
|
||||
printDate: string;
|
||||
catcode: string;
|
||||
bar2: string;
|
||||
bar3: string;
|
||||
};
|
||||
|
||||
type CertificateLodopRecord = {
|
||||
barcode: string;
|
||||
productName: string;
|
||||
skuCode: string;
|
||||
brandSkuCode: string;
|
||||
productCategory: string;
|
||||
color: string;
|
||||
specification: string;
|
||||
material: string;
|
||||
executionStandard: string;
|
||||
supplierName: string;
|
||||
};
|
||||
|
||||
type BarcodePrintTemplate = {
|
||||
title: string;
|
||||
printFunc: (
|
||||
record: BarcodeLodopRecord,
|
||||
lodop: Lodop,
|
||||
options?: { batchNumber?: string },
|
||||
) => void;
|
||||
};
|
||||
|
||||
type CertificatePrintTemplate = {
|
||||
title: string;
|
||||
width: string;
|
||||
height: string;
|
||||
orientation: 1 | 2;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
background: string;
|
||||
layout:
|
||||
| "pumeiLandscape"
|
||||
| "pumeiPortrait"
|
||||
| "sofiaCertificate"
|
||||
| "sofiaCarton"
|
||||
| "commonWithMaker"
|
||||
| "commonWithoutMaker"
|
||||
| "gujiaCertificate";
|
||||
qr?: {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type BarcodePrintTemplateTitle = string;
|
||||
export type CertificatePrintTemplateTitle = string;
|
||||
|
||||
const CLODOP_SCRIPT_URLS = [
|
||||
"http://127.0.0.1:8000/CLodopfuncs.js",
|
||||
"http://127.0.0.1:18000/CLodopfuncs.js",
|
||||
"http://localhost:8000/CLodopfuncs.js",
|
||||
"http://localhost:18000/CLodopfuncs.js",
|
||||
"https://localhost.lodop.net:8443/CLodopfuncs.js",
|
||||
];
|
||||
|
||||
let lodopScriptLoadPromise: Promise<void> | null = null;
|
||||
|
||||
function trimText(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function getTodayPrintDate() {
|
||||
const now = new Date();
|
||||
return [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function readLeafCategory(value: string) {
|
||||
const parts = value
|
||||
.split(">")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return parts.at(-1) ?? value.trim();
|
||||
}
|
||||
|
||||
function buildColorCode(color: string) {
|
||||
if (!color.trim()) return "";
|
||||
return pinyin(color, { toneType: "none", type: "array" })
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function splitBarcodeParts(barcode: string) {
|
||||
const catcode = barcode.match(/^[A-Za-z|-]+/)?.[0] ?? "";
|
||||
const rest = catcode ? barcode.slice(catcode.length) : barcode;
|
||||
const [bar2 = rest, bar3Tail = ""] = rest.split("N");
|
||||
|
||||
return {
|
||||
catcode,
|
||||
bar2,
|
||||
bar3: bar3Tail ? `N${bar3Tail}` : "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildLodopRecord(row: BarcodePrintRow): BarcodeLodopRecord {
|
||||
const barcode = trimText(row.barcode);
|
||||
if (!barcode) {
|
||||
throw new Error("当前行没有条码,无法打印。");
|
||||
}
|
||||
|
||||
const parts = splitBarcodeParts(barcode);
|
||||
const size = trimText(row.size).replace(/\//g, ".");
|
||||
|
||||
return {
|
||||
barcode,
|
||||
factoryNumber: trimText(row.factoryModel),
|
||||
skuName1: `${buildColorCode(row.color)}${size}`,
|
||||
productCategory: readLeafCategory(row.productCategory),
|
||||
hangtagMaterial: trimText(row.tagMaterial || row.material),
|
||||
executionStandard: trimText(row.executeStandard),
|
||||
printDate: getTodayPrintDate(),
|
||||
...parts,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCertificateRecord(row: BarcodePrintRow): CertificateLodopRecord {
|
||||
const barcode = trimText(row.barcode);
|
||||
if (!barcode) {
|
||||
throw new Error("当前行没有条码,无法打印合格证。");
|
||||
}
|
||||
|
||||
return {
|
||||
barcode,
|
||||
productName: trimText(row.productName),
|
||||
skuCode: trimText(row.skuCode),
|
||||
brandSkuCode: trimText(row.brandSkuCode),
|
||||
productCategory: readLeafCategory(trimText(row.productCategory)),
|
||||
color: trimText(row.color),
|
||||
specification: trimText(row.specification || row.size),
|
||||
material: trimText(row.certificateMaterial || row.material || row.tagMaterial),
|
||||
executionStandard: trimText(row.executeStandard),
|
||||
supplierName: trimText(row.supplierName),
|
||||
};
|
||||
}
|
||||
|
||||
function loadScript(src: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[data-clodop-src="${src}"]`);
|
||||
if (existing) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
const timer = window.setTimeout(() => {
|
||||
script.remove();
|
||||
reject(new Error("C-Lodop 加载超时"));
|
||||
}, 1800);
|
||||
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.dataset.clodopSrc = src;
|
||||
script.onload = () => {
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
window.clearTimeout(timer);
|
||||
script.remove();
|
||||
reject(new Error("C-Lodop 加载失败"));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCLodopScript() {
|
||||
if (window.getCLodop || window.getLodop) return;
|
||||
if (!lodopScriptLoadPromise) {
|
||||
lodopScriptLoadPromise = (async () => {
|
||||
for (const url of CLODOP_SCRIPT_URLS) {
|
||||
try {
|
||||
await loadScript(url);
|
||||
if (window.getCLodop || window.getLodop) return;
|
||||
} catch {
|
||||
// Try the next C-Lodop endpoint.
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await lodopScriptLoadPromise;
|
||||
|
||||
if (!window.getCLodop && !window.getLodop) {
|
||||
lodopScriptLoadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getLodop() {
|
||||
await loadCLodopScript();
|
||||
const lodop = window.getLodop?.() ?? window.getCLodop?.() ?? window.LODOP;
|
||||
if (!lodop) {
|
||||
throw new Error("未检测到 C-Lodop 打印插件。请下载安装 C-Lodop,确认插件服务已启动,然后刷新页面再打印。");
|
||||
}
|
||||
|
||||
window.LODOP = lodop;
|
||||
return lodop;
|
||||
}
|
||||
|
||||
function setFont(lodop: Lodop, size: number, bold = false) {
|
||||
lodop.SET_PRINT_STYLEA(0, "FontName", "微软雅黑");
|
||||
lodop.SET_PRINT_STYLEA(0, "FontSize", size);
|
||||
if (bold) lodop.SET_PRINT_STYLEA(0, "Bold", 1);
|
||||
}
|
||||
|
||||
function printCommonLayout(
|
||||
record: BarcodeLodopRecord,
|
||||
lodop: Lodop,
|
||||
options?: {
|
||||
splitCode?: boolean;
|
||||
hideStandard?: boolean;
|
||||
labelPrefix?: string;
|
||||
barcodeTextHeight?: number;
|
||||
barcodeFontSize?: number;
|
||||
productionBatchLeft?: number;
|
||||
},
|
||||
) {
|
||||
lodop.PRINT_INIT("条码打印");
|
||||
lodop.ADD_PRINT_BARCODE(15, 22, "100.01mm", "45.01mm", "QRCode", record.barcode);
|
||||
lodop.SET_PRINT_STYLEA(0, "ShowBarText", 0);
|
||||
lodop.ADD_PRINT_TEXT(26, 181, 304, 25, `型号:${record.factoryNumber}`);
|
||||
setFont(lodop, 12);
|
||||
lodop.ADD_PRINT_TEXT(59, 181, 415, 75, `${options?.labelPrefix ?? ""}${record.skuName1}`);
|
||||
setFont(lodop, 15);
|
||||
lodop.SET_PRINT_STYLEA(0, "TextNeatRow", 1);
|
||||
lodop.ADD_PRINT_TEXT(26, 429, 198, 25, `类目:${record.productCategory}`);
|
||||
setFont(lodop, 15);
|
||||
lodop.ADD_PRINT_TEXT(145, 181, 407, 25, `材质:${record.hangtagMaterial}`);
|
||||
setFont(lodop, 15);
|
||||
lodop.ADD_PRINT_TEXT(179, 26, 160, 25, "执行标准");
|
||||
setFont(lodop, 15);
|
||||
lodop.ADD_PRINT_TEXT(203, 14, 195, 25, options?.hideStandard ? "" : record.executionStandard);
|
||||
setFont(lodop, 15);
|
||||
|
||||
if (options?.splitCode) {
|
||||
lodop.ADD_PRINT_TEXT(203, 191, 150, 25, record.catcode);
|
||||
setFont(lodop, 15);
|
||||
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
|
||||
lodop.ADD_PRINT_TEXT(186, 274, 230, 52, record.bar2);
|
||||
setFont(lodop, 28, true);
|
||||
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
|
||||
lodop.ADD_PRINT_TEXT(203, 474, 141, 25, record.bar3);
|
||||
setFont(lodop, 15);
|
||||
} else {
|
||||
lodop.ADD_PRINT_TEXT(203, 191, 800, options?.barcodeTextHeight ?? 25, record.barcode);
|
||||
setFont(lodop, options?.barcodeFontSize ?? 25, true);
|
||||
}
|
||||
|
||||
lodop.ADD_PRINT_TEXT(182, options?.productionBatchLeft ?? 438, 172, 25, `生产批号:${record.printDate}`);
|
||||
lodop.SET_PRINT_STYLEA(0, "FontName", "微软雅黑");
|
||||
lodop.ADD_PRINT_RECT(20, 178, 420, 157, 0, 1);
|
||||
lodop.ADD_PRINT_RECT(56, 178, 420, 1, 0, 1);
|
||||
lodop.ADD_PRINT_RECT(135, 178, 420, 1, 0, 1);
|
||||
lodop.ADD_PRINT_RECT(21, 426, 1, 34, 0, 1);
|
||||
}
|
||||
|
||||
const BARCODE_PRINT_TEMPLATES: BarcodePrintTemplate[] = [
|
||||
{
|
||||
title: "条码打印_第一套编码(数字开头)&一次性采买条码",
|
||||
printFunc(record, lodop) {
|
||||
printCommonLayout(record, lodop);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "条码打印_第二套模板",
|
||||
printFunc(record, lodop) {
|
||||
printCommonLayout(record, lodop, {
|
||||
splitCode: true,
|
||||
labelPrefix: "编码:",
|
||||
productionBatchLeft: 458,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "外贸条码打印",
|
||||
printFunc(record, lodop) {
|
||||
printCommonLayout(record, lodop, {
|
||||
hideStandard: true,
|
||||
barcodeTextHeight: 20,
|
||||
barcodeFontSize: 20,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "顾家",
|
||||
printFunc(record, lodop, options) {
|
||||
lodop.PRINT_INIT("条码打印");
|
||||
lodop.SET_PRINT_PAGESIZE?.(2, "60mm", "40mm", "");
|
||||
lodop.ADD_PRINT_BARCODE("4mm", "10mm", "25mm", "25mm", "QRCode", record.barcode);
|
||||
lodop.SET_PRINT_STYLEA(0, "ShowBarText", 0);
|
||||
lodop.ADD_PRINT_TEXT("27mm", "0mm", "100%", "1mm", record.catcode);
|
||||
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
|
||||
setFont(lodop, 10, true);
|
||||
lodop.ADD_PRINT_TEXT("32mm", "0mm", "100%", "1mm", record.bar2);
|
||||
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
|
||||
setFont(lodop, 20, true);
|
||||
lodop.ADD_PRINT_TEXT("44mm", "0mm", "100%", "1mm", record.bar3);
|
||||
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
|
||||
setFont(lodop, 10, true);
|
||||
if (options?.batchNumber?.trim()) {
|
||||
lodop.ADD_PRINT_TEXT("48mm", "0mm", "100%", "1mm", `(${options.batchNumber.trim()})`);
|
||||
lodop.SET_PRINT_STYLEA(0, "Alignment", 2);
|
||||
setFont(lodop, 10, true);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const BARCODE_PRINT_TEMPLATE_OPTIONS = BARCODE_PRINT_TEMPLATES.map(
|
||||
({ title }) => ({ title }),
|
||||
);
|
||||
|
||||
const CERTIFICATE_PRINT_TEMPLATES: CertificatePrintTemplate[] = [
|
||||
{
|
||||
title: "朴美横版贴纸(10cm*7cm)",
|
||||
width: "100mm",
|
||||
height: "70mm",
|
||||
orientation: 2,
|
||||
canvasWidth: 1181,
|
||||
canvasHeight: 827,
|
||||
background: "/barcode-print/templates/hgz.jpg",
|
||||
layout: "pumeiLandscape",
|
||||
qr: { x: 921, y: 44, size: 182 },
|
||||
},
|
||||
{
|
||||
title: "朴美竖版贴纸(10cm*13cm)",
|
||||
width: "100mm",
|
||||
height: "130mm",
|
||||
orientation: 1,
|
||||
canvasWidth: 1181,
|
||||
canvasHeight: 1535,
|
||||
background: "/barcode-print/templates/hgz_v2.jpg",
|
||||
layout: "pumeiPortrait",
|
||||
},
|
||||
{
|
||||
title: "索菲亚合格证卡片(7.4cm*10cm)",
|
||||
width: "74mm",
|
||||
height: "100mm",
|
||||
orientation: 1,
|
||||
canvasWidth: 874,
|
||||
canvasHeight: 1181,
|
||||
background: "/barcode-print/templates/hgz_sfy.png",
|
||||
layout: "sofiaCertificate",
|
||||
},
|
||||
{
|
||||
title: "索菲亚外箱贴纸(10cm*7cm)",
|
||||
width: "100mm",
|
||||
height: "70mm",
|
||||
orientation: 2,
|
||||
canvasWidth: 1181,
|
||||
canvasHeight: 827,
|
||||
background: "/barcode-print/templates/wxtz_sfy.jpg",
|
||||
layout: "sofiaCarton",
|
||||
},
|
||||
{
|
||||
title: "通用横版贴纸-带供应商(10cm*7cm)",
|
||||
width: "100mm",
|
||||
height: "70mm",
|
||||
orientation: 2,
|
||||
canvasWidth: 1181,
|
||||
canvasHeight: 827,
|
||||
background: "/barcode-print/templates/hgz.jpg",
|
||||
layout: "commonWithMaker",
|
||||
qr: { x: 921, y: 44, size: 182 },
|
||||
},
|
||||
{
|
||||
title: "通用横版贴纸-不带供应商(10cm*7cm)",
|
||||
width: "100mm",
|
||||
height: "70mm",
|
||||
orientation: 2,
|
||||
canvasWidth: 1181,
|
||||
canvasHeight: 827,
|
||||
background: "/barcode-print/templates/hgz_easy.jpg",
|
||||
layout: "commonWithoutMaker",
|
||||
qr: { x: 921, y: 44, size: 182 },
|
||||
},
|
||||
{
|
||||
title: "顾家合格证卡片(11cm*13cm)",
|
||||
width: "110mm",
|
||||
height: "130mm",
|
||||
orientation: 1,
|
||||
canvasWidth: 1323,
|
||||
canvasHeight: 1559,
|
||||
background: "/barcode-print/templates/hgz_gj.jpg",
|
||||
layout: "gujiaCertificate",
|
||||
},
|
||||
];
|
||||
|
||||
export const CERTIFICATE_PRINT_TEMPLATE_OPTIONS = CERTIFICATE_PRINT_TEMPLATES.map(
|
||||
({ title }) => ({ title }),
|
||||
);
|
||||
|
||||
function chooseBarcodeTemplate(row: BarcodePrintRow) {
|
||||
if (row.supplierName.includes("顾家")) {
|
||||
return BARCODE_PRINT_TEMPLATES.find((template) => template.title === "顾家") ?? BARCODE_PRINT_TEMPLATES[0];
|
||||
}
|
||||
|
||||
return BARCODE_PRINT_TEMPLATES[0];
|
||||
}
|
||||
|
||||
export async function printBarcodeRow(
|
||||
row: BarcodePrintRow,
|
||||
options: {
|
||||
templateTitle?: BarcodePrintTemplateTitle;
|
||||
batchNumber?: string;
|
||||
} = {},
|
||||
) {
|
||||
const lodop = await getLodop();
|
||||
const record = buildLodopRecord(row);
|
||||
const template = options.templateTitle
|
||||
? BARCODE_PRINT_TEMPLATES.find((item) => item.title === options.templateTitle)
|
||||
: chooseBarcodeTemplate(row);
|
||||
|
||||
if (!template) {
|
||||
throw new Error("请选择有效的条码打印模板。");
|
||||
}
|
||||
|
||||
lodop.NEWPAGE();
|
||||
lodop.SET_PRINT_MODE("PRINT_PAGE_PERCENT", "Full-Page");
|
||||
template.printFunc(record, lodop, { batchNumber: options.batchNumber });
|
||||
lodop.PREVIEW();
|
||||
return template.title;
|
||||
}
|
||||
|
||||
type CertificateCanvasText = {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
fontSize: number;
|
||||
fontWeight: string;
|
||||
lineHeight: number;
|
||||
};
|
||||
|
||||
function normalizeCertificateText(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function formatCertificateSpec(value: string) {
|
||||
return normalizeCertificateText(value).replace(/\//g, "*");
|
||||
}
|
||||
|
||||
function getCertificateProductLabel(record: CertificateLodopRecord) {
|
||||
const label = [record.productCategory, record.color]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
|
||||
return normalizeCertificateText(label || record.productName || record.skuCode);
|
||||
}
|
||||
|
||||
function getLast10DayText() {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 10);
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function getCommonMakerText(batchNumber?: string) {
|
||||
const maker = "常州朴美家居有限公司";
|
||||
return batchNumber?.trim() ? `${maker}(${batchNumber.trim()})` : maker;
|
||||
}
|
||||
|
||||
function createCertificateText(
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
options: { fontSize: number; fontWeight?: string; lineHeight?: number },
|
||||
): CertificateCanvasText {
|
||||
return {
|
||||
text: normalizeCertificateText(text),
|
||||
x,
|
||||
y,
|
||||
fontSize: options.fontSize,
|
||||
fontWeight: options.fontWeight ?? "normal",
|
||||
lineHeight: options.lineHeight ?? Math.round(options.fontSize * 1.2),
|
||||
};
|
||||
}
|
||||
|
||||
function getCertificateCanvasTexts(
|
||||
template: CertificatePrintTemplate,
|
||||
record: CertificateLodopRecord,
|
||||
batchNumber?: string,
|
||||
) {
|
||||
const productLabel = getCertificateProductLabel(record);
|
||||
const specification = formatCertificateSpec(record.specification);
|
||||
const material = normalizeCertificateText(record.material);
|
||||
const standard = normalizeCertificateText(record.executionStandard);
|
||||
const last10Day = getLast10DayText();
|
||||
const commonRows = [
|
||||
productLabel,
|
||||
record.barcode,
|
||||
specification,
|
||||
material,
|
||||
"常州朴美家居有限公司",
|
||||
];
|
||||
const commonRightRows = [
|
||||
standard,
|
||||
"合格",
|
||||
"B",
|
||||
"常州经济开发区横林镇红赵路62号",
|
||||
"19941684324",
|
||||
];
|
||||
|
||||
if (template.layout === "pumeiLandscape") {
|
||||
return [
|
||||
...commonRows.map((text, index) =>
|
||||
createCertificateText(text, 224, 317 + index * 69, {
|
||||
fontSize: index === 1 ? 26 : 30,
|
||||
fontWeight: "600",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
...commonRightRows.map((text, index) =>
|
||||
createCertificateText(text, 754, 317 + index * 69, {
|
||||
fontSize: index === 3 ? 28 : 30,
|
||||
fontWeight: "600",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (template.layout === "pumeiPortrait") {
|
||||
return [
|
||||
...commonRows.map((text, index) =>
|
||||
createCertificateText(text, 224, 882 + index * 69, {
|
||||
fontSize: 26,
|
||||
fontWeight: "500",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
...commonRightRows.map((text, index) =>
|
||||
createCertificateText(text, 754, 882 + index * 69, {
|
||||
fontSize: 26,
|
||||
fontWeight: "500",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (template.layout === "sofiaCertificate") {
|
||||
return [productLabel, record.skuCode, specification, standard, "QC01", last10Day].map(
|
||||
(text, index) =>
|
||||
createCertificateText(text, 310, 398 + index * 66, {
|
||||
fontSize: 26,
|
||||
lineHeight: 32,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (template.layout === "sofiaCarton") {
|
||||
return [
|
||||
createCertificateText(productLabel, 500, 321, {
|
||||
fontSize: 40,
|
||||
fontWeight: "bold",
|
||||
lineHeight: 40,
|
||||
}),
|
||||
createCertificateText(record.barcode, 500, 391, {
|
||||
fontSize: 40,
|
||||
fontWeight: "bold",
|
||||
lineHeight: 40,
|
||||
}),
|
||||
createCertificateText(record.color, 634, 454, { fontSize: 24, fontWeight: "bold" }),
|
||||
];
|
||||
}
|
||||
|
||||
if (template.layout === "commonWithMaker") {
|
||||
const leftRows = [
|
||||
productLabel,
|
||||
record.barcode,
|
||||
specification,
|
||||
material,
|
||||
getCommonMakerText(batchNumber),
|
||||
];
|
||||
const rightRows = [standard, "合格", "B", "常州经济开发区横林镇红赵路62号", "19941684327"];
|
||||
|
||||
return [
|
||||
...leftRows.map((text, index) =>
|
||||
createCertificateText(text, 224, 317 + index * 69, {
|
||||
fontSize: 30,
|
||||
fontWeight: "600",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
...rightRows.map((text, index) =>
|
||||
createCertificateText(text, 754, 317 + index * 69, {
|
||||
fontSize: index === 3 ? 28 : 30,
|
||||
fontWeight: "600",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (template.layout === "commonWithoutMaker") {
|
||||
const leftRows = [productLabel, record.barcode, specification, material];
|
||||
const rightRows = [standard, "合格", "B"];
|
||||
|
||||
return [
|
||||
...leftRows.map((text, index) =>
|
||||
createCertificateText(text, 224, 317 + index * 69, {
|
||||
fontSize: 30,
|
||||
fontWeight: "600",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
...rightRows.map((text, index) =>
|
||||
createCertificateText(text, 754, 317 + index * 69, {
|
||||
fontSize: 30,
|
||||
fontWeight: "600",
|
||||
lineHeight: 32,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const gujiaRows = [
|
||||
productLabel,
|
||||
record.brandSkuCode,
|
||||
specification,
|
||||
material,
|
||||
"顾家家居股份有限公司",
|
||||
"B",
|
||||
"合格",
|
||||
last10Day,
|
||||
"",
|
||||
];
|
||||
|
||||
return gujiaRows.map((text, index) =>
|
||||
createCertificateText(text, 455, 530 + index * 101, {
|
||||
fontSize: 42,
|
||||
lineHeight: 48,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function pxToPrintMm(px: number, totalPx: number, totalMm: string) {
|
||||
const printSize = Number.parseFloat(totalMm);
|
||||
if (!Number.isFinite(printSize)) return "0mm";
|
||||
|
||||
return `${(px / totalPx) * printSize}mm`;
|
||||
}
|
||||
|
||||
function pxToPrintFontPt(px: number, template: CertificatePrintTemplate) {
|
||||
const printHeightMm = Number.parseFloat(template.height);
|
||||
if (!Number.isFinite(printHeightMm)) return px;
|
||||
|
||||
const fontHeightMm = (px / template.canvasHeight) * printHeightMm;
|
||||
return Number(((fontHeightMm / 25.4) * 72).toFixed(1));
|
||||
}
|
||||
|
||||
function getCertificateBackgroundUrl(template: CertificatePrintTemplate) {
|
||||
if (typeof window === "undefined") return template.background;
|
||||
|
||||
return new URL(template.background, window.location.origin).toString();
|
||||
}
|
||||
|
||||
function addCertificateTemplateText(
|
||||
lodop: Lodop,
|
||||
template: CertificatePrintTemplate,
|
||||
item: CertificateCanvasText,
|
||||
) {
|
||||
lodop.ADD_PRINT_TEXT(
|
||||
pxToPrintMm(item.y, template.canvasHeight, template.height),
|
||||
pxToPrintMm(item.x, template.canvasWidth, template.width),
|
||||
pxToPrintMm(template.canvasWidth - item.x, template.canvasWidth, template.width),
|
||||
pxToPrintMm(item.lineHeight, template.canvasHeight, template.height),
|
||||
item.text,
|
||||
);
|
||||
lodop.SET_PRINT_STYLEA(0, "FontName", "Microsoft YaHei");
|
||||
lodop.SET_PRINT_STYLEA(0, "FontSize", pxToPrintFontPt(item.fontSize, template));
|
||||
lodop.SET_PRINT_STYLEA(
|
||||
0,
|
||||
"Bold",
|
||||
item.fontWeight === "bold" || Number.parseInt(item.fontWeight, 10) >= 600
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
|
||||
function printCertificateLayout(
|
||||
record: CertificateLodopRecord,
|
||||
lodop: Lodop,
|
||||
template: CertificatePrintTemplate,
|
||||
options: { batchNumber?: string } = {},
|
||||
) {
|
||||
const backgroundUrl = getCertificateBackgroundUrl(template);
|
||||
|
||||
lodop.PRINT_INIT("合格证打印");
|
||||
lodop.SET_PRINT_PAGESIZE?.(template.orientation, template.width, template.height, "");
|
||||
lodop.ADD_PRINT_IMAGE(
|
||||
"0mm",
|
||||
"0mm",
|
||||
template.width,
|
||||
template.height,
|
||||
`<img border="0" src="${backgroundUrl}" style="width:100%;height:100%;" />`,
|
||||
);
|
||||
lodop.SET_PRINT_STYLEA(0, "Stretch", 2);
|
||||
|
||||
for (const item of getCertificateCanvasTexts(template, record, options.batchNumber)) {
|
||||
addCertificateTemplateText(lodop, template, item);
|
||||
}
|
||||
|
||||
if (template.qr) {
|
||||
const qrLeft = pxToPrintMm(template.qr.x, template.canvasWidth, template.width);
|
||||
const qrTop = pxToPrintMm(template.qr.y, template.canvasHeight, template.height);
|
||||
const qrWidth = pxToPrintMm(template.qr.size, template.canvasWidth, template.width);
|
||||
const qrHeight = pxToPrintMm(template.qr.size, template.canvasHeight, template.height);
|
||||
|
||||
lodop.ADD_PRINT_BARCODE(qrTop, qrLeft, qrWidth, qrHeight, "QRCode", record.barcode);
|
||||
lodop.SET_PRINT_STYLEA(0, "ShowBarText", 0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function printCertificateRow(
|
||||
row: BarcodePrintRow,
|
||||
templateTitle?: CertificatePrintTemplateTitle,
|
||||
options: { batchNumber?: string } = {},
|
||||
) {
|
||||
const lodop = await getLodop();
|
||||
const record = buildCertificateRecord(row);
|
||||
const template = templateTitle
|
||||
? CERTIFICATE_PRINT_TEMPLATES.find((item) => item.title === templateTitle)
|
||||
: CERTIFICATE_PRINT_TEMPLATES[0];
|
||||
|
||||
if (!template) {
|
||||
throw new Error("请选择有效的合格证模板。");
|
||||
}
|
||||
|
||||
lodop.NEWPAGE();
|
||||
lodop.SET_PRINT_MODE("PRINT_PAGE_PERCENT", "Full-Page");
|
||||
printCertificateLayout(record, lodop, template, options);
|
||||
lodop.PREVIEW();
|
||||
return template.title;
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import { listSharedNonIntegratedProductPage } from "@/app/intake/non-integrated/nonIntegratedApi";
|
||||
|
||||
const BARCODE_PRINT_PRODUCT_REFRESH_PAGE_SIZE = 1000;
|
||||
|
||||
export async function refreshBarcodePrintProducts(): Promise<
|
||||
NonIntegratedProductRecord[]
|
||||
> {
|
||||
const firstPage = await listSharedNonIntegratedProductPage({
|
||||
page: 1,
|
||||
pageSize: BARCODE_PRINT_PRODUCT_REFRESH_PAGE_SIZE,
|
||||
productKind: "all",
|
||||
quickScope: "all",
|
||||
});
|
||||
|
||||
if (firstPage.pageInfo.totalPages <= 1) {
|
||||
return firstPage.products;
|
||||
}
|
||||
|
||||
const restPages = await Promise.all(
|
||||
Array.from({ length: firstPage.pageInfo.totalPages - 1 }, (_, index) =>
|
||||
listSharedNonIntegratedProductPage({
|
||||
page: index + 2,
|
||||
pageSize: BARCODE_PRINT_PRODUCT_REFRESH_PAGE_SIZE,
|
||||
productKind: "all",
|
||||
quickScope: "all",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
...firstPage.products,
|
||||
...restPages.flatMap((page) => page.products),
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import type { ImportProgressState } from "./barcodePrintTypes";
|
||||
|
||||
export const EMPTY_IMPORT_PROGRESS: ImportProgressState = {
|
||||
active: false,
|
||||
label: "",
|
||||
percent: 0,
|
||||
status: "idle",
|
||||
message: "",
|
||||
};
|
||||
|
||||
export function waitForProgressFrame() {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
|
||||
export type BarcodePrintRow = {
|
||||
id: string;
|
||||
importRecordIds?: string[];
|
||||
spu: string;
|
||||
model: string;
|
||||
specCode: string;
|
||||
setCount: string;
|
||||
skuCode: string;
|
||||
brandSkuCode: string;
|
||||
productName: string;
|
||||
materialNo: string;
|
||||
specification: string;
|
||||
certificateMaterial: string;
|
||||
barcode: string;
|
||||
factoryModel: string;
|
||||
attributeCode: string;
|
||||
drawingId: string;
|
||||
barcodeSpecName: string;
|
||||
productCategory: string;
|
||||
salesCategory: string;
|
||||
materialType: string;
|
||||
size: string;
|
||||
color: string;
|
||||
material: string;
|
||||
executeStandard: string;
|
||||
tagMaterial: string;
|
||||
supplierName: string;
|
||||
warehouse: string;
|
||||
installationService: string;
|
||||
lengthCm: string;
|
||||
widthCm: string;
|
||||
heightCm: string;
|
||||
volumeCm3: string;
|
||||
weightKg: string;
|
||||
costPrice: string;
|
||||
productPlanning: string;
|
||||
visualPlanning: string;
|
||||
spuOwner: string;
|
||||
operationsOwner: string;
|
||||
purchaseOwner: string;
|
||||
productionType: string;
|
||||
shippingMode: string;
|
||||
supplierCategory: string;
|
||||
purchaseOrderMethod: string;
|
||||
assemblyDetail: string;
|
||||
createdAt: string;
|
||||
dataSource: string;
|
||||
};
|
||||
|
||||
function firstText(...values: Array<string | undefined | null>) {
|
||||
return values.find((value) => value?.trim())?.trim() ?? "";
|
||||
}
|
||||
|
||||
function calculateVolumeCm3(lengthCm: string, widthCm: string, heightCm: string) {
|
||||
const length = Number.parseFloat(lengthCm);
|
||||
const width = Number.parseFloat(widthCm);
|
||||
const height = Number.parseFloat(heightCm);
|
||||
|
||||
if (!Number.isFinite(length) || !Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const volume = length * width * height;
|
||||
if (!Number.isFinite(volume)) return "";
|
||||
|
||||
return Number.isInteger(volume) ? String(volume) : volume.toFixed(2);
|
||||
}
|
||||
|
||||
function normalizeCreatedAt(value: string) {
|
||||
if (!value.trim()) return "";
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function isTwoPieceSet(value: string) {
|
||||
return Number.parseInt(value.trim(), 10) === 2;
|
||||
}
|
||||
|
||||
function stripPackageSuffix(skuCode: string, packageSuffix: string) {
|
||||
const normalizedSkuCode = skuCode.trim();
|
||||
const normalizedPackageSuffix = packageSuffix.trim().toUpperCase();
|
||||
|
||||
if (
|
||||
normalizedPackageSuffix &&
|
||||
normalizedSkuCode.toUpperCase().endsWith(normalizedPackageSuffix)
|
||||
) {
|
||||
return normalizedSkuCode.slice(0, -normalizedPackageSuffix.length);
|
||||
}
|
||||
|
||||
const fallbackMatch = normalizedSkuCode.match(/^(.+\d)[A-Z]$/i);
|
||||
return fallbackMatch?.[1] ?? normalizedSkuCode;
|
||||
}
|
||||
|
||||
function buildSpecCode({
|
||||
packageSuffix,
|
||||
setCount,
|
||||
skuCode,
|
||||
}: {
|
||||
packageSuffix: string;
|
||||
setCount: string;
|
||||
skuCode: string;
|
||||
}) {
|
||||
if (!skuCode.trim() || !isTwoPieceSet(setCount)) return "";
|
||||
return stripPackageSuffix(skuCode, packageSuffix);
|
||||
}
|
||||
|
||||
function buildFactoryModel(model: string, supplierCode: string) {
|
||||
const normalizedModel = model.trim();
|
||||
const normalizedSupplierCode = supplierCode.trim().toUpperCase();
|
||||
|
||||
if (
|
||||
!normalizedSupplierCode ||
|
||||
normalizedModel.toUpperCase().includes(normalizedSupplierCode)
|
||||
) {
|
||||
return normalizedModel;
|
||||
}
|
||||
|
||||
return `${normalizedModel}${normalizedSupplierCode}`;
|
||||
}
|
||||
|
||||
export function buildBarcodePrintRows(
|
||||
products: NonIntegratedProductRecord[],
|
||||
): BarcodePrintRow[] {
|
||||
return products.flatMap((product) => {
|
||||
const enteredPricingRowBySkuId = new Map(
|
||||
product.pricingRows
|
||||
.filter((row) => row.systemEnteredAt?.trim())
|
||||
.map((row) => [row.skuId, row]),
|
||||
);
|
||||
const skuItems = product.skuItems.filter((sku) =>
|
||||
enteredPricingRowBySkuId.has(sku.id),
|
||||
);
|
||||
|
||||
return skuItems.map((sku, index) => {
|
||||
const skuId = sku.id;
|
||||
const factoryRow = product.factoryRows.find((row) => row.skuId === skuId);
|
||||
const measurementRow = product.sampleMeasurementRows.find(
|
||||
(row) => row.skuId === skuId,
|
||||
);
|
||||
const pricingRow = enteredPricingRowBySkuId.get(skuId);
|
||||
const lengthCm = measurementRow?.lengthCm?.trim() ?? "";
|
||||
const widthCm = measurementRow?.widthCm?.trim() ?? "";
|
||||
const heightCm = measurementRow?.heightCm?.trim() ?? "";
|
||||
const barcode = firstText(factoryRow?.barcode, sku?.barcode);
|
||||
const skuCode = firstText(factoryRow?.skuCode, sku?.skuCode);
|
||||
const setCount = firstText(factoryRow?.setCount, sku?.setCount);
|
||||
const model = firstText(sku?.model, factoryRow?.model, product.productCode);
|
||||
const supplierCode = firstText(
|
||||
factoryRow?.supplierCode,
|
||||
sku?.manufacturerCode,
|
||||
);
|
||||
const material = firstText(sku?.material, product.openingReference.material);
|
||||
const grabbedCategory = firstText(
|
||||
product.openingReference.productCategory,
|
||||
product.productCategory,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `${product.id}:${skuId}:${index}`,
|
||||
spu: product.spu,
|
||||
model,
|
||||
specCode: buildSpecCode({
|
||||
packageSuffix: sku?.packageSuffix ?? "",
|
||||
setCount,
|
||||
skuCode,
|
||||
}),
|
||||
setCount,
|
||||
skuCode,
|
||||
brandSkuCode: "",
|
||||
productName: "",
|
||||
materialNo: "",
|
||||
specification: "",
|
||||
certificateMaterial: "",
|
||||
barcode,
|
||||
factoryModel: buildFactoryModel(model, supplierCode),
|
||||
attributeCode: sku?.attributeCode?.trim() ?? "",
|
||||
drawingId: sku?.drawingId?.trim() ?? "",
|
||||
barcodeSpecName: firstText(factoryRow?.specName, sku?.specName),
|
||||
productCategory: grabbedCategory,
|
||||
salesCategory: grabbedCategory,
|
||||
materialType: firstText(product.materialType, sku?.boardMaterialType),
|
||||
size: firstText(sku?.size, product.size),
|
||||
color: firstText(sku?.color, product.color),
|
||||
material,
|
||||
executeStandard: firstText(factoryRow?.executeStandard, sku?.executeStandard),
|
||||
tagMaterial: material,
|
||||
supplierName: firstText(factoryRow?.supplierName, sku?.manufacturer),
|
||||
warehouse: factoryRow?.warehouse?.trim() ?? "",
|
||||
installationService: factoryRow?.installationService?.trim() ?? "",
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
volumeCm3: calculateVolumeCm3(lengthCm, widthCm, heightCm),
|
||||
weightKg: measurementRow?.weightKg?.trim() ?? "",
|
||||
costPrice: pricingRow?.costPrice?.trim() ?? "",
|
||||
productPlanning: product.productPlanning,
|
||||
visualPlanning: product.visualPlanning,
|
||||
spuOwner: product.spuOwner,
|
||||
operationsOwner: firstText(product.operationsOwner, product.operator),
|
||||
purchaseOwner: firstText(product.purchaseOwner, product.purchaser),
|
||||
productionType: firstText(factoryRow?.productionType, product.productionType),
|
||||
shippingMode: firstText(factoryRow?.shippingMethod, product.shippingMode),
|
||||
supplierCategory: factoryRow?.supplierCategory?.trim() ?? "",
|
||||
purchaseOrderMethod: factoryRow?.purchaseOrderMethod?.trim() ?? "",
|
||||
assemblyDetail: firstText(
|
||||
factoryRow?.installMethod,
|
||||
sku?.installMethod,
|
||||
product.installMethod,
|
||||
),
|
||||
createdAt: normalizeCreatedAt(pricingRow?.systemEnteredAt ?? ""),
|
||||
dataSource: firstText(sku?.sourceTable, sku?.source, "流程"),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import type {
|
||||
BarcodePrintColumn,
|
||||
BarcodePrintColumnKey,
|
||||
BarcodePrintColumnWidths,
|
||||
} from "./barcodePrintTypes";
|
||||
|
||||
export const BARCODE_PRINT_PAGE_SIZE = 20;
|
||||
export const SELECTION_COLUMN_WIDTH = 34;
|
||||
export const ROW_NUMBER_COLUMN_WIDTH = 46;
|
||||
export const ROW_NUMBER_COLUMN_MIN_WIDTH = 46;
|
||||
export const OPERATION_COLUMN_WIDTH = 132;
|
||||
export const BARCODE_PRINT_COLUMN_MIN_WIDTH = 56;
|
||||
|
||||
export const BARCODE_PRINT_COLUMNS: BarcodePrintColumn[] = [
|
||||
{ key: "spu", label: "SPU", width: 92 },
|
||||
{ key: "model", label: "型号", width: 104 },
|
||||
{ key: "specCode", label: "规格码", width: 112 },
|
||||
{ key: "setCount", label: "套件量", width: 68 },
|
||||
{ key: "skuCode", label: "货号", width: 112 },
|
||||
{ key: "brandSkuCode", label: "品牌货号", width: 112 },
|
||||
{ key: "productName", label: "品名", width: 120 },
|
||||
{ key: "materialNo", label: "料号", width: 112 },
|
||||
{ key: "specification", label: "规格(长*宽*高)", width: 136 },
|
||||
{ key: "certificateMaterial", label: "合格证的主材*基材", width: 150 },
|
||||
{ key: "barcode", label: "条码", width: 132 },
|
||||
{ key: "factoryModel", label: "工厂型号", width: 120 },
|
||||
{ key: "attributeCode", label: "属性代码", width: 84 },
|
||||
{ key: "drawingId", label: "图纸ID", width: 124 },
|
||||
{ key: "barcodeSpecName", label: "条码规格名称", width: 170 },
|
||||
{ key: "productCategory", label: "商品类目", width: 96 },
|
||||
{ key: "salesCategory", label: "销售类目", width: 96 },
|
||||
{ key: "materialType", label: "材质类型", width: 96 },
|
||||
{ key: "size", label: "尺寸", width: 112 },
|
||||
{ key: "color", label: "颜色", width: 104 },
|
||||
{ key: "material", label: "材质", width: 150 },
|
||||
{ key: "executeStandard", label: "执行标准", width: 126 },
|
||||
{ key: "tagMaterial", label: "吊牌材质", width: 104 },
|
||||
{ key: "supplierName", label: "供应商名称", width: 126 },
|
||||
{ key: "warehouse", label: "发货仓库", width: 126 },
|
||||
{ key: "installationService", label: "安装服务", width: 88 },
|
||||
{ key: "lengthCm", label: "长(CM)", width: 78 },
|
||||
{ key: "widthCm", label: "宽(CM)", width: 78 },
|
||||
{ key: "heightCm", label: "高(CM)", width: 78 },
|
||||
{ key: "volumeCm3", label: "体积(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,
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -210,6 +210,7 @@ export function buildIntakePrimaryDomainProps(params: {
|
||||
canViewBarcodePrint: params.canViewBarcodePrint,
|
||||
barcodePrint: {
|
||||
products: params.nonIntegratedProducts,
|
||||
user: params.user,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user