Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e71f786d | ||
|
|
8cc542110b | ||
|
|
66bfe8b46e | ||
|
|
9508f1390e | ||
|
|
32f7469e84 | ||
|
|
ce682c3679 | ||
|
|
aa67a66047 | ||
|
|
aed6fe2bfa | ||
|
|
99684c04f2 | ||
|
|
860bd0161b | ||
|
|
bbc9ad13fe | ||
|
|
6fdf336b9e |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
**
|
||||
!dist/
|
||||
!dist/**
|
||||
!Dockerfile.dist
|
||||
!docker/
|
||||
!docker/dist-server/
|
||||
!docker/dist-server/**
|
||||
+1
-1
@@ -11,7 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
*.exe
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY docker/dist-server/ ./
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dist-server .
|
||||
|
||||
FROM scratch
|
||||
|
||||
WORKDIR /www
|
||||
|
||||
COPY --from=builder /out/dist-server /dist-server
|
||||
COPY dist/ /www/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/dist-server"]
|
||||
@@ -1,5 +0,0 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -1,25 +1,35 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "my-vue-app",
|
||||
"dependencies": {
|
||||
"@ag-grid-community/locale": "^35.1.0",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@internationalized/number": "^3.6.5",
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"ag-grid-community": "^35.1.0",
|
||||
"ag-grid-enterprise": "^35.1.0",
|
||||
"ag-grid-vue3": "^35.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js": "^10.6.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"motion-v": "^2.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"reka-ui": "^2.8.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35,328 +45,548 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ag-grid-community/locale": ["@ag-grid-community/locale@35.1.0", "", {}, "sha512-Tez1imtqfipMT3O1Ay+dyDcFJIj6H6gXBp45s44pwkzWQzxO20IBpZUrmAPTNRMYVZNXqCVbNsozWrPaVFAgeQ=="],
|
||||
"@ag-grid-community/locale": ["@ag-grid-community/locale@35.1.0", "https://registry.npmmirror.com/@ag-grid-community/locale/-/locale-35.1.0.tgz", {}, "sha512-Tez1imtqfipMT3O1Ay+dyDcFJIj6H6gXBp45s44pwkzWQzxO20IBpZUrmAPTNRMYVZNXqCVbNsozWrPaVFAgeQ=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
"@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
"@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
"@fast-csv/format": ["@fast-csv/format@4.3.5", "https://registry.npmmirror.com/@fast-csv/format/-/format-4.3.5.tgz", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
|
||||
"@fast-csv/parse": ["@fast-csv/parse@4.3.6", "https://registry.npmmirror.com/@fast-csv/parse/-/parse-4.3.6.tgz", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.4.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
"@floating-ui/vue": ["@floating-ui/vue@1.1.10", "", { "dependencies": { "@floating-ui/dom": "^1.7.5", "@floating-ui/utils": "^0.2.10", "vue-demi": ">=0.13.0" } }, "sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg=="],
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.5.tgz", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
"@floating-ui/vue": ["@floating-ui/vue@1.1.10", "https://registry.npmmirror.com/@floating-ui/vue/-/vue-1.1.10.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.5", "@floating-ui/utils": "^0.2.10", "vue-demi": ">=0.13.0" } }, "sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
"@iconify/vue": ["@iconify/vue@5.0.0", "https://registry.npmmirror.com/@iconify/vue/-/vue-5.0.0.tgz", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3" } }, "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
"@internationalized/date": ["@internationalized/date@3.12.0", "https://registry.npmmirror.com/@internationalized/date/-/date-3.12.0.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "https://registry.npmmirror.com/@internationalized/number/-/number-3.6.5.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
"@intlify/core-base": ["@intlify/core-base@11.3.1", "https://registry.npmmirror.com/@intlify/core-base/-/core-base-11.3.1.tgz", { "dependencies": { "@intlify/devtools-types": "11.3.1", "@intlify/message-compiler": "11.3.1", "@intlify/shared": "11.3.1" } }, "sha512-9nG3ItSD5ApZHmTbv2UFqvJSy3m+u6C/orMohukNKoT/Yuwiz8tPtlNw6ylLuPqSP2kP7ZF4Cdqwp6V1m3BQgw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
"@intlify/devtools-types": ["@intlify/devtools-types@11.3.1", "https://registry.npmmirror.com/@intlify/devtools-types/-/devtools-types-11.3.1.tgz", { "dependencies": { "@intlify/core-base": "11.3.1", "@intlify/shared": "11.3.1" } }, "sha512-qrOknIx294W4YYVYBgldDOeOnv2NlpabW+aYGjMuXMSrY36f7GCAPlEBE2G+qTC5x0oAWDBSY5BmvLlPUx1exg=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.114.0", "", {}, "sha512-mVGQvr/uFJGQ3hsvgQ1sJfh79t5owyZZZtw+VaH+WhtvsmtgjT6imznB9sz2Q67Q0/4obM9mOOtQscU4aJteSg=="],
|
||||
"@intlify/message-compiler": ["@intlify/message-compiler@11.3.1", "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-11.3.1.tgz", { "dependencies": { "@intlify/shared": "11.3.1", "source-map-js": "^1.0.2" } }, "sha512-uIa4YurbphU+4Cl5CoL6nq/c7uQhVNRowEelgboNmXNs+UEcyFLQBESwaUjMvdtYxzA2qh+vGim080KZ84ruDA=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.114.0", "", {}, "sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA=="],
|
||||
"@intlify/shared": ["@intlify/shared@11.3.1", "https://registry.npmmirror.com/@intlify/shared/-/shared-11.3.1.tgz", {}, "sha512-9GWc5PKuRdeWkT7FJN43c/+rD6xpSB3WtizewkfFCK/0XzYqCk4gQBWWcTdfKo8ylEcHwqYsR2Z3HRE3XhEHrQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zCEmUrt1bggwgBgeKLxNj217J1OrChrp3jJt24VK9jAharSTeVaHODNL+LpcQVhRz+FktYWfT9cjo5oZ99ZLpg=="],
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZP9xb9lPAex36pvkNWCjSEJW/Gfdm9I3ssiqOFLmpZ/vosPXgpoGxCmh+dX1Qs+/bWQE6toNFXWWL8vYoKoK9Q=="],
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IdrPunf6dp9mywMgTOKMMGDnMHQ6+h5gRl6LW8rhD8WK2kXX0IwzcM5Zc0B5J7xQs8QWOlKjv8BJsU/1CD3pg=="],
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-o/JCk+dL0IN68EBhZ4DqfsfvxPfMeoM6cJtxORC1YYoxGHZyth2Kb2maXDb4oddw2wu8iIbnYXYPEzBtAF5CAg=="],
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.5", "", { "os": "linux", "cpu": "arm" }, "sha512-IIBwTtA6VwxQLcEgq2mfrUgam7VvPZjhd/jxmeS1npM+edWsrrpRLHUdze+sk4rhb8/xpP3flemgcZXXUW6ukw=="],
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-KSol1De1spMZL+Xg7K5IBWXIvRWv7+pveaxFWXpezezAG7CS6ojzRjtCGCiLxQricutTAi/LkNWKMsd2wNhMKQ=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-WFljyDkxtXRlWxMjxeegf7xMYXxUr8u7JdXlOEWKYgDqEgxUnSEsVDxBiNWQ1D5kQKwf8Wo4sVKEYPRhCdsjwA=="],
|
||||
"@noble/ciphers": ["@noble/ciphers@2.1.1", "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-2.1.1.tgz", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.5", "", { "os": "linux", "cpu": "x64" }, "sha512-CUlplTujmbDWp2gamvrqVKi2Or8lmngXT1WxsizJfts7JrvfGhZObciaY/+CbdbS9qNnskvwMZNEhTPrn7b+WA=="],
|
||||
"@noble/hashes": ["@noble/hashes@2.0.1", "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.0.1.tgz", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.5", "", { "os": "linux", "cpu": "x64" }, "sha512-wdf7g9NbVZCeAo2iGhsjJb7I8ZFfs6X8bumfrWg82VK+8P6AlLXwk48a1ASiJQDTS7Svq2xVzZg3sGO2aXpHRA=="],
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.114.0", "https://registry.npmmirror.com/@oxc-project/runtime/-/runtime-0.114.0.tgz", {}, "sha512-mVGQvr/uFJGQ3hsvgQ1sJfh79t5owyZZZtw+VaH+WhtvsmtgjT6imznB9sz2Q67Q0/4obM9mOOtQscU4aJteSg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.5", "", { "os": "none", "cpu": "arm64" }, "sha512-0CWY7ubu12nhzz+tkpHjoG3IRSTlWYe0wrfJRf4qqjqQSGtAYgoL9kwzdvlhaFdZ5ffVeyYw9qLsChcjUMEloQ=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.114.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.114.0.tgz", {}, "sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LztXnGzv6t2u830mnZrFLRVqT/DPJ9DL4ZTz/y93rqUVkeHjMMYIYaFj+BUthiYxbVH9dH0SZYufETspKY/NhA=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.5.tgz", { "os": "android", "cpu": "arm64" }, "sha512-zCEmUrt1bggwgBgeKLxNj217J1OrChrp3jJt24VK9jAharSTeVaHODNL+LpcQVhRz+FktYWfT9cjo5oZ99ZLpg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-jUct1XVeGtyjqJXEAfvdFa8xoigYZ2rge7nYEm70ppQxpfH9ze2fbIrpHmP2tNM2vL/F6Dd0CpXhpjPbC6bSxQ=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.5.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ZP9xb9lPAex36pvkNWCjSEJW/Gfdm9I3ssiqOFLmpZ/vosPXgpoGxCmh+dX1Qs+/bWQE6toNFXWWL8vYoKoK9Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.5", "", { "os": "win32", "cpu": "x64" }, "sha512-VQ8F9ld5gw29epjnVGdrx8ugiLTe8BMqmhDYy7nGbdeDo4HAt4bgdZvLbViEhg7DZyHLpiEUlO5/jPSUrIuxRQ=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.5.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-7IdrPunf6dp9mywMgTOKMMGDnMHQ6+h5gRl6LW8rhD8WK2kXX0IwzcM5Zc0B5J7xQs8QWOlKjv8BJsU/1CD3pg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.5.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-o/JCk+dL0IN68EBhZ4DqfsfvxPfMeoM6cJtxORC1YYoxGHZyth2Kb2maXDb4oddw2wu8iIbnYXYPEzBtAF5CAg=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.5.tgz", { "os": "linux", "cpu": "arm" }, "sha512-IIBwTtA6VwxQLcEgq2mfrUgam7VvPZjhd/jxmeS1npM+edWsrrpRLHUdze+sk4rhb8/xpP3flemgcZXXUW6ukw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.0" } }, "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.5.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-KSol1De1spMZL+Xg7K5IBWXIvRWv7+pveaxFWXpezezAG7CS6ojzRjtCGCiLxQricutTAi/LkNWKMsd2wNhMKQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.0", "@tailwindcss/oxide-darwin-arm64": "4.2.0", "@tailwindcss/oxide-darwin-x64": "4.2.0", "@tailwindcss/oxide-freebsd-x64": "4.2.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", "@tailwindcss/oxide-linux-x64-musl": "4.2.0", "@tailwindcss/oxide-wasm32-wasi": "4.2.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" } }, "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.5.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WFljyDkxtXRlWxMjxeegf7xMYXxUr8u7JdXlOEWKYgDqEgxUnSEsVDxBiNWQ1D5kQKwf8Wo4sVKEYPRhCdsjwA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.5.tgz", { "os": "linux", "cpu": "x64" }, "sha512-CUlplTujmbDWp2gamvrqVKi2Or8lmngXT1WxsizJfts7JrvfGhZObciaY/+CbdbS9qNnskvwMZNEhTPrn7b+WA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.5.tgz", { "os": "linux", "cpu": "x64" }, "sha512-wdf7g9NbVZCeAo2iGhsjJb7I8ZFfs6X8bumfrWg82VK+8P6AlLXwk48a1ASiJQDTS7Svq2xVzZg3sGO2aXpHRA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.5.tgz", { "os": "none", "cpu": "arm64" }, "sha512-0CWY7ubu12nhzz+tkpHjoG3IRSTlWYe0wrfJRf4qqjqQSGtAYgoL9kwzdvlhaFdZ5ffVeyYw9qLsChcjUMEloQ=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.5.tgz", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LztXnGzv6t2u830mnZrFLRVqT/DPJ9DL4ZTz/y93rqUVkeHjMMYIYaFj+BUthiYxbVH9dH0SZYufETspKY/NhA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.5.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-jUct1XVeGtyjqJXEAfvdFa8xoigYZ2rge7nYEm70ppQxpfH9ze2fbIrpHmP2tNM2vL/F6Dd0CpXhpjPbC6bSxQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.5.tgz", { "os": "win32", "cpu": "x64" }, "sha512-VQ8F9ld5gw29epjnVGdrx8ugiLTe8BMqmhDYy7nGbdeDo4HAt4bgdZvLbViEhg7DZyHLpiEUlO5/jPSUrIuxRQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A=="],
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA=="],
|
||||
"@swc/helpers": ["@swc/helpers@0.5.18", "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.18.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.0", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.0" } }, "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.0", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.0.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.0", "@tailwindcss/oxide-darwin-arm64": "4.2.0", "@tailwindcss/oxide-darwin-x64": "4.2.0", "@tailwindcss/oxide-freebsd-x64": "4.2.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", "@tailwindcss/oxide-linux-x64-musl": "4.2.0", "@tailwindcss/oxide-wasm32-wasi": "4.2.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" } }, "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.0", "", { "dependencies": { "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "tailwindcss": "4.2.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.0", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.0", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.0.tgz", { "dependencies": { "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "tailwindcss": "4.2.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.28", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ=="],
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.28", "", { "dependencies": { "@vue/compiler-core": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA=="],
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.18", "https://registry.npmmirror.com/@tanstack/vue-virtual/-/vue-virtual-3.13.18.tgz", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.28", "@vue/compiler-dom": "3.5.28", "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g=="],
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g=="],
|
||||
"@types/bun": ["@types/bun@1.3.9", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
"@types/node": ["@types/node@24.10.13", "https://registry.npmmirror.com/@types/node/-/node-24.10.13.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@3.2.5", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g=="],
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="],
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="],
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/runtime-core": "3.5.28", "@vue/shared": "3.5.28", "csstype": "^3.2.3" } }, "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA=="],
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.28", "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.28.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.28", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.28", "", { "dependencies": { "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "vue": "3.5.28" } }, "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg=="],
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.28", "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", { "dependencies": { "@vue/compiler-core": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="],
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.28", "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.28", "@vue/compiler-dom": "3.5.28", "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g=="],
|
||||
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.8.1", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g=="],
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.28", "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@14.2.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", "@vueuse/shared": "14.2.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ=="],
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@14.2.1", "", {}, "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw=="],
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"ag-charts-community": ["ag-charts-community@13.1.0", "", { "dependencies": { "ag-charts-core": "13.1.0", "ag-charts-locale": "13.1.0", "ag-charts-types": "13.1.0" } }, "sha512-w+uFTjxlAoTq1+8tgUORtB/zr9jm38ibXzbbWnkBP9Dep9yahi5a1jZL7yExAX35uq3g9QtjTh0Oj/QPDBQ9Ew=="],
|
||||
"@vue/language-core": ["@vue/language-core@3.2.5", "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.5.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g=="],
|
||||
|
||||
"ag-charts-core": ["ag-charts-core@13.1.0", "", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-mLHJZ8oU5CPeLRURescdISCtMsiiA/m4d1iBr6aQBEgiTVogRMGpFpsYNtQiYtoW2sRh+62I9sN8fhC3JQjX/g=="],
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.28", "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.28.tgz", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="],
|
||||
|
||||
"ag-charts-enterprise": ["ag-charts-enterprise@13.1.0", "", { "dependencies": { "ag-charts-community": "13.1.0", "ag-charts-core": "13.1.0" } }, "sha512-WyKIqvkOdtdvEJxq76hjTacXTCpIR2lq1JDMYc5MtoHYtiVt1KHApsxS0nbutp/CxGKRgdOqJtxUF+3r33pgPw=="],
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.28", "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.28.tgz", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="],
|
||||
|
||||
"ag-charts-locale": ["ag-charts-locale@13.1.0", "", {}, "sha512-mPgJnVsOI4Cf17CAlRh8BvLz19e165sdQJeUXNaB7M+DPB+pxODOcfx4oqZlR4Wc8Zu++TGb/2ueHa/aeV2qeQ=="],
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.28", "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/runtime-core": "3.5.28", "@vue/shared": "3.5.28", "csstype": "^3.2.3" } }, "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA=="],
|
||||
|
||||
"ag-charts-types": ["ag-charts-types@13.1.0", "", {}, "sha512-DytRM3CXli+Y013SC1Mr8lQBrhVTACK+11ilDHOhwUM0sRpmGuR51XFGcBKOliW1Vas1AycP31Cm3Pp0jx3hqw=="],
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.28", "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.28.tgz", { "dependencies": { "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "vue": "3.5.28" } }, "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg=="],
|
||||
|
||||
"ag-grid-community": ["ag-grid-community@35.1.0", "", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-yWFQfRNjv3KUBkHHzFdDOYGjPcDMU0B8Up4qG651diFlGRUGEGVs94SK73niWvk1FDZdpV9oWrwq3f30/qAoVg=="],
|
||||
"@vue/shared": ["@vue/shared@3.5.28", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.28.tgz", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="],
|
||||
|
||||
"ag-grid-enterprise": ["ag-grid-enterprise@35.1.0", "", { "dependencies": { "ag-grid-community": "35.1.0" }, "optionalDependencies": { "ag-charts-community": "13.1.0", "ag-charts-enterprise": "13.1.0" } }, "sha512-Zhod3fpgWa9KE0JNFkkkb8/3Qv66UR9KF3wFyCz++wQUtQm5wdExul4UA8wm1ukvBmD6QyBLQ5Cs9zDnIEb0uQ=="],
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.8.1", "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.8.1.tgz", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g=="],
|
||||
|
||||
"ag-grid-vue3": ["ag-grid-vue3@35.1.0", "", { "dependencies": { "ag-grid-community": "35.1.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-BvM7yrFxRB/r5hZ4xSyE6T2lU2Rj+Ls6RH5tTu/n8DmhCTmLj4QCEkoU7EuaE0/Az3uEHOubYMaCX4jcDf181A=="],
|
||||
"@vueuse/core": ["@vueuse/core@14.2.1", "https://registry.npmmirror.com/@vueuse/core/-/core-14.2.1.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", "@vueuse/shared": "14.2.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="],
|
||||
"@vueuse/metadata": ["@vueuse/metadata@14.2.1", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.2.1.tgz", {}, "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
"@vueuse/shared": ["@vueuse/shared@14.2.1", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.2.1.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
"ag-charts-community": ["ag-charts-community@13.1.0", "https://registry.npmmirror.com/ag-charts-community/-/ag-charts-community-13.1.0.tgz", { "dependencies": { "ag-charts-core": "13.1.0", "ag-charts-locale": "13.1.0", "ag-charts-types": "13.1.0" } }, "sha512-w+uFTjxlAoTq1+8tgUORtB/zr9jm38ibXzbbWnkBP9Dep9yahi5a1jZL7yExAX35uq3g9QtjTh0Oj/QPDBQ9Ew=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
"ag-charts-core": ["ag-charts-core@13.1.0", "https://registry.npmmirror.com/ag-charts-core/-/ag-charts-core-13.1.0.tgz", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-mLHJZ8oU5CPeLRURescdISCtMsiiA/m4d1iBr6aQBEgiTVogRMGpFpsYNtQiYtoW2sRh+62I9sN8fhC3JQjX/g=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
"ag-charts-enterprise": ["ag-charts-enterprise@13.1.0", "https://registry.npmmirror.com/ag-charts-enterprise/-/ag-charts-enterprise-13.1.0.tgz", { "dependencies": { "ag-charts-community": "13.1.0", "ag-charts-core": "13.1.0" } }, "sha512-WyKIqvkOdtdvEJxq76hjTacXTCpIR2lq1JDMYc5MtoHYtiVt1KHApsxS0nbutp/CxGKRgdOqJtxUF+3r33pgPw=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
"ag-charts-locale": ["ag-charts-locale@13.1.0", "https://registry.npmmirror.com/ag-charts-locale/-/ag-charts-locale-13.1.0.tgz", {}, "sha512-mPgJnVsOI4Cf17CAlRh8BvLz19e165sdQJeUXNaB7M+DPB+pxODOcfx4oqZlR4Wc8Zu++TGb/2ueHa/aeV2qeQ=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
"ag-charts-types": ["ag-charts-types@13.1.0", "https://registry.npmmirror.com/ag-charts-types/-/ag-charts-types-13.1.0.tgz", {}, "sha512-DytRM3CXli+Y013SC1Mr8lQBrhVTACK+11ilDHOhwUM0sRpmGuR51XFGcBKOliW1Vas1AycP31Cm3Pp0jx3hqw=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
"ag-grid-community": ["ag-grid-community@35.1.0", "https://registry.npmmirror.com/ag-grid-community/-/ag-grid-community-35.1.0.tgz", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-yWFQfRNjv3KUBkHHzFdDOYGjPcDMU0B8Up4qG651diFlGRUGEGVs94SK73niWvk1FDZdpV9oWrwq3f30/qAoVg=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
"ag-grid-enterprise": ["ag-grid-enterprise@35.1.0", "https://registry.npmmirror.com/ag-grid-enterprise/-/ag-grid-enterprise-35.1.0.tgz", { "dependencies": { "ag-grid-community": "35.1.0" }, "optionalDependencies": { "ag-charts-community": "13.1.0", "ag-charts-enterprise": "13.1.0" } }, "sha512-Zhod3fpgWa9KE0JNFkkkb8/3Qv66UR9KF3wFyCz++wQUtQm5wdExul4UA8wm1ukvBmD6QyBLQ5Cs9zDnIEb0uQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
"ag-grid-vue3": ["ag-grid-vue3@35.1.0", "https://registry.npmmirror.com/ag-grid-vue3/-/ag-grid-vue3-35.1.0.tgz", { "dependencies": { "ag-grid-community": "35.1.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-BvM7yrFxRB/r5hZ4xSyE6T2lU2Rj+Ls6RH5tTu/n8DmhCTmLj4QCEkoU7EuaE0/Az3uEHOubYMaCX4jcDf181A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
"alien-signals": ["alien-signals@3.1.2", "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
"archiver": ["archiver@5.3.2", "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
"archiver-utils": ["archiver-utils@2.1.0", "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"async": ["async@3.2.6", "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
"balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
"base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
"big-integer": ["big-integer@1.6.52", "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
"binary": ["binary@0.3.0", "https://registry.npmmirror.com/binary/-/binary-0.3.0.tgz", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
"birpc": ["birpc@2.9.0", "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
||||
"bl": ["bl@4.1.0", "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
"bluebird": ["bluebird@3.4.7", "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
|
||||
"buffer": ["buffer@5.7.1", "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
|
||||
"buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "https://registry.npmmirror.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
|
||||
"buffers": ["buffers@0.1.1", "https://registry.npmmirror.com/buffers/-/buffers-0.1.1.tgz", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
|
||||
"bun-types": ["bun-types@1.3.9", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
|
||||
"chainsaw": ["chainsaw@0.1.0", "https://registry.npmmirror.com/chainsaw/-/chainsaw-0.1.0.tgz", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
|
||||
"compress-commons": ["compress-commons@4.1.2", "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||
"concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
|
||||
"copy-anything": ["copy-anything@4.0.5", "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"lucide-vue-next": ["lucide-vue-next@0.563.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-zsE/lCKtmaa7bGfhSpN84br1K9YoQ5pCN+2oKWjQQG3Lo6ufUUKBuHSjNFI6RvUevxaajNXb8XwFUKeTXG3sIA=="],
|
||||
"core-util-is": ["core-util-is@1.0.3", "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
"crc-32": ["crc-32@1.2.2", "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
"crc32-stream": ["crc32-stream@4.0.3", "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
"dayjs": ["dayjs@1.11.19", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
"decimal.js": ["decimal.js@10.6.0", "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
"defu": ["defu@6.1.4", "https://registry.npmmirror.com/defu/-/defu-6.1.4.tgz", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
"duplexer2": ["duplexer2@0.1.4", "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"pinia-plugin-persistedstate": ["pinia-plugin-persistedstate@4.7.1", "", { "dependencies": { "defu": "^6.1.4" }, "peerDependencies": { "@nuxt/kit": ">=3.0.0", "@pinia/nuxt": ">=0.10.0", "pinia": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "@pinia/nuxt", "pinia"] }, "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ=="],
|
||||
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"reka-ui": ["reka-ui@2.8.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA=="],
|
||||
"exceljs": ["exceljs@4.4.0", "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
"fast-csv": ["fast-csv@4.3.6", "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.5", "", { "dependencies": { "@oxc-project/types": "=0.114.0", "@rolldown/pluginutils": "1.0.0-rc.5" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.5", "@rolldown/binding-darwin-arm64": "1.0.0-rc.5", "@rolldown/binding-darwin-x64": "1.0.0-rc.5", "@rolldown/binding-freebsd-x64": "1.0.0-rc.5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.5", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.5", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.5", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.5", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.5", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.5", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-0AdalTs6hNTioaCYIkAa7+xsmHBfU5hCNclZnM/lp7lGGDuUOb6N4BVNtwiomybbencDjq/waKjTImqiGCs5sw=="],
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"sortablejs": ["sortablejs@1.14.0", "", {}, "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="],
|
||||
"framer-motion": ["framer-motion@12.34.3", "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.34.3.tgz", { "dependencies": { "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
"fs-constants": ["fs-constants@1.0.0", "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
"fstream": ["fstream@1.0.12", "https://registry.npmmirror.com/fstream/-/fstream-1.0.12.tgz", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="],
|
||||
"glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
"hey-listen": ["hey-listen@1.0.8", "https://registry.npmmirror.com/hey-listen/-/hey-listen-1.0.8.tgz", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
"hookable": ["hookable@5.5.3", "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
"ieee754": ["ieee754@1.2.1", "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
"immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
"inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"vite": ["vite@8.0.0-beta.15", "", { "dependencies": { "@oxc-project/runtime": "0.114.0", "lightningcss": "^1.31.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-rc.5", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-RHX7IvsJlEfjyA1rS7MY0UsmF91etdLAamslHR5lfuO3W/BXRdXm2tRE64ztpSPZbKqB4wAAZ0AwtF6QzfKZLA=="],
|
||||
"inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
"is-what": ["is-what@5.5.0", "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
|
||||
"vue": ["vue@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="],
|
||||
"isarray": ["isarray@1.0.0", "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.2.5", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA=="],
|
||||
"jszip": ["jszip@3.10.1", "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"vuedraggable": ["vuedraggable@4.1.0", "", { "dependencies": { "sortablejs": "1.14.0" }, "peerDependencies": { "vue": "^3.0.1" } }, "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww=="],
|
||||
"lazystream": ["lazystream@1.0.1", "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
"lie": ["lie@3.1.1", "https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
"lightningcss": ["lightningcss@1.31.1", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.31.1.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||
|
||||
"listenercount": ["listenercount@1.0.1", "https://registry.npmmirror.com/listenercount/-/listenercount-1.0.1.tgz", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="],
|
||||
|
||||
"localforage": ["localforage@1.10.0", "https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.difference": ["lodash.difference@4.5.0", "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.flatten": ["lodash.flatten@4.4.0", "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="],
|
||||
|
||||
"lodash.groupby": ["lodash.groupby@4.6.0", "https://registry.npmmirror.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isequal": ["lodash.isequal@4.5.0", "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
||||
|
||||
"lodash.isfunction": ["lodash.isfunction@3.0.9", "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="],
|
||||
|
||||
"lodash.isnil": ["lodash.isnil@4.0.0", "https://registry.npmmirror.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="],
|
||||
|
||||
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||
|
||||
"lodash.isundefined": ["lodash.isundefined@3.0.1", "https://registry.npmmirror.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="],
|
||||
|
||||
"lodash.union": ["lodash.union@4.6.0", "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="],
|
||||
|
||||
"lodash.uniq": ["lodash.uniq@4.5.0", "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
|
||||
|
||||
"lucide-vue-next": ["lucide-vue-next@0.563.0", "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.563.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-zsE/lCKtmaa7bGfhSpN84br1K9YoQ5pCN+2oKWjQQG3Lo6ufUUKBuHSjNFI6RvUevxaajNXb8XwFUKeTXG3sIA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@5.1.9", "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"mkdirp": ["mkdirp@3.0.1", "https://registry.npmmirror.com/mkdirp/-/mkdirp-3.0.1.tgz", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.34.3", "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.34.3.tgz", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.29.2", "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.29.2.tgz", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
|
||||
"motion-v": ["motion-v@2.0.0", "https://registry.npmmirror.com/motion-v/-/motion-v-2.0.0.tgz", { "dependencies": { "framer-motion": "^12.29.2", "hey-listen": "^1.0.8", "motion-dom": "^12.29.2", "motion-utils": "^12.29.2" }, "peerDependencies": { "@vueuse/core": ">=10.0.0", "vue": ">=3.0.0" } }, "sha512-oQuQMrPhti+Zps6OosOaW3b/eqzaGAuwI54XHJKq/dIWtQWcNzfyhTo4VB5xmp7yLN+3BE9FKF6skLsynfgbHQ=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pinia": ["pinia@3.0.4", "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
|
||||
|
||||
"pinia-plugin-persistedstate": ["pinia-plugin-persistedstate@4.7.1", "https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.7.1.tgz", { "dependencies": { "defu": "^6.1.4" }, "peerDependencies": { "@nuxt/kit": ">=3.0.0", "@pinia/nuxt": ">=0.10.0", "pinia": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "@pinia/nuxt", "pinia"] }, "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdir-glob": ["readdir-glob@1.1.3", "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
|
||||
|
||||
"reka-ui": ["reka-ui@2.8.2", "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.8.2.tgz", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rimraf": ["rimraf@2.7.1", "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.5", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.5.tgz", { "dependencies": { "@oxc-project/types": "=0.114.0", "@rolldown/pluginutils": "1.0.0-rc.5" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.5", "@rolldown/binding-darwin-arm64": "1.0.0-rc.5", "@rolldown/binding-darwin-x64": "1.0.0-rc.5", "@rolldown/binding-freebsd-x64": "1.0.0-rc.5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.5", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.5", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.5", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.5", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.5", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.5", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-0AdalTs6hNTioaCYIkAa7+xsmHBfU5hCNclZnM/lp7lGGDuUOb6N4BVNtwiomybbencDjq/waKjTImqiGCs5sw=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"saxes": ["saxes@5.0.1", "https://registry.npmmirror.com/saxes/-/saxes-5.0.1.tgz", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"sortablejs": ["sortablejs@1.14.0", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz", {}, "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.0", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.0.tgz", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tar-stream": ["tar-stream@2.2.0", "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tmp": ["tmp@0.2.5", "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||
|
||||
"traverse": ["traverse@0.3.9", "https://registry.npmmirror.com/traverse/-/traverse-0.3.9.tgz", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unzipper": ["unzipper@0.10.14", "https://registry.npmmirror.com/unzipper/-/unzipper-0.10.14.tgz", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"vite": ["vite@8.0.0-beta.15", "https://registry.npmmirror.com/vite/-/vite-8.0.0-beta.15.tgz", { "dependencies": { "@oxc-project/runtime": "0.114.0", "lightningcss": "^1.31.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-rc.5", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-RHX7IvsJlEfjyA1rS7MY0UsmF91etdLAamslHR5lfuO3W/BXRdXm2tRE64ztpSPZbKqB4wAAZ0AwtF6QzfKZLA=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.28", "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-i18n": ["vue-i18n@11.3.1", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.3.1.tgz", { "dependencies": { "@intlify/core-base": "11.3.1", "@intlify/devtools-types": "11.3.1", "@intlify/shared": "11.3.1", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw=="],
|
||||
|
||||
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.2.5", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.5.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA=="],
|
||||
|
||||
"vuedraggable": ["vuedraggable@4.1.0", "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz", { "dependencies": { "sortablejs": "1.14.0" }, "peerDependencies": { "vue": "^3.0.1" } }, "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"zip-stream": ["zip-stream@4.1.1", "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="],
|
||||
|
||||
"@fast-csv/format/@types/node": ["@types/node@14.18.63", "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
|
||||
|
||||
"@fast-csv/parse/@types/node": ["@types/node@14.18.63", "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"archiver-utils/readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"duplexer2/readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"jszip/lie": ["lie@3.3.0", "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"jszip/readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"reka-ui/@internationalized/date": ["@internationalized/date@3.11.0", "https://registry.npmmirror.com/@internationalized/date/-/date-3.11.0.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.5.tgz", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"unzipper/readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"vue-router/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"zip-stream/archiver-utils": ["archiver-utils@3.0.4", "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="],
|
||||
|
||||
"archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
const DEFAULT_FILE_NAME = 'data'
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream'
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const normalizeSuffix = (suffix) => {
|
||||
const value = String(suffix || '').trim()
|
||||
if (!value) throw new Error('INVALID_SUFFIX')
|
||||
return value.startsWith('.') ? value : `.${value}`
|
||||
}
|
||||
|
||||
const sanitizeFileNamePart = (value) => {
|
||||
const cleaned = String(value || '')
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return cleaned || DEFAULT_FILE_NAME
|
||||
}
|
||||
|
||||
const formatTimestamp = (date = new Date()) => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
const second = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${year}${month}${day}-${hour}${minute}${second}`
|
||||
}
|
||||
|
||||
const encodeData = (data) => {
|
||||
const json = JSON.stringify(data)
|
||||
return encoder.encode(json)
|
||||
}
|
||||
|
||||
const decodeData = (bytes) => {
|
||||
const text = decoder.decode(bytes)
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
const downloadBlob = (blob, fileName) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const pickFile = (accept) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = accept
|
||||
input.style.display = 'none'
|
||||
|
||||
const cleanup = () => {
|
||||
input.removeEventListener('change', handleChange)
|
||||
input.remove()
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
const file = input.files?.[0] || null
|
||||
cleanup()
|
||||
|
||||
if (!file) {
|
||||
reject(new Error('FILE_NOT_SELECTED'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(file)
|
||||
}
|
||||
|
||||
input.addEventListener('change', handleChange, { once: true })
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
export const exportData = async (data, suffix, options = {}) => {
|
||||
const normalizedSuffix = normalizeSuffix(suffix)
|
||||
const bytes = encodeData(data)
|
||||
const blob = new Blob([bytes], {
|
||||
type: options.mimeType || DEFAULT_MIME_TYPE
|
||||
})
|
||||
const baseName = sanitizeFileNamePart(options.fileName)
|
||||
const fileName = `${baseName}-${formatTimestamp()}${normalizedSuffix}`
|
||||
const shouldDownload = options.download !== false
|
||||
|
||||
if (shouldDownload) {
|
||||
downloadBlob(blob, fileName)
|
||||
}
|
||||
|
||||
return {
|
||||
blob,
|
||||
fileName,
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
export const importData = async (suffix) => {
|
||||
const normalizedSuffix = normalizeSuffix(suffix)
|
||||
const file = await pickFile(normalizedSuffix)
|
||||
const fileName = String(file.name || '')
|
||||
|
||||
if (!fileName.toLowerCase().endsWith(normalizedSuffix.toLowerCase())) {
|
||||
throw new Error('INVALID_FILE_SUFFIX')
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
return decodeData(bytes)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module dist-server
|
||||
|
||||
go 1.24
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = "80"
|
||||
webRoot = "/www"
|
||||
indexName = "index.html"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := strings.TrimSpace(os.Getenv("PORT"))
|
||||
if port == "" {
|
||||
port = defaultPort
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: http.HandlerFunc(serve),
|
||||
}
|
||||
|
||||
log.Printf("dist server listening on :%s", port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func serve(w http.ResponseWriter, r *http.Request) {
|
||||
requestPath := strings.TrimPrefix(filepath.Clean("/"+r.URL.Path), "/")
|
||||
if requestPath == "." || requestPath == "" {
|
||||
serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(webRoot, filepath.FromSlash(requestPath))
|
||||
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||
applyAssetCacheHeaders(w, requestPath)
|
||||
http.ServeFile(w, r, fullPath)
|
||||
return
|
||||
}
|
||||
|
||||
serveIndex(w, r)
|
||||
}
|
||||
|
||||
func serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
http.ServeFile(w, r, filepath.Join(webRoot, indexName))
|
||||
}
|
||||
|
||||
func applyAssetCacheHeaders(w http.ResponseWriter, requestPath string) {
|
||||
if strings.EqualFold(requestPath, indexName) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(requestPath, "static/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -4,10 +4,43 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>造价计算工具</title>
|
||||
<title>交通运输工程造价咨询服务预算编制规范</title>
|
||||
|
||||
<!-- 👇 企微 / 微信分享专用 meta 👇 -->
|
||||
<meta name="description" content="交通运输工程造价咨询服务预算编制规范工具,依据 T/GDHS 017-2026 标准,提供专业预算编制、计算、导出功能。" />
|
||||
|
||||
<!-- 微信开放平台标签(解决不显示封面/标题问题) -->
|
||||
<meta property="og:title" content="交通运输工程造价咨询服务预算编制工具" />
|
||||
<meta property="og:description" content="依据 T/GDHS 017-2026 标准,专业工程造价预算编制工具。" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://jtzjfw.lianzhong.com.cn/logo.jpg" /> <!-- 必须替换成你自己的在线图片 -->
|
||||
|
||||
<!-- 企微专用优化 -->
|
||||
<meta name="wx:cover" content="https://jtzjfw.lianzhong.com.cn/logo.jpg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- <script>
|
||||
//上线前添加访问版本号,强制刷新缓存
|
||||
;(() => {
|
||||
const makeVisitVersion = () => {
|
||||
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
|
||||
const bytes = new Uint32Array(2)
|
||||
window.crypto.getRandomValues(bytes)
|
||||
return `${Date.now().toString(36)}-${bytes[0].toString(36)}${bytes[1].toString(36)}`
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('v', makeVisitVersion())
|
||||
const nextUrl = `${url.pathname}${url.search}${url.hash}`
|
||||
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
if (nextUrl !== currentUrl) {
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
}
|
||||
})()
|
||||
</script> -->
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1003
-656
File diff suppressed because it is too large.
Load diff
+14
-4
@@ -1,31 +1,41 @@
|
||||
{
|
||||
"name": "my-vue-app",
|
||||
"name": "ZWJJ2026",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
"build": "bunx vue-tsc -b && bunx --bun vite build",
|
||||
"preview": "bunx --bun vite preview",
|
||||
"type-check": "bunx vue-tsc --noEmit"
|
||||
"type-check": "bunx vue-tsc --noEmit",
|
||||
"dockerPush": "bun run build && docker build -f Dockerfile.dist -t wintsa/zwzjjstool2026:latest . && docker push wintsa/zwzjjstool2026:latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-grid-community/locale": "^35.1.0",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@internationalized/number": "^3.6.5",
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"ag-grid-community": "^35.1.0",
|
||||
"ag-grid-enterprise": "^35.1.0",
|
||||
"ag-grid-vue3": "^35.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js": "^10.6.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"motion-v": "^2.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"reka-ui": "^2.8.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.4",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
Binary file not shown.
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,45 @@
|
||||
param(
|
||||
[string]$ImageName = "jgjs2026-dist",
|
||||
[string]$Tag = "latest"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$distPath = Join-Path $projectRoot "dist"
|
||||
$dockerfilePath = Join-Path $projectRoot "Dockerfile.dist"
|
||||
$serverSourcePath = Join-Path $projectRoot "docker\\dist-server"
|
||||
$buildContext = Join-Path ([System.IO.Path]::GetTempPath()) ("jgjs2026-dist-docker-" + [System.Guid]::NewGuid().ToString("N"))
|
||||
|
||||
if (-not (Test-Path $distPath)) {
|
||||
throw "dist directory not found. Run npm run build first."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $dockerfilePath)) {
|
||||
throw "Dockerfile.dist not found."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $serverSourcePath)) {
|
||||
throw "docker/dist-server not found."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $buildContext | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $buildContext "docker") | Out-Null
|
||||
|
||||
try {
|
||||
Copy-Item $dockerfilePath (Join-Path $buildContext "Dockerfile.dist")
|
||||
Copy-Item $serverSourcePath (Join-Path $buildContext "docker\\dist-server") -Recurse
|
||||
Copy-Item $distPath (Join-Path $buildContext "dist") -Recurse
|
||||
|
||||
Write-Host "Building Docker image ${ImageName}:${Tag} from minimal dist context..."
|
||||
docker build -f (Join-Path $buildContext "Dockerfile.dist") -t "${ImageName}:${Tag}" $buildContext
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $buildContext) {
|
||||
Remove-Item $buildContext -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Done."
|
||||
Write-Host "Run with:"
|
||||
Write-Host "docker run --rm -p 8080:80 ${ImageName}:${Tag}"
|
||||
+1
-9
@@ -1,11 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import Tab from '@/layout/tab.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tab></tab>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
>
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 scrollArea-full"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
@@ -31,3 +31,9 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.scrollArea-full > *) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TooltipArrow, TooltipContent as RekaTooltipContent, TooltipPortal } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
sideOffset: 6
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<RekaTooltipContent
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'z-[90] rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<TooltipArrow class="fill-popover" />
|
||||
</RekaTooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as TooltipContent } from './TooltipContent.vue'
|
||||
export { TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
|
||||
<TypeLine
|
||||
scene="ht-tab"
|
||||
:title="`合同段:${contractName}`"
|
||||
:storage-key="`project-active-cat-${contractId}`"
|
||||
default-category="info"
|
||||
:categories="xmCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { markRaw, defineAsyncComponent, defineComponent, h, type Component } from 'vue';
|
||||
import TypeLine from '@/layout/typeLine.vue';
|
||||
|
||||
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
||||
const props = defineProps<{
|
||||
contractId: string; // 合同ID(必传)
|
||||
contractName: string; // 合同名称(必传)
|
||||
}>();
|
||||
|
||||
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
|
||||
interface XmCategoryItem {
|
||||
key: string;
|
||||
label: string;
|
||||
component: Component; // 标记为 Vue 组件类型
|
||||
}
|
||||
|
||||
// 3. 优化异步组件导入(添加加载失败兜底 + 类型标注)
|
||||
const htView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtInfoWithProps',
|
||||
setup() {
|
||||
const AsyncHtInfo = defineAsyncComponent({
|
||||
loader: () => import('@/components/views/htInfo.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 htInfo 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtInfo, { contractId: props.contractId });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const zxfwView = markRaw(
|
||||
defineComponent({
|
||||
name: 'ZxFwWithProps',
|
||||
setup() {
|
||||
const AsyncZxFw = defineAsyncComponent({
|
||||
loader: () => import('@/components/views/zxFw.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 zxFw 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncZxFw, { contractId: props.contractId });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 4. 给分类数组添加严格类型标注
|
||||
const xmCategories: XmCategoryItem[] = [
|
||||
{ key: 'info', label: '规模信息', component: htView },
|
||||
{ key: 'contract', label: '咨询服务', component: zxfwView }
|
||||
];
|
||||
</script>
|
||||
@@ -1,373 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import localforage from 'localforage'
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { Edit3, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import {
|
||||
ToastAction,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastRoot,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from 'reka-ui'
|
||||
|
||||
interface ContractItem {
|
||||
id: string
|
||||
name: string
|
||||
order: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ht-card-v1'
|
||||
const formStore = localforage.createInstance({
|
||||
name: 'jgjs-pricing-db',
|
||||
storeName: 'form_state'
|
||||
})
|
||||
|
||||
const tabStore = useTabStore()
|
||||
|
||||
|
||||
|
||||
const contracts = ref<ContractItem[]>([])
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const contractNameInput = ref('')
|
||||
const editingContractId = ref<string | null>(null)
|
||||
const toastOpen = ref(false)
|
||||
const toastTitle = ref('操作成功')
|
||||
const toastText = ref('')
|
||||
const modalOffset = ref({ x: 0, y: 0 })
|
||||
let dragStartX = 0
|
||||
let dragStartY = 0
|
||||
let baseOffsetX = 0
|
||||
let baseOffsetY = 0
|
||||
|
||||
const buildDefaultContracts = (): ContractItem[] => [
|
||||
|
||||
]
|
||||
|
||||
const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
|
||||
list.map((item, index) => ({
|
||||
...item,
|
||||
order: index,
|
||||
createdAt: item.createdAt || new Date().toISOString()
|
||||
}))
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
const notify = (text: string) => {
|
||||
toastTitle.value = '操作成功'
|
||||
toastText.value = text
|
||||
toastOpen.value = false
|
||||
requestAnimationFrame(() => {
|
||||
toastOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const saveContracts = async () => {
|
||||
try {
|
||||
contracts.value = normalizeOrder(contracts.value)
|
||||
await localforage.setItem(STORAGE_KEY, JSON.parse(JSON.stringify(contracts.value)))
|
||||
} catch (error) {
|
||||
console.error('save contracts failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const removeForageKeysByContractId = async (store: typeof localforage, contractId: string) => {
|
||||
try {
|
||||
const keys = await store.keys()
|
||||
const targetKeys = keys.filter(key => key.includes(contractId))
|
||||
await Promise.all(targetKeys.map(key => store.removeItem(key)))
|
||||
} catch (error) {
|
||||
console.error('remove forage keys by contract id failed:', contractId, error)
|
||||
}
|
||||
}
|
||||
|
||||
const removeRelatedTabsByContractId = (contractId: string) => {
|
||||
const relatedTabIds = tabStore.tabs
|
||||
.filter(tab => {
|
||||
const propsContractId = tab?.props?.contractId
|
||||
return (
|
||||
tab.id === `contract-${contractId}` ||
|
||||
tab.id.startsWith(`zxfw-edit-${contractId}-`) ||
|
||||
propsContractId === contractId
|
||||
)
|
||||
})
|
||||
.map(tab => tab.id)
|
||||
|
||||
for (const tabId of relatedTabIds) {
|
||||
tabStore.removeTab(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupContractRelatedData = async (contractId: string) => {
|
||||
await Promise.all([
|
||||
removeForageKeysByContractId(localforage, contractId),
|
||||
removeForageKeysByContractId(formStore as any, contractId)
|
||||
])
|
||||
}
|
||||
|
||||
const loadContracts = async () => {
|
||||
try {
|
||||
const saved = await localforage.getItem<ContractItem[]>(STORAGE_KEY)
|
||||
if (!saved || saved.length === 0) {
|
||||
contracts.value = buildDefaultContracts()
|
||||
await saveContracts()
|
||||
return
|
||||
}
|
||||
|
||||
const sorted = [...saved].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
contracts.value = normalizeOrder(sorted)
|
||||
} catch (error) {
|
||||
console.error('load contracts failed:', error)
|
||||
contracts.value = buildDefaultContracts()
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
editingContractId.value = null
|
||||
contractNameInput.value = ''
|
||||
modalOffset.value = { x: 0, y: 0 }
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
const openEditModal = (item: ContractItem) => {
|
||||
editingContractId.value = item.id
|
||||
contractNameInput.value = item.name
|
||||
modalOffset.value = { x: 0, y: 0 }
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
editingContractId.value = null
|
||||
contractNameInput.value = ''
|
||||
modalOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const createContract = async () => {
|
||||
const name = contractNameInput.value.trim()
|
||||
if (!name) return
|
||||
|
||||
if (editingContractId.value) {
|
||||
const current = contracts.value.find(item => item.id === editingContractId.value)
|
||||
if (!current) return
|
||||
if (current.name === name) {
|
||||
closeCreateModal()
|
||||
return
|
||||
}
|
||||
|
||||
contracts.value = contracts.value.map(item =>
|
||||
item.id === editingContractId.value ? { ...item, name } : item
|
||||
)
|
||||
await saveContracts()
|
||||
notify('编辑成功')
|
||||
closeCreateModal()
|
||||
return
|
||||
}
|
||||
|
||||
contracts.value = [
|
||||
...contracts.value,
|
||||
{
|
||||
id: `ct-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
||||
name,
|
||||
order: contracts.value.length,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
await saveContracts()
|
||||
notify('新建成功')
|
||||
closeCreateModal()
|
||||
}
|
||||
|
||||
const deleteContract = async (id: string) => {
|
||||
// 先关闭合同段及其咨询服务相关 tab,避免页面仍在运行时继续写回缓存
|
||||
removeRelatedTabsByContractId(id)
|
||||
await nextTick()
|
||||
await cleanupContractRelatedData(id)
|
||||
// 组件卸载时会异步保存一次,延迟再清一遍避免数据回写
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
await cleanupContractRelatedData(id)
|
||||
|
||||
contracts.value = contracts.value.filter(item => item.id !== id)
|
||||
await saveContracts()
|
||||
notify('删除成功')
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => {
|
||||
if (
|
||||
event.oldIndex == null ||
|
||||
event.newIndex == null ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
await saveContracts()
|
||||
notify('排序完成')
|
||||
}
|
||||
|
||||
const handleCardClick = (item: ContractItem) => {
|
||||
tabStore.openTab({
|
||||
id: `contract-${item.id}`,
|
||||
title: `合同段${item.name}`,
|
||||
componentName: 'ContractDetailView',
|
||||
props: { contractId: item.id, contractName: item.name }
|
||||
})
|
||||
}
|
||||
|
||||
const onDragMove = (event: MouseEvent) => {
|
||||
modalOffset.value = {
|
||||
x: baseOffsetX + (event.clientX - dragStartX),
|
||||
y: baseOffsetY + (event.clientY - dragStartY)
|
||||
}
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
window.removeEventListener('mousemove', onDragMove)
|
||||
window.removeEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
dragStartX = event.clientX
|
||||
dragStartY = event.clientY
|
||||
baseOffsetX = modalOffset.value.x
|
||||
baseOffsetY = modalOffset.value.y
|
||||
window.addEventListener('mousemove', onDragMove)
|
||||
window.addEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContracts()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDrag()
|
||||
void saveContracts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold">合同段列表</h3>
|
||||
<Button @click="openCreateModal">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
添加合同段
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-model="contracts"
|
||||
item-key="id"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
animation="200"
|
||||
@end="handleDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<Card
|
||||
class="group cursor-pointer transition-colors hover:border-primary"
|
||||
@click="handleCardClick(element)"
|
||||
>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">
|
||||
<span class="ml-2">{{ element.name }}</span>
|
||||
</CardTitle>
|
||||
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click.stop="openEditModal(element)"
|
||||
>
|
||||
<Edit3 class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive"
|
||||
@click.stop="deleteContract(element.id)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div class="px-6 pb-4 text-xs text-muted-foreground">
|
||||
创建时间:{{ formatDateTime(element.createdAt) }}
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md rounded-lg border bg-background shadow-xl"
|
||||
:style="{ transform: `translate(${modalOffset.x}px, ${modalOffset.y}px)` }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b px-5 py-4 cursor-move select-none"
|
||||
@mousedown.prevent="startDrag"
|
||||
>
|
||||
<h4 class="text-base font-semibold">
|
||||
{{ editingContractId ? '编辑合同段' : '新增合同段' }}
|
||||
</h4>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 px-5 py-4">
|
||||
<label class="block text-sm font-medium text-foreground">合同段名称</label>
|
||||
<input
|
||||
v-model="contractNameInput"
|
||||
type="text"
|
||||
placeholder="请输入合同段名称"
|
||||
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@keydown.enter="createContract"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
|
||||
<Button variant="outline" @click="closeCreateModal">取消</Button>
|
||||
<Button :disabled="!contractNameInput.trim()" @click="createContract">
|
||||
{{ editingContractId ? '保存' : '确定' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToastRoot
|
||||
v-model:open="toastOpen"
|
||||
:duration="1800"
|
||||
class="group pointer-events-auto flex items-center gap-3 rounded-xl border border-slate-800/90 bg-slate-900 px-4 py-3 text-white shadow-xl"
|
||||
>
|
||||
<div class="grid gap-1">
|
||||
<ToastTitle class="text-sm font-semibold text-white">{{ toastTitle }}</ToastTitle>
|
||||
<ToastDescription class="text-xs text-slate-100">{{ toastText }}</ToastDescription>
|
||||
</div>
|
||||
<ToastAction
|
||||
alt-text="知道了"
|
||||
class="ml-auto inline-flex h-7 items-center rounded-md border border-white/30 bg-white/10 px-2 text-xs text-white hover:bg-white/20"
|
||||
@click="toastOpen = false"
|
||||
>
|
||||
知道了
|
||||
</ToastAction>
|
||||
</ToastRoot>
|
||||
<ToastViewport class="fixed bottom-5 right-5 z-[80] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
|
||||
</ToastProvider>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<TypeLine
|
||||
scene="xm-tab"
|
||||
title="项目配置"
|
||||
storage-key="project-active-cat"
|
||||
default-category="info"
|
||||
:categories="xmCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { markRaw, defineAsyncComponent } from 'vue'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
|
||||
const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
|
||||
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
||||
|
||||
const xmCategories = [
|
||||
{ key: 'info', label: '基础信息', component: xmView },
|
||||
{ key: 'contract', label: '合同段管理', component: htView }
|
||||
]
|
||||
</script>
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<TypeLine
|
||||
scene="zxfw-pricing-tab"
|
||||
title="咨询服务计算"
|
||||
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
|
||||
default-category="investment-scale-method"
|
||||
:categories="pricingCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
contractName?: string
|
||||
serviceId: string|number
|
||||
}>()
|
||||
|
||||
interface PricingCategoryItem {
|
||||
key: string
|
||||
label: string
|
||||
component: Component
|
||||
}
|
||||
|
||||
const createPricingPane = (name: string) =>
|
||||
markRaw(
|
||||
defineComponent({
|
||||
name,
|
||||
setup() {
|
||||
const AsyncPricingView = defineAsyncComponent({
|
||||
loader: () => import(`@/components/views/pricingView/${name}.vue`),
|
||||
onError: err => {
|
||||
console.error('加载 PricingMethodView 组件失败:', err)
|
||||
}
|
||||
})
|
||||
|
||||
return () => h(AsyncPricingView, { contractId: props.contractId, serviceId: props.serviceId })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
|
||||
const landScaleView = createPricingPane('LandScalePricingPane')
|
||||
const workloadView = createPricingPane('WorkloadPricingPane')
|
||||
const hourlyView = createPricingPane('HourlyPricingPane')
|
||||
|
||||
const pricingCategories: PricingCategoryItem[] = [
|
||||
{ key: 'investment-scale-method', label: '投资规模法', component: investmentScaleView },
|
||||
{ key: 'land-scale-method', label: '用地规模法', component: landScaleView },
|
||||
{ key: 'workload-method', label: '工作量法', component: workloadView },
|
||||
{ key: 'hourly-method', label: '工时法', component: hourlyView }
|
||||
]
|
||||
</script>
|
||||
@@ -1,397 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { majorList } from '@/sql'
|
||||
import 'ag-grid-enterprise'
|
||||
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
}>()
|
||||
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const XM_DB_KEY = 'xm-info-v3'
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
|
||||
type majorLite = { code: string; name: string }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
||||
|
||||
for (const [key, item] of serviceEntries) {
|
||||
const code = item.code
|
||||
const isGroup = !code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(code)) groupOrder.push(code)
|
||||
groupMap.set(code, {
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
})()
|
||||
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: [group.id, child.id]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
|
||||
{
|
||||
headerName: '造价金额(万元)',
|
||||
field: 'amount',
|
||||
minWidth: 170,
|
||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? v : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '用地面积(亩)',
|
||||
field: 'landArea',
|
||||
minWidth: 170,
|
||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? v : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '专业编码以及工程专业名称',
|
||||
minWidth: 320,
|
||||
pinned: 'left',
|
||||
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
return idLabelMap.get(nodeId) || nodeId
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: GridOptions<DetailRow> = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
singleClickEdit: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
groupDefaultExpanded: -1,
|
||||
suppressFieldDotNotation: true,
|
||||
getDataPath: data => data.path,
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
|
||||
)
|
||||
|
||||
const totalLandArea = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
|
||||
)
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
amount: totalAmount.value,
|
||||
landArea: totalLandArea.value,
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log('Saving to IndexedDB:', payload)
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
// 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐)
|
||||
const xmData = await localforage.getItem<XmInfoState>(XM_DB_KEY)
|
||||
if (xmData?.detailRows) {
|
||||
detailRows.value = mergeWithDictRows(xmData.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params:any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style >
|
||||
.ag-floating-top{
|
||||
overflow-y:auto !important
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line .ag-cell-value {
|
||||
display: inline-block;
|
||||
min-width: 84%;
|
||||
padding: 2px 4px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
font-style: italic;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xmMx .ag-cell.editable-cell-empty,
|
||||
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,437 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, ColGroupDef, GridOptions } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { majorList } from '@/sql'
|
||||
import 'ag-grid-enterprise'
|
||||
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
laborBudgetUnitPrice: number | null
|
||||
compositeBudgetUnitPrice: number | null
|
||||
adoptedBudgetUnitPrice: number | null
|
||||
personnelCount: number | null
|
||||
workdayCount: number | null
|
||||
serviceBudget: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
|
||||
serviceId: string|number
|
||||
}>()
|
||||
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
|
||||
type majorLite = { code: string; name: string }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
||||
|
||||
for (const [key, item] of serviceEntries) {
|
||||
const code = item.code
|
||||
const isGroup = !code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(code)) groupOrder.push(code)
|
||||
groupMap.set(code, {
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
})()
|
||||
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
laborBudgetUnitPrice: null,
|
||||
compositeBudgetUnitPrice: null,
|
||||
adoptedBudgetUnitPrice: null,
|
||||
personnelCount: null,
|
||||
workdayCount: null,
|
||||
serviceBudget: null,
|
||||
remark: '',
|
||||
path: [group.id, child.id]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
laborBudgetUnitPrice:
|
||||
typeof fromDb.laborBudgetUnitPrice === 'number' ? fromDb.laborBudgetUnitPrice : null,
|
||||
compositeBudgetUnitPrice:
|
||||
typeof fromDb.compositeBudgetUnitPrice === 'number' ? fromDb.compositeBudgetUnitPrice : null,
|
||||
adoptedBudgetUnitPrice:
|
||||
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
||||
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
||||
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
|
||||
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNumberOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
const v = Number(value)
|
||||
return Number.isFinite(v) ? v : null
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
|
||||
const editableNumberCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
...extra
|
||||
})
|
||||
|
||||
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
||||
{
|
||||
headerName: '人员名称',
|
||||
minWidth: 200,
|
||||
width: 220,
|
||||
pinned: 'left',
|
||||
valueGetter: params => {
|
||||
if (params.node?.rowPinned) return ''
|
||||
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '预算参考单价',
|
||||
marryChildren: true,
|
||||
children: [
|
||||
editableNumberCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
|
||||
editableNumberCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
|
||||
]
|
||||
},
|
||||
editableNumberCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
|
||||
editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: 'sum' }),
|
||||
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: 'sum' }),
|
||||
editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: 'sum' }),
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '编码',
|
||||
minWidth: 150,
|
||||
pinned: 'left',
|
||||
width: 160,
|
||||
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const label = idLabelMap.get(nodeId) || nodeId
|
||||
return label.includes(' ') ? label.split(' ')[0] : label
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: GridOptions<DetailRow> = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
singleClickEdit: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
groupDefaultExpanded: -1,
|
||||
suppressFieldDotNotation: true,
|
||||
getDataPath: data => data.path,
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
}
|
||||
|
||||
const totalPersonnelCount = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.personnelCount || 0), 0)
|
||||
)
|
||||
|
||||
const totalWorkdayCount = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.workdayCount || 0), 0)
|
||||
)
|
||||
|
||||
const totalServiceBudget = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.serviceBudget || 0), 0)
|
||||
)
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
laborBudgetUnitPrice: null,
|
||||
compositeBudgetUnitPrice: null,
|
||||
adoptedBudgetUnitPrice: null,
|
||||
personnelCount: totalPersonnelCount.value,
|
||||
workdayCount: totalWorkdayCount.value,
|
||||
serviceBudget: totalServiceBudget.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log('Saving to IndexedDB:', payload)
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params:any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">工时法明细</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style >
|
||||
.ag-floating-top{
|
||||
overflow-y:auto !important
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line .ag-cell-value {
|
||||
display: inline-block;
|
||||
min-width: 84%;
|
||||
padding: 2px 4px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
font-style: italic;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xmMx .ag-cell.editable-cell-empty,
|
||||
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,474 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { majorList } from '@/sql'
|
||||
import 'ag-grid-enterprise'
|
||||
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
amount: number | null
|
||||
benchmarkBudget: number | null
|
||||
consultCategoryFactor: number | null
|
||||
majorFactor: number | null
|
||||
budgetFee: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
serviceId: string|number
|
||||
}>()
|
||||
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
|
||||
type majorLite = { code: string; name: string }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
||||
|
||||
for (const [key, item] of serviceEntries) {
|
||||
const code = item.code
|
||||
const isGroup = !code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(code)) groupOrder.push(code)
|
||||
groupMap.set(code, {
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
})()
|
||||
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
amount: null,
|
||||
benchmarkBudget: null,
|
||||
consultCategoryFactor: null,
|
||||
majorFactor: null,
|
||||
budgetFee: null,
|
||||
remark: '',
|
||||
path: [group.id, child.id]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
|
||||
consultCategoryFactor:
|
||||
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
|
||||
majorFactor: typeof fromDb.majorFactor === 'number' ? fromDb.majorFactor : null,
|
||||
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNumberOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
const v = Number(value)
|
||||
return Number.isFinite(v) ? v : null
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: '工程专业名称',
|
||||
minWidth: 220,
|
||||
width: 240,
|
||||
pinned: 'left',
|
||||
valueGetter: params => {
|
||||
if (params.node?.rowPinned) return ''
|
||||
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '造价金额(万元)',
|
||||
field: 'amount',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '基准预算(元)',
|
||||
field: 'benchmarkBudget',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '咨询分类系数',
|
||||
field: 'consultCategoryFactor',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '专业系数',
|
||||
field: 'majorFactor',
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '预算费用',
|
||||
field: 'budgetFee',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '专业编码',
|
||||
minWidth: 160,
|
||||
pinned: 'left',
|
||||
width: 170,
|
||||
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const label = idLabelMap.get(nodeId) || nodeId
|
||||
return label.includes(' ') ? label.split(' ')[0] : label
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: GridOptions<DetailRow> = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
singleClickEdit: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
groupDefaultExpanded: -1,
|
||||
suppressFieldDotNotation: true,
|
||||
getDataPath: data => data.path,
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
|
||||
)
|
||||
|
||||
const totalBenchmarkBudget = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
|
||||
)
|
||||
|
||||
const totalBudgetFee = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
|
||||
)
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
amount: totalAmount.value,
|
||||
benchmarkBudget: totalBenchmarkBudget.value,
|
||||
consultCategoryFactor: null,
|
||||
majorFactor: null,
|
||||
budgetFee: totalBudgetFee.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log('Saving to IndexedDB:', payload)
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params:any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style >
|
||||
.ag-floating-top{
|
||||
overflow-y:auto !important
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line .ag-cell-value {
|
||||
display: inline-block;
|
||||
min-width: 84%;
|
||||
padding: 2px 4px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
font-style: italic;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xmMx .ag-cell.editable-cell-empty,
|
||||
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,481 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { majorList } from '@/sql'
|
||||
import 'ag-grid-enterprise'
|
||||
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
benchmarkBudget: number | null
|
||||
consultCategoryFactor: number | null
|
||||
majorFactor: number | null
|
||||
budgetFee: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
serviceId: string|number
|
||||
}>()
|
||||
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
|
||||
type majorLite = { code: string; name: string }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
||||
|
||||
for (const [key, item] of serviceEntries) {
|
||||
const code = item.code
|
||||
const isGroup = !code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(code)) groupOrder.push(code)
|
||||
groupMap.set(code, {
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
})()
|
||||
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
benchmarkBudget: null,
|
||||
consultCategoryFactor: null,
|
||||
majorFactor: null,
|
||||
budgetFee: null,
|
||||
remark: '',
|
||||
path: [group.id, child.id]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
|
||||
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
|
||||
consultCategoryFactor:
|
||||
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
|
||||
majorFactor: typeof fromDb.majorFactor === 'number' ? fromDb.majorFactor : null,
|
||||
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNumberOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
const v = Number(value)
|
||||
return Number.isFinite(v) ? v : null
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: '工程专业名称',
|
||||
minWidth: 220,
|
||||
width: 240,
|
||||
pinned: 'left',
|
||||
valueGetter: params => {
|
||||
if (params.node?.rowPinned) return ''
|
||||
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '用地面积(亩)',
|
||||
field: 'landArea',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '基准预算(元)',
|
||||
field: 'benchmarkBudget',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '咨询分类系数',
|
||||
field: 'consultCategoryFactor',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '专业系数',
|
||||
field: 'majorFactor',
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '预算费用',
|
||||
field: 'budgetFee',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '专业编码',
|
||||
minWidth: 160,
|
||||
pinned: 'left',
|
||||
width: 170,
|
||||
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const label = idLabelMap.get(nodeId) || nodeId
|
||||
return label.includes(' ') ? label.split(' ')[0] : label
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: GridOptions<DetailRow> = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
singleClickEdit: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
groupDefaultExpanded: -1,
|
||||
suppressFieldDotNotation: true,
|
||||
getDataPath: data => data.path,
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
|
||||
)
|
||||
|
||||
const totalLandArea = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
|
||||
)
|
||||
|
||||
const totalBenchmarkBudget = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
|
||||
)
|
||||
|
||||
const totalBudgetFee = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
|
||||
)
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
amount: totalAmount.value,
|
||||
landArea: totalLandArea.value,
|
||||
benchmarkBudget: totalBenchmarkBudget.value,
|
||||
consultCategoryFactor: null,
|
||||
majorFactor: null,
|
||||
budgetFee: totalBudgetFee.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log('Saving to IndexedDB:', payload)
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params:any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style >
|
||||
.ag-floating-top{
|
||||
overflow-y:auto !important
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line .ag-cell-value {
|
||||
display: inline-block;
|
||||
min-width: 84%;
|
||||
padding: 2px 4px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
font-style: italic;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xmMx .ag-cell.editable-cell-empty,
|
||||
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,493 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { majorList } from '@/sql'
|
||||
import 'ag-grid-enterprise'
|
||||
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
workload: number | null
|
||||
budgetBase: number | null
|
||||
budgetReferenceUnitPrice: number | null
|
||||
budgetAdoptedUnitPrice: number | null
|
||||
consultCategoryFactor: number | null
|
||||
serviceFee: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
|
||||
serviceId: string|number
|
||||
}>()
|
||||
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
|
||||
type majorLite = { code: string; name: string }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
||||
|
||||
for (const [key, item] of serviceEntries) {
|
||||
const code = item.code
|
||||
const isGroup = !code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(code)) groupOrder.push(code)
|
||||
groupMap.set(code, {
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
})()
|
||||
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
workload: null,
|
||||
budgetBase: null,
|
||||
budgetReferenceUnitPrice: null,
|
||||
budgetAdoptedUnitPrice: null,
|
||||
consultCategoryFactor: null,
|
||||
serviceFee: null,
|
||||
remark: '',
|
||||
path: [group.id, child.id]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
workload: typeof fromDb.workload === 'number' ? fromDb.workload : null,
|
||||
budgetBase: typeof fromDb.budgetBase === 'number' ? fromDb.budgetBase : null,
|
||||
budgetReferenceUnitPrice:
|
||||
typeof fromDb.budgetReferenceUnitPrice === 'number' ? fromDb.budgetReferenceUnitPrice : null,
|
||||
budgetAdoptedUnitPrice:
|
||||
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
|
||||
consultCategoryFactor:
|
||||
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
|
||||
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNumberOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
const v = Number(value)
|
||||
return Number.isFinite(v) ? v : null
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: '工作内容',
|
||||
minWidth: 220,
|
||||
width: 240,
|
||||
pinned: 'left',
|
||||
valueGetter: params => {
|
||||
if (params.node?.rowPinned) return ''
|
||||
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '工作量',
|
||||
field: 'workload',
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '预算基数',
|
||||
field: 'budgetBase',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '预算参考单价',
|
||||
field: 'budgetReferenceUnitPrice',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '预算采用单价',
|
||||
field: 'budgetAdoptedUnitPrice',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '咨询分类系数',
|
||||
field: 'consultCategoryFactor',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '服务费用(元)',
|
||||
field: 'serviceFee',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => parseNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '编码',
|
||||
minWidth: 160,
|
||||
pinned: 'left',
|
||||
width: 170,
|
||||
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const label = idLabelMap.get(nodeId) || nodeId
|
||||
return label.includes(' ') ? label.split(' ')[0] : label
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: GridOptions<DetailRow> = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
singleClickEdit: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
groupDefaultExpanded: -1,
|
||||
suppressFieldDotNotation: true,
|
||||
getDataPath: data => data.path,
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
}
|
||||
|
||||
const totalBudgetBase = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.budgetBase || 0), 0)
|
||||
)
|
||||
|
||||
const totalWorkload = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.workload || 0), 0)
|
||||
)
|
||||
|
||||
const totalServiceFee = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.serviceFee || 0), 0)
|
||||
)
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
workload: totalWorkload.value,
|
||||
budgetBase: totalBudgetBase.value,
|
||||
budgetReferenceUnitPrice: null,
|
||||
budgetAdoptedUnitPrice: null,
|
||||
consultCategoryFactor: null,
|
||||
serviceFee: totalServiceFee.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log('Saving to IndexedDB:', payload)
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params:any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">工作流规模</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style >
|
||||
.ag-floating-top{
|
||||
overflow-y:auto !important
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line .ag-cell-value {
|
||||
display: inline-block;
|
||||
min-width: 84%;
|
||||
padding: 2px 4px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
font-style: italic;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xmMx .ag-cell.editable-cell-empty,
|
||||
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,486 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { majorList } from '@/sql'
|
||||
import 'ag-grid-enterprise'
|
||||
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const DB_KEY = 'xm-info-v3'
|
||||
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||
|
||||
const projectName = ref(DEFAULT_PROJECT_NAME)
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const gridSectionRef = ref<HTMLElement | null>(null)
|
||||
const agGridRef = ref<HTMLElement | null>(null)
|
||||
const agGridHeight = ref(580)
|
||||
let snapScrollHost: HTMLElement | null = null
|
||||
let snapTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isSnapping = false
|
||||
let hostResizeObserver: ResizeObserver | null = null
|
||||
|
||||
const updateGridCardHeight = () => {
|
||||
if (!snapScrollHost || !rootRef.value) return
|
||||
const contentWrap = rootRef.value.parentElement
|
||||
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
|
||||
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||||
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
||||
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
||||
agGridHeight.value = nextHeight
|
||||
}
|
||||
|
||||
const bindSnapScrollHost = () => {
|
||||
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
|
||||
if (!snapScrollHost) return
|
||||
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
|
||||
hostResizeObserver?.disconnect()
|
||||
hostResizeObserver = new ResizeObserver(() => {
|
||||
updateGridCardHeight()
|
||||
})
|
||||
hostResizeObserver.observe(snapScrollHost)
|
||||
updateGridCardHeight()
|
||||
}
|
||||
|
||||
const unbindSnapScrollHost = () => {
|
||||
if (snapScrollHost) {
|
||||
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
|
||||
}
|
||||
hostResizeObserver?.disconnect()
|
||||
hostResizeObserver = null
|
||||
snapScrollHost = null
|
||||
}
|
||||
|
||||
const trySnapToGrid = () => {
|
||||
if (isSnapping || !snapScrollHost || !agGridRef.value) return
|
||||
|
||||
const hostRect = snapScrollHost.getBoundingClientRect()
|
||||
const gridRect = agGridRef.value.getBoundingClientRect()
|
||||
const offsetTop = gridRect.top - hostRect.top
|
||||
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
|
||||
const inSnapRange = offsetTop > -120 && offsetTop < 180
|
||||
|
||||
if (!inVisibleBand || !inSnapRange) return
|
||||
|
||||
isSnapping = true
|
||||
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||
snapLockTimer = setTimeout(() => {
|
||||
isSnapping = false
|
||||
}, 420)
|
||||
}
|
||||
|
||||
function handleSnapHostScroll() {
|
||||
if (isSnapping) return
|
||||
if (snapTimer) clearTimeout(snapTimer)
|
||||
snapTimer = setTimeout(() => {
|
||||
trySnapToGrid()
|
||||
}, 90)
|
||||
}
|
||||
type MajorLite = { code: string; name: string }
|
||||
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, MajorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(majorEntries.map(([key, item]) => [item.code, { id: key, ...item }]))
|
||||
|
||||
for (const [key, item] of majorEntries) {
|
||||
const isGroup = !item.code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(item.code)) groupOrder.push(item.code)
|
||||
groupMap.set(item.code, {
|
||||
id: key,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = item.code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code: item.code,
|
||||
name: item.name
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
})()
|
||||
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: [group.id, child.id]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
|
||||
{
|
||||
headerName: '造价金额(万元)',
|
||||
field: 'amount',
|
||||
minWidth: 170,
|
||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? v : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '用地面积(亩)',
|
||||
field: 'landArea',
|
||||
minWidth: 170,
|
||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: 'sum',
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? v : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '专业编码以及工程专业名称',
|
||||
minWidth: 320,
|
||||
pinned: 'left',
|
||||
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
return idLabelMap.get(nodeId) || nodeId
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: GridOptions<DetailRow> = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
singleClickEdit: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
groupDefaultExpanded: -1,
|
||||
suppressFieldDotNotation: true,
|
||||
getDataPath: data => data.path,
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
|
||||
)
|
||||
|
||||
const totalLandArea = computed(() =>
|
||||
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
|
||||
)
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
amount: totalAmount.value,
|
||||
landArea: totalLandArea.value,
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: XmInfoState = {
|
||||
projectName: projectName.value,
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log(payload)
|
||||
await localforage.setItem(DB_KEY, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY)
|
||||
if (data) {
|
||||
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const schedulePersist = () => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// const handleBeforeUnload = () => {
|
||||
// void saveToIndexedDB()
|
||||
// }
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
watch(projectName, schedulePersist)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
bindSnapScrollHost()
|
||||
requestAnimationFrame(() => {
|
||||
updateGridCardHeight()
|
||||
})
|
||||
// window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
unbindSnapScrollHost()
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
if (snapTimer) clearTimeout(snapTimer)
|
||||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params:any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="space-y-6">
|
||||
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<label class="block text-sm font-medium text-foreground">项目名称</label>
|
||||
</div>
|
||||
<input
|
||||
v-model="projectName"
|
||||
type="text"
|
||||
placeholder="请输入项目名称"
|
||||
class="h-10 w-full max-w-xl rounded-md border bg-background px-3 text-sm outline-none ring-offset-background transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="gridSectionRef"
|
||||
class="rounded-lg border bg-card xmMx scroll-mt-3"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">项目明细</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
<div ref="agGridRef" class="ag-theme-quartz w-full" :style="{ height: `${agGridHeight}px` }">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style >
|
||||
.ag-floating-top{
|
||||
overflow-y:auto !important
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line .ag-cell-value {
|
||||
display: inline-block;
|
||||
min-width: 84%;
|
||||
padding: 2px 4px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.xmMx .editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
font-style: italic;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xmMx .ag-cell.editable-cell-empty,
|
||||
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,685 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import 'ag-grid-enterprise'
|
||||
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from 'reka-ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { serviceList } from '@/sql'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
|
||||
interface ServiceItem {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
investScale: number | null
|
||||
landScale: number | null
|
||||
workload: number | null
|
||||
hourly: number | null
|
||||
subtotal?: number | null
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
interface ZxFwState {
|
||||
selectedIds?: string[]
|
||||
selectedCodes?: string[]
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
}>()
|
||||
const tabStore = useTabStore()
|
||||
const DB_KEY = computed(() => `zxFW-${props.contractId}`)
|
||||
|
||||
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
|
||||
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, ServiceListItem] => {
|
||||
const item = entry[1]
|
||||
const itemCode = item?.code || item?.ref
|
||||
return Boolean(itemCode && item?.name) && item.defCoe !== null
|
||||
})
|
||||
.map(([key, item]) => ({
|
||||
id: key,
|
||||
code: item.code || item.ref || '',
|
||||
name: item.name
|
||||
}))
|
||||
|
||||
const serviceById = new Map(serviceDict.map(item => [item.id, item]))
|
||||
const serviceIdByCode = new Map(serviceDict.map(item => [item.code, item.id]))
|
||||
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: 'C', name: '合同预算' }
|
||||
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
|
||||
|
||||
const selectedIds = ref<string[]>([])
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const gridSectionRef = ref<HTMLElement | null>(null)
|
||||
const agGridRef = ref<HTMLElement | null>(null)
|
||||
const agGridHeight = ref(580)
|
||||
let snapScrollHost: HTMLElement | null = null
|
||||
let snapTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isSnapping = false
|
||||
let hostResizeObserver: ResizeObserver | null = null
|
||||
|
||||
const updateGridCardHeight = () => {
|
||||
if (!snapScrollHost || !rootRef.value) return
|
||||
const contentWrap = rootRef.value.parentElement
|
||||
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
|
||||
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||||
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
||||
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
||||
agGridHeight.value = nextHeight
|
||||
}
|
||||
|
||||
const bindSnapScrollHost = () => {
|
||||
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
|
||||
if (!snapScrollHost) return
|
||||
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
|
||||
hostResizeObserver?.disconnect()
|
||||
hostResizeObserver = new ResizeObserver(() => {
|
||||
updateGridCardHeight()
|
||||
})
|
||||
hostResizeObserver.observe(snapScrollHost)
|
||||
updateGridCardHeight()
|
||||
}
|
||||
|
||||
const unbindSnapScrollHost = () => {
|
||||
if (snapScrollHost) {
|
||||
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
|
||||
}
|
||||
hostResizeObserver?.disconnect()
|
||||
hostResizeObserver = null
|
||||
snapScrollHost = null
|
||||
}
|
||||
|
||||
const trySnapToGrid = () => {
|
||||
if (isSnapping || !snapScrollHost || !agGridRef.value) return
|
||||
|
||||
const hostRect = snapScrollHost.getBoundingClientRect()
|
||||
const gridRect = agGridRef.value.getBoundingClientRect()
|
||||
const offsetTop = gridRect.top - hostRect.top
|
||||
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
|
||||
const inSnapRange = offsetTop > -120 && offsetTop < 180
|
||||
|
||||
if (!inVisibleBand || !inSnapRange) return
|
||||
|
||||
isSnapping = true
|
||||
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||
snapLockTimer = setTimeout(() => {
|
||||
isSnapping = false
|
||||
}, 420)
|
||||
}
|
||||
|
||||
function handleSnapHostScroll() {
|
||||
if (isSnapping) return
|
||||
if (snapTimer) clearTimeout(snapTimer)
|
||||
snapTimer = setTimeout(() => {
|
||||
trySnapToGrid()
|
||||
}, 90)
|
||||
}
|
||||
|
||||
const pickerOpen = ref(false)
|
||||
const pickerTempIds = ref<string[]>([])
|
||||
const pickerSearch = ref('')
|
||||
const dragSelecting = ref(false)
|
||||
let dragSelectChecked = false
|
||||
const dragAppliedCodes = new Set<string>()
|
||||
const dragStartPoint = ref({ x: 0, y: 0 })
|
||||
const dragCurrentPoint = ref({ x: 0, y: 0 })
|
||||
const pickerItemElMap = new Map<string, HTMLElement>()
|
||||
|
||||
const selectedServiceText = computed(() => {
|
||||
if (selectedIds.value.length === 0) return ''
|
||||
const names = selectedIds.value
|
||||
.map(id => serviceById.get(id)?.name || '')
|
||||
.filter(Boolean)
|
||||
if (names.length <= 2) return names.join('、')
|
||||
return `${names.slice(0, 2).join('、')} 等 ${names.length} 项`
|
||||
})
|
||||
|
||||
const filteredServiceDict = computed(() => {
|
||||
const keyword = pickerSearch.value.trim()
|
||||
if (!keyword) return serviceDict
|
||||
return serviceDict.filter(item => item.code.includes(keyword) || item.name.includes(keyword))
|
||||
})
|
||||
|
||||
const dragRectStyle = computed(() => {
|
||||
if (!dragSelecting.value) return {}
|
||||
const left = Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x)
|
||||
const top = Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
||||
const width = Math.abs(dragCurrentPoint.value.x - dragStartPoint.value.x)
|
||||
const height = Math.abs(dragCurrentPoint.value.y - dragStartPoint.value.y)
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
}
|
||||
})
|
||||
|
||||
const numericParser = (newValue: any): number | null => {
|
||||
if (newValue === '' || newValue == null) return null
|
||||
const num = Number(newValue)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const valueOrZero = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
|
||||
|
||||
const getPricingPaneStorageKeys = (serviceId: string) => [
|
||||
`tzGMF-${props.contractId}-${serviceId}`,
|
||||
`ydGMF-${props.contractId}-${serviceId}`,
|
||||
`gzlF-${props.contractId}-${serviceId}`,
|
||||
`hourlyPricing-${props.contractId}-${serviceId}`
|
||||
]
|
||||
|
||||
const clearPricingPaneValues = async (serviceId: string) => {
|
||||
const keys = getPricingPaneStorageKeys(serviceId)
|
||||
await Promise.all(keys.map(key => localforage.removeItem(key)))
|
||||
}
|
||||
|
||||
const clearRowValues = async (row: DetailRow) => {
|
||||
if (isFixedRow(row)) return
|
||||
|
||||
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓�? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
|
||||
await nextTick()
|
||||
|
||||
row.investScale = null
|
||||
row.landScale = null
|
||||
row.workload = null
|
||||
row.hourly = null
|
||||
row.subtotal = null
|
||||
detailRows.value = [...detailRows.value]
|
||||
|
||||
await clearPricingPaneValues(row.id)
|
||||
// 定时�?卸载保存可能产生回写,延迟再清一次更稳妥
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
await clearPricingPaneValues(row.id)
|
||||
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
const openEditTab = (row: DetailRow) => {
|
||||
tabStore.openTab({
|
||||
id: `zxfw-edit-${props.contractId}-${row.id}`,
|
||||
title: `服务编辑-${row.code}${row.name}`,
|
||||
componentName: 'ZxFwView',
|
||||
props: { contractId: props.contractId, contractName: row.name ,serviceId: row.id}
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{ headerName: '编码', field: 'code', minWidth: 80, flex: 1 },
|
||||
{ headerName: '名称', field: 'name', minWidth: 320, flex: 2 },
|
||||
{
|
||||
headerName: '投资规模法',
|
||||
field: 'investScale',
|
||||
minWidth: 140,
|
||||
flex: 2,
|
||||
editable: false,
|
||||
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||
},
|
||||
{
|
||||
headerName: '用地规模法',
|
||||
field: 'landScale',
|
||||
minWidth: 140,
|
||||
flex: 2,
|
||||
editable: false,
|
||||
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||
},
|
||||
{
|
||||
headerName: '工作量法',
|
||||
field: 'workload',
|
||||
minWidth: 120,
|
||||
flex: 2,
|
||||
editable: false,
|
||||
|
||||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||
},
|
||||
{
|
||||
headerName: '工时法',
|
||||
field: 'hourly',
|
||||
minWidth: 120,
|
||||
flex: 2,
|
||||
editable: false,
|
||||
|
||||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||
},
|
||||
{
|
||||
headerName: '小计',
|
||||
field: 'subtotal',
|
||||
flex: 3,
|
||||
|
||||
minWidth: 120,
|
||||
editable: false,
|
||||
valueGetter: params => {
|
||||
if (!params.data) return null
|
||||
return (
|
||||
valueOrZero(params.data.investScale) +
|
||||
valueOrZero(params.data.landScale) +
|
||||
valueOrZero(params.data.workload) +
|
||||
valueOrZero(params.data.hourly)
|
||||
)
|
||||
},
|
||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||
},
|
||||
{
|
||||
headerName: '操作',
|
||||
field: 'actions',
|
||||
minWidth: 88,
|
||||
flex: 1,
|
||||
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: (params: ICellRendererParams<DetailRow>) =>
|
||||
isFixedRow(params.data)
|
||||
? ''
|
||||
: `<div class="zxfw-action-wrap">
|
||||
<button class="zxfw-action-btn" data-action="clear" title="清空">🧹</button>
|
||||
<button class="zxfw-action-btn" data-action="edit" title="编辑">✏️</button>
|
||||
</div>`
|
||||
}
|
||||
]
|
||||
|
||||
const gridOptions: GridOptions<DetailRow> = {
|
||||
animateRows: true,
|
||||
singleClickEdit: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
},
|
||||
onCellClicked: async params => {
|
||||
if (params.colDef.field !== 'actions' || !params.data || isFixedRow(params.data)) return
|
||||
const target = params.event?.target as HTMLElement | null
|
||||
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
||||
const action = btn?.dataset.action
|
||||
if (action === 'clear') {
|
||||
await clearRowValues(params.data)
|
||||
return
|
||||
}
|
||||
if (action === 'edit') {
|
||||
openEditTab(params.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const applySelection = (codes: string[]) => {
|
||||
const prevSelectedSet = new Set(selectedIds.value)
|
||||
const uniqueIds = Array.from(new Set(codes)).filter(
|
||||
id => serviceById.has(id) && id !== fixedBudgetRow.id
|
||||
)
|
||||
const existingMap = new Map(detailRows.value.map(row => [row.id, row]))
|
||||
|
||||
const baseRows: DetailRow[] = uniqueIds
|
||||
.map(id => {
|
||||
const dictItem = serviceById.get(id)
|
||||
if (!dictItem) return null
|
||||
|
||||
const old = existingMap.get(id)
|
||||
return {
|
||||
id: old?.id || id,
|
||||
code: dictItem.code,
|
||||
name: dictItem.name,
|
||||
investScale: old?.investScale ?? null,
|
||||
landScale: old?.landScale ?? null,
|
||||
workload: old?.workload ?? null,
|
||||
hourly: old?.hourly ?? null
|
||||
}
|
||||
})
|
||||
.filter((row): row is DetailRow => Boolean(row))
|
||||
|
||||
const orderMap = new Map(serviceDict.map((item, index) => [item.id, index]))
|
||||
baseRows.sort((a, b) => (orderMap.get(a.id) || 0) - (orderMap.get(b.id) || 0))
|
||||
|
||||
const fixedOld = existingMap.get(fixedBudgetRow.id)
|
||||
const fixedRow: DetailRow = {
|
||||
id: fixedOld?.id || fixedBudgetRow.id,
|
||||
code: fixedBudgetRow.code,
|
||||
name: fixedBudgetRow.name,
|
||||
investScale: typeof fixedOld?.investScale === 'number' ? fixedOld.investScale : null,
|
||||
landScale: typeof fixedOld?.landScale === 'number' ? fixedOld.landScale : null,
|
||||
workload: typeof fixedOld?.workload === 'number' ? fixedOld.workload : null,
|
||||
hourly: typeof fixedOld?.hourly === 'number' ? fixedOld.hourly : null,
|
||||
subtotal: null,
|
||||
actions: null
|
||||
}
|
||||
|
||||
const removedIds = Array.from(prevSelectedSet).filter(id => !uniqueIds.includes(id))
|
||||
for (const id of removedIds) {
|
||||
tabStore.removeTab(`zxfw-edit-${props.contractId}-${id}`)
|
||||
}
|
||||
|
||||
selectedIds.value = uniqueIds
|
||||
detailRows.value = [...baseRows, fixedRow]
|
||||
}
|
||||
|
||||
const preparePickerOpen = () => {
|
||||
pickerTempIds.value = [...selectedIds.value]
|
||||
pickerSearch.value = ''
|
||||
}
|
||||
|
||||
const closePicker = () => {
|
||||
stopDragSelect()
|
||||
pickerOpen.value = false
|
||||
}
|
||||
|
||||
const handlePickerOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
preparePickerOpen()
|
||||
} else {
|
||||
stopDragSelect()
|
||||
}
|
||||
pickerOpen.value = open
|
||||
}
|
||||
|
||||
const confirmPicker = () => {
|
||||
applySelection(pickerTempIds.value)
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const clearPickerSelection = () => {
|
||||
pickerTempIds.value = []
|
||||
}
|
||||
|
||||
const toggleServiceCode = (code: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!pickerTempIds.value.includes(code)) {
|
||||
pickerTempIds.value = [...pickerTempIds.value, code]
|
||||
}
|
||||
return
|
||||
}
|
||||
pickerTempIds.value = pickerTempIds.value.filter(item => item !== code)
|
||||
}
|
||||
|
||||
const applyTempChecked = (code: string, checked: boolean) => {
|
||||
const exists = pickerTempIds.value.includes(code)
|
||||
if (checked && !exists) {
|
||||
pickerTempIds.value = [...pickerTempIds.value, code]
|
||||
return
|
||||
}
|
||||
if (!checked && exists) {
|
||||
pickerTempIds.value = pickerTempIds.value.filter(item => item !== code)
|
||||
}
|
||||
}
|
||||
|
||||
const setPickerItemRef = (
|
||||
code: string,
|
||||
el: Element | ComponentPublicInstance | null
|
||||
) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
pickerItemElMap.set(code, el)
|
||||
return
|
||||
}
|
||||
pickerItemElMap.delete(code)
|
||||
}
|
||||
|
||||
const isRectIntersect = (
|
||||
a: { left: number; right: number; top: number; bottom: number },
|
||||
b: { left: number; right: number; top: number; bottom: number }
|
||||
) => !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom)
|
||||
|
||||
const applyDragSelectionByRect = () => {
|
||||
const rect = {
|
||||
left: Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
||||
right: Math.max(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
||||
top: Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y),
|
||||
bottom: Math.max(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
||||
}
|
||||
|
||||
for (const [code, el] of pickerItemElMap.entries()) {
|
||||
if (dragAppliedCodes.has(code)) continue
|
||||
const itemRect = el.getBoundingClientRect()
|
||||
const hit = isRectIntersect(rect, itemRect)
|
||||
if (hit) {
|
||||
applyTempChecked(code, dragSelectChecked)
|
||||
dragAppliedCodes.add(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopDragSelect = () => {
|
||||
dragSelecting.value = false
|
||||
dragAppliedCodes.clear()
|
||||
window.removeEventListener('mousemove', onDragSelectingMove)
|
||||
window.removeEventListener('mouseup', stopDragSelect)
|
||||
}
|
||||
|
||||
const onDragSelectingMove = (event: MouseEvent) => {
|
||||
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
|
||||
applyDragSelectionByRect()
|
||||
}
|
||||
|
||||
const startDragSelect = (event: MouseEvent, code: string) => {
|
||||
dragSelecting.value = true
|
||||
dragAppliedCodes.clear()
|
||||
dragSelectChecked = !pickerTempIds.value.includes(code)
|
||||
dragStartPoint.value = { x: event.clientX, y: event.clientY }
|
||||
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
|
||||
applyTempChecked(code, dragSelectChecked)
|
||||
dragAppliedCodes.add(code)
|
||||
window.addEventListener('mousemove', onDragSelectingMove)
|
||||
window.addEventListener('mouseup', stopDragSelect)
|
||||
}
|
||||
|
||||
const handleDragHover = (code: string) => {
|
||||
if (!dragSelecting.value || dragAppliedCodes.has(code)) return
|
||||
applyTempChecked(code, dragSelectChecked)
|
||||
dragAppliedCodes.add(code)
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: ZxFwState = {
|
||||
selectedIds: [...selectedIds.value],
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<ZxFwState>(DB_KEY.value)
|
||||
if (!data) {
|
||||
selectedIds.value = []
|
||||
detailRows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const idsFromStorage = data.selectedIds
|
||||
|| (data.selectedCodes || []).map(code => serviceIdByCode.get(code)).filter((id): id is string => Boolean(id))
|
||||
applySelection(idsFromStorage)
|
||||
|
||||
const savedRowMap = new Map((data.detailRows || []).map(row => [row.id, row]))
|
||||
detailRows.value = detailRows.value.map(row => {
|
||||
const old = savedRowMap.get(row.id)
|
||||
if (!old) return row
|
||||
return {
|
||||
...row,
|
||||
investScale: typeof old.investScale === 'number' ? old.investScale : null,
|
||||
landScale: typeof old.landScale === 'number' ? old.landScale : null,
|
||||
workload: typeof old.workload === 'number' ? old.workload : null,
|
||||
hourly: typeof old.hourly === 'number' ? old.hourly : null
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
selectedIds.value = []
|
||||
detailRows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
bindSnapScrollHost()
|
||||
requestAnimationFrame(() => {
|
||||
updateGridCardHeight()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbindSnapScrollHost()
|
||||
stopDragSelect()
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
if (snapTimer) clearTimeout(snapTimer)
|
||||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="space-y-6">
|
||||
<DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange">
|
||||
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<label class="block text-sm font-medium text-foreground">选择服务</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input :value="selectedServiceText" readonly placeholder="请点击右侧“浏览”选择服务"
|
||||
class="h-10 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none" />
|
||||
<DialogTrigger as-child>
|
||||
<button type="button"
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-md border text-sm hover:bg-accent cursor-pointer"
|
||||
title="浏览服务词典">
|
||||
<Search class="h-4 w-4" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/40" />
|
||||
<DialogContent
|
||||
class="fixed left-1/2 top-1/2 z-[60] w-[96vw] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background shadow-xl p-0">
|
||||
<DialogTitle class="sr-only">选择服务词典</DialogTitle>
|
||||
<DialogDescription class="sr-only">浏览并选择服务词典</DialogDescription>
|
||||
<div class="flex items-center justify-between border-b px-5 py-4">
|
||||
<h4 class="text-base font-semibold">选择服务词典</h4>
|
||||
<DialogClose as-child>
|
||||
<button type="button"
|
||||
class="inline-flex cursor-pointer h-8 items-center rounded-md border px-3 text-sm hover:bg-accent">
|
||||
关闭
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[420px] overflow-auto px-5 py-4">
|
||||
<div class="mb-3">
|
||||
<input v-model="pickerSearch" type="text" placeholder="输入编码或名称过滤"
|
||||
class="h-9 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<label v-for="item in filteredServiceDict" :key="item.id" :ref="el => setPickerItemRef(item.id, el)"
|
||||
class="flex select-none items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
@mousedown.prevent="startDragSelect($event, item.id)" @mouseenter="handleDragHover(item.id)"
|
||||
@click.prevent>
|
||||
<input type="checkbox" :checked="pickerTempIds.includes(item.id)" class="pointer-events-none" />
|
||||
<span class="text-muted-foreground">{{ item.code }}</span>
|
||||
<span>{{ item.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
|
||||
<Button type="button" variant="outline" @click="clearPickerSelection">
|
||||
清空
|
||||
</Button>
|
||||
<DialogClose as-child>
|
||||
<Button type="button" @click="confirmPicker">
|
||||
确认选择
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<div v-if="dragSelecting"
|
||||
class="pointer-events-none fixed z-[70] rounded-sm border border-sky-500/90 bg-sky-400/10"
|
||||
:style="dragRectStyle" />
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<div
|
||||
ref="gridSectionRef"
|
||||
class="rounded-lg border bg-card xmMx scroll-mt-3"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">咨询服务明细</h3>
|
||||
<div class="text-xs text-muted-foreground">按服务词典生成</div>
|
||||
</div>
|
||||
|
||||
<div ref="agGridRef" class="ag-theme-quartz w-full" :style="{ height: `${agGridHeight}px` }">
|
||||
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="gridOptions"
|
||||
:theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ag-floating-top {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.zxfw-action-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.zxfw-action-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
|
||||
import Tab from '@/layout/tab.vue'
|
||||
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
||||
import localforage from 'localforage'
|
||||
import {
|
||||
buildProjectUrl,
|
||||
DEFAULT_PROJECT_ID,
|
||||
ensureProjectIdInUrl,
|
||||
FORCE_HOME_QUERY_KEY,
|
||||
getProjectDbName,
|
||||
NEW_PROJECT_QUERY_KEY,
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_PROJECT_ID
|
||||
} from '@/lib/workspace'
|
||||
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
|
||||
import { listenProjectDeleted, listenResetAll } from '@/lib/projectEvents'
|
||||
import { listProjects, type ProjectMeta } from '@/lib/projectRegistry'
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const { t } = useI18n()
|
||||
const isReady = ref(false)
|
||||
const lockConflict = ref(false)
|
||||
const currentProjectId = ref('')
|
||||
const currentProjectName = ref('')
|
||||
const conflictProjectList = ref<ProjectMeta[]>([])
|
||||
const openedProjectIds = ref<string[]>([])
|
||||
const closeCountdown = ref(10)
|
||||
const isNewProjectRequest = ref(false)
|
||||
const isForceHomeRequest = ref(false)
|
||||
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
let releaseLock: (() => void) | null = null
|
||||
let stopProjectDeletedListener: (() => void) | null = null
|
||||
let stopResetAllListener: (() => void) | null = null
|
||||
let isHandlingDeletedProject = false
|
||||
let isHandlingGlobalReset = false
|
||||
|
||||
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
|
||||
|
||||
const handleImportComplete = () => {
|
||||
tabStore.hasCompletedSetup = true
|
||||
}
|
||||
|
||||
const initCurrentProjectLock = () => {
|
||||
if (releaseLock) return
|
||||
const projectId = String(currentProjectId.value || '').trim()
|
||||
if (!projectId || projectId === QUICK_PROJECT_ID) {
|
||||
lockConflict.value = false
|
||||
return
|
||||
}
|
||||
const lock = initProjectSessionLock({
|
||||
projectId,
|
||||
onConflict: (next) => {
|
||||
lockConflict.value = next
|
||||
if (next) {
|
||||
refreshConflictProjectList()
|
||||
startCloseCountdown()
|
||||
} else {
|
||||
clearCloseCountdown()
|
||||
}
|
||||
}
|
||||
})
|
||||
releaseLock = lock.release
|
||||
}
|
||||
|
||||
const refreshConflictProjectList = () => {
|
||||
void (async () => {
|
||||
const projects = listProjects()
|
||||
const enriched = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
try {
|
||||
const kvStoreInstance = localforage.createInstance({
|
||||
name: getProjectDbName(project.id),
|
||||
storeName: 'pinia-kv'
|
||||
})
|
||||
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
|
||||
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
|
||||
const projectInfo = entries?.['xm-base-info-v1']
|
||||
const projectName =
|
||||
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
|
||||
? projectInfo.projectName.trim()
|
||||
: ''
|
||||
return {
|
||||
...project,
|
||||
name: projectName || project.name
|
||||
}
|
||||
} catch {
|
||||
return project
|
||||
}
|
||||
})
|
||||
)
|
||||
conflictProjectList.value = enriched
|
||||
const hit = enriched.find(item => item.id === currentProjectId.value)
|
||||
currentProjectName.value = hit?.name || currentProjectId.value
|
||||
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(enriched.map(item => item.id)))
|
||||
})()
|
||||
}
|
||||
|
||||
const clearCloseCountdown = () => {
|
||||
if (!closeCountdownTimer) return
|
||||
clearInterval(closeCountdownTimer)
|
||||
closeCountdownTimer = null
|
||||
}
|
||||
|
||||
const startCloseCountdown = () => {
|
||||
clearCloseCountdown()
|
||||
closeCountdown.value = 10
|
||||
closeCountdownTimer = setInterval(() => {
|
||||
closeCountdown.value -= 1
|
||||
if (closeCountdown.value <= 0) {
|
||||
clearCloseCountdown()
|
||||
backToHome()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const isConflictProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
|
||||
|
||||
const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean }) => {
|
||||
if (isConflictProjectOpen(projectId)) return
|
||||
const href = buildProjectUrl(projectId, options)
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
const createProjectAndOpen = () => {
|
||||
refreshConflictProjectList()
|
||||
openProjectInNewTab(DEFAULT_PROJECT_ID, { newProject: true })
|
||||
}
|
||||
|
||||
const backToHome = () => {
|
||||
window.location.href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
|
||||
}
|
||||
|
||||
const syncRouteRequestFlags = () => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
isNewProjectRequest.value = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
||||
isForceHomeRequest.value = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1'
|
||||
} catch {
|
||||
isNewProjectRequest.value = false
|
||||
isForceHomeRequest.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatProjectEditedTime = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const pad = (num: number) => String(num).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
const handleReleaseProjectLock = () => {
|
||||
if (!releaseLock) return
|
||||
releaseLock()
|
||||
releaseLock = null
|
||||
lockConflict.value = false
|
||||
}
|
||||
|
||||
const handleProjectDeleted = (deletedProjectId: string) => {
|
||||
if (String(deletedProjectId || '').trim() !== currentProjectId.value) return
|
||||
if (isHandlingDeletedProject) return
|
||||
isHandlingDeletedProject = true
|
||||
handleReleaseProjectLock()
|
||||
tabStore.resetTabs()
|
||||
tabStore.hasCompletedSetup = false
|
||||
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
|
||||
try {
|
||||
window.close()
|
||||
} catch {
|
||||
// ignore and fallback to redirect
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
window.location.href = href
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (isHandlingGlobalReset) return
|
||||
isHandlingGlobalReset = true
|
||||
handleReleaseProjectLock()
|
||||
tabStore.resetTabs()
|
||||
tabStore.hasCompletedSetup = false
|
||||
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
|
||||
try {
|
||||
window.close()
|
||||
} catch {
|
||||
// ignore and fallback to redirect
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
window.location.href = href
|
||||
}, 120)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentProjectId.value = ensureProjectIdInUrl()
|
||||
syncRouteRequestFlags()
|
||||
refreshConflictProjectList()
|
||||
if (!isForceHomeRequest.value && currentProjectId.value !== QUICK_PROJECT_ID && currentProjectId.value !== DEFAULT_PROJECT_ID) {
|
||||
initCurrentProjectLock()
|
||||
}
|
||||
|
||||
window.addEventListener('home-import-selected', handleImportComplete)
|
||||
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
||||
stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted)
|
||||
stopResetAllListener = listenResetAll(handleResetAll)
|
||||
waitForHydration('tabs').then(() => {
|
||||
if (isForceHomeRequest.value) {
|
||||
tabStore.resetTabs()
|
||||
tabStore.hasCompletedSetup = false
|
||||
}
|
||||
if (!tabStore.hasCompletedSetup && !isNewProjectRequest.value && !isForceHomeRequest.value) {
|
||||
const hasProjects = listProjects().length > 0
|
||||
if (hasProjects && currentProjectId.value !== DEFAULT_PROJECT_ID) {
|
||||
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
||||
tabStore.hasCompletedSetup = true
|
||||
} else {
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
title: t('home.cards.projectBudget'),
|
||||
componentName: 'ProjectCalcView'
|
||||
})
|
||||
tabStore.hasCompletedSetup = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tabStore.hasCompletedSetup && Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
||||
const activeId = typeof tabStore.activeTabId === 'string' ? tabStore.activeTabId : ''
|
||||
const hasActive = Boolean(activeId) && tabStore.tabs.some(tab => tab.id === activeId)
|
||||
if (!hasActive) {
|
||||
tabStore.activeTabId = tabStore.tabs[0]?.id
|
||||
}
|
||||
}
|
||||
if (!releaseLock) {
|
||||
lockConflict.value = false
|
||||
clearCloseCountdown()
|
||||
}
|
||||
isReady.value = true
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearCloseCountdown()
|
||||
window.removeEventListener('home-import-selected', handleImportComplete)
|
||||
window.removeEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
||||
if (stopProjectDeletedListener) {
|
||||
stopProjectDeletedListener()
|
||||
stopProjectDeletedListener = null
|
||||
}
|
||||
if (stopResetAllListener) {
|
||||
stopResetAllListener()
|
||||
stopResetAllListener = null
|
||||
}
|
||||
if (releaseLock) {
|
||||
releaseLock()
|
||||
releaseLock = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="isReady">
|
||||
<div
|
||||
v-if="lockConflict"
|
||||
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ t('app.projectConflict.title') }}</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||
{{ t('app.projectConflict.desc', { name: currentProjectName }) }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
{{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
|
||||
</p>
|
||||
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||
<button
|
||||
v-for="project in conflictProjectList"
|
||||
:key="project.id"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md border border-transparent bg-white px-3 py-2 text-left text-sm transition"
|
||||
:class="isConflictProjectOpen(project.id) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-200 hover:bg-slate-100'"
|
||||
:disabled="isConflictProjectOpen(project.id)"
|
||||
@click="openProjectInNewTab(project.id)"
|
||||
>
|
||||
<span class="font-medium text-slate-700">
|
||||
{{ project.name }}
|
||||
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">{{ t('app.projectConflict.opened') }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">{{ t('app.projectConflict.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100"
|
||||
@click="createProjectAndOpen"
|
||||
>
|
||||
{{ t('app.projectConflict.createAndOpen') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
:class="'cursor-pointer hover:bg-slate-100'"
|
||||
@click="backToHome"
|
||||
>
|
||||
{{ t('app.projectConflict.openDefault') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<HomeEntryView v-if="showHomeEntry" />
|
||||
<Tab v-else />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Languages } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DEFAULT_LOCALE, setAppLocale, type AppLocale } from '@/i18n'
|
||||
import {
|
||||
DISCLAIMER_ENTRY_QUERY_KEY,
|
||||
DISCLAIMER_RETURN_URL_QUERY_KEY,
|
||||
hasAcceptedRestrictedDisclaimer,
|
||||
persistRestrictedDisclaimerAcceptance,
|
||||
readRestrictedEntryCodeFromUrl
|
||||
} from '@/lib/workspace'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const accepted = ref(false)
|
||||
|
||||
const parsedParams = (() => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const returnUrl = String(url.searchParams.get(DISCLAIMER_RETURN_URL_QUERY_KEY) || '').trim()
|
||||
const parsedReturnUrl = returnUrl ? new URL(returnUrl, window.location.href) : null
|
||||
const entryFromReturnUrl = String(parsedReturnUrl?.searchParams.get(DISCLAIMER_ENTRY_QUERY_KEY) || '').trim()
|
||||
return {
|
||||
returnUrl,
|
||||
entry: entryFromReturnUrl || readRestrictedEntryCodeFromUrl(parsedReturnUrl)
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
returnUrl: '',
|
||||
entry: readRestrictedEntryCodeFromUrl()
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
const hasRestrictedEntry = computed(() => Boolean(parsedParams.entry))
|
||||
const backHref = computed(() => parsedParams.returnUrl || './?projectId=default')
|
||||
|
||||
const sections = computed(() => [
|
||||
{
|
||||
title: t('disclaimerPage.sections.standardBasisTitle'),
|
||||
paragraphs: [
|
||||
t('disclaimerPage.sections.standardBasisP1'),
|
||||
t('disclaimerPage.sections.standardBasisP2'),
|
||||
t('disclaimerPage.sections.standardBasisP3')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('disclaimerPage.sections.referenceOnlyTitle'),
|
||||
paragraphs: [t('disclaimerPage.sections.referenceOnlyP1')]
|
||||
},
|
||||
{
|
||||
title: t('disclaimerPage.sections.accuracyTitle'),
|
||||
paragraphs: [t('disclaimerPage.sections.accuracyP1')]
|
||||
},
|
||||
{
|
||||
title: t('disclaimerPage.sections.riskTitle'),
|
||||
paragraphs: [t('disclaimerPage.sections.riskP1')]
|
||||
},
|
||||
{
|
||||
title: t('disclaimerPage.sections.liabilityTitle'),
|
||||
paragraphs: [t('disclaimerPage.sections.liabilityP1'), t('disclaimerPage.sections.liabilityP2')]
|
||||
},
|
||||
{
|
||||
title: t('disclaimerPage.sections.interruptionTitle'),
|
||||
paragraphs: [t('disclaimerPage.sections.interruptionP1')]
|
||||
},
|
||||
{
|
||||
title: t('disclaimerPage.sections.externalTitle'),
|
||||
paragraphs: [t('disclaimerPage.sections.externalP1')]
|
||||
},
|
||||
{
|
||||
title: t('disclaimerPage.sections.lawTitle'),
|
||||
paragraphs: [t('disclaimerPage.sections.lawP1')]
|
||||
}
|
||||
])
|
||||
|
||||
const toggleLocale = () => {
|
||||
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
|
||||
setAppLocale(next as AppLocale)
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!accepted.value) return
|
||||
if (parsedParams.entry) {
|
||||
persistRestrictedDisclaimerAcceptance(parsedParams.entry)
|
||||
}
|
||||
window.location.href = backHref.value
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const lang = locale.value || DEFAULT_LOCALE
|
||||
document.documentElement.lang = lang
|
||||
document.title = t('disclaimerPage.documentTitle')
|
||||
})
|
||||
|
||||
accepted.value = parsedParams.entry ? hasAcceptedRestrictedDisclaimer(parsedParams.entry) : false
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-[linear-gradient(180deg,#f7fbff_0%,#eef4f8_44%,#e4edf3_100%)] text-slate-900">
|
||||
<div class="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10">
|
||||
<div class="mb-4 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 cursor-pointer gap-2 rounded-full border-slate-300/80 bg-white/85 px-4 text-xs text-slate-700 shadow-sm backdrop-blur"
|
||||
@click="toggleLocale"
|
||||
>
|
||||
<Languages class="h-3.5 w-3.5" />
|
||||
<span>{{ locale === 'en-US' ? 'EN' : '中' }}</span>
|
||||
<span class="hidden sm:inline">{{ t('disclaimerPage.actions.switchLocale') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section class="overflow-hidden rounded-[28px] border border-slate-200/70 bg-white/85 shadow-[0_24px_80px_rgba(15,23,42,0.10)] backdrop-blur">
|
||||
<div class="relative overflow-hidden px-5 py-6 sm:px-8 sm:py-8 lg:px-10">
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 h-40 bg-[radial-gradient(circle_at_top,rgba(14,116,144,0.18),transparent_68%)]" />
|
||||
<div class="relative">
|
||||
<p class="text-xs font-bold tracking-[0.24em] text-teal-700">{{ t('disclaimerPage.eyebrow') }}</p>
|
||||
<h1 class="mt-3 max-w-4xl text-2xl font-semibold leading-tight tracking-tight sm:text-3xl">
|
||||
{{ t('disclaimerPage.pageTitle') }}
|
||||
</h1>
|
||||
<p class="mt-4 text-sm text-slate-500">
|
||||
<span>{{ t('disclaimerPage.lastUpdatedLabel') }}</span>
|
||||
<span>{{ t('disclaimerPage.lastUpdatedValue') }}</span>
|
||||
</p>
|
||||
<p class="mt-5 max-w-4xl text-sm leading-7 text-slate-600 sm:text-[15px]">
|
||||
{{ t('disclaimerPage.leadText') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[linear-gradient(90deg,rgba(148,163,184,0),rgba(148,163,184,0.7),rgba(148,163,184,0))]" />
|
||||
|
||||
<div class="px-5 py-6 sm:px-8 lg:px-10 lg:py-8">
|
||||
<section
|
||||
v-for="section in sections"
|
||||
:key="section.title"
|
||||
class="mb-6 border-b border-slate-100 pb-6 last:mb-0 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<h2 class="text-lg font-semibold leading-7 text-slate-900 sm:text-[20px]">{{ section.title }}</h2>
|
||||
<p
|
||||
v-for="paragraph in section.paragraphs"
|
||||
:key="paragraph"
|
||||
class="mt-3 text-sm leading-7 text-slate-600 sm:text-[15px]"
|
||||
>
|
||||
{{ paragraph }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="hasRestrictedEntry"
|
||||
class="mt-6 rounded-[24px] border border-teal-200/70 bg-[linear-gradient(135deg,rgba(15,118,110,0.08),rgba(14,165,233,0.08))] p-5 sm:p-6"
|
||||
>
|
||||
<h2 class="text-base font-semibold text-slate-900">{{ t('disclaimerPage.confirm.title') }}</h2>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-600">{{ t('disclaimerPage.confirm.desc1') }}</p>
|
||||
<p class="mt-1 text-sm leading-7 text-slate-600">{{ t('disclaimerPage.confirm.desc2') }}</p>
|
||||
|
||||
<label class="mt-4 flex cursor-pointer items-start gap-3 rounded-2xl border border-slate-200/80 bg-white/80 px-4 py-3 text-sm leading-6 text-slate-700">
|
||||
<input v-model="accepted" type="checkbox" class="mt-1 h-4 w-4 accent-teal-700" />
|
||||
<span>{{ t('disclaimerPage.confirm.checkbox') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center justify-center rounded-full bg-teal-700 px-5 text-sm font-semibold text-white shadow-[0_12px_24px_rgba(15,118,110,0.18)] transition hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-55 disabled:shadow-none"
|
||||
:disabled="!accepted"
|
||||
@click="handleContinue"
|
||||
>
|
||||
{{ t('disclaimerPage.confirm.continue') }}
|
||||
</button>
|
||||
<a
|
||||
:href="backHref"
|
||||
class="inline-flex min-h-11 items-center justify-center rounded-full border border-slate-300 bg-white px-5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{{ t('disclaimerPage.actions.back') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs leading-5 text-slate-500">{{ t('disclaimerPage.confirm.hint') }}</p>
|
||||
</section>
|
||||
|
||||
<div v-else class="mt-6 flex">
|
||||
<a
|
||||
:href="backHref"
|
||||
class="inline-flex min-h-11 items-center justify-center rounded-full border border-slate-300 bg-white px-5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{{ t('disclaimerPage.actions.back') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
|
||||
import { getAdditionalWorkListEntries } from '@/sql'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
contractName?: string
|
||||
}>()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
|
||||
const additionalWorkNames = computed(() =>
|
||||
getAdditionalWorkListEntries(locale.value).map(item => ({ id: item?.id, name: item?.name }))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HtFeeMethodGrid
|
||||
:title="t('htFee.additionalTitle')"
|
||||
:storageKey="STORAGE_KEY"
|
||||
:contract-id="props.contractId"
|
||||
:contract-name="props.contractName"
|
||||
:fixed-names="additionalWorkNames"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
|
||||
interface HtBaseInfoState {
|
||||
quality: string
|
||||
duration: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const DEFAULT_QUALITY = t('htBaseInfo.defaultQuality')
|
||||
const DEFAULT_DURATION = ''
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
}>()
|
||||
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const storageKey = () => `ht-base-info-${props.contractId}`
|
||||
const quality = ref(DEFAULT_QUALITY)
|
||||
const duration = ref('')
|
||||
const lastSavedSnapshot = ref('')
|
||||
|
||||
const saveForm = (force = false) => {
|
||||
const payload: HtBaseInfoState = {
|
||||
quality: quality.value,
|
||||
duration: duration.value
|
||||
}
|
||||
const snapshot = JSON.stringify(payload)
|
||||
if (!force && snapshot === lastSavedSnapshot.value) return
|
||||
zxFwPricingStore.setKeyState(storageKey(), payload)
|
||||
lastSavedSnapshot.value = snapshot
|
||||
}
|
||||
|
||||
const loadForm = async () => {
|
||||
const data = await zxFwPricingStore.loadKeyState<HtBaseInfoState>(storageKey())
|
||||
const hasStoredValue = Boolean(
|
||||
data && (Object.prototype.hasOwnProperty.call(data, 'quality') || Object.prototype.hasOwnProperty.call(data, 'duration'))
|
||||
)
|
||||
quality.value = typeof data?.quality === 'string' && data.quality ? data.quality : DEFAULT_QUALITY
|
||||
duration.value = typeof data?.duration === 'string' ? data.duration : (hasStoredValue ? '' : DEFAULT_DURATION)
|
||||
lastSavedSnapshot.value = JSON.stringify({ quality: quality.value, duration: duration.value })
|
||||
if (!hasStoredValue) saveForm(true)
|
||||
}
|
||||
|
||||
watch([quality, duration], () => {
|
||||
saveForm()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadForm()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
saveForm(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card p-5">
|
||||
<div class="mb-4 border-b pb-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ t('htBaseInfo.title') }}</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5">
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.qualityLabel') }}</div>
|
||||
<textarea
|
||||
v-model="quality"
|
||||
rows="3"
|
||||
:placeholder="t('htBaseInfo.qualityPlaceholder')"
|
||||
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.durationLabel') }}</div>
|
||||
<textarea
|
||||
v-model="duration"
|
||||
rows="3"
|
||||
:placeholder="t('htBaseInfo.durationPlaceholder')"
|
||||
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
|
||||
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
projectInfoKey?: string
|
||||
parentStorageKey?: string
|
||||
}>()
|
||||
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
type ServiceItem = {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
|
||||
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const projectIndustry = ref('')
|
||||
const kvStore = useKvStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
console.error('loadProjectIndustry failed:', error)
|
||||
projectIndustry.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const filteredServiceDict = computed<Record<string, ServiceItem>>(() => {
|
||||
const industry = projectIndustry.value
|
||||
if (!industry) return {}
|
||||
const entries = getServiceDictEntries()
|
||||
.filter(({ item }) => isIndustryEnabledByType(item, getIndustryTypeValue(industry)))
|
||||
.map(({ id, item }) => [id, item as ServiceItem] as const)
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<XmFactorGrid
|
||||
:title="t('htFactors.consultCategoryTitle')"
|
||||
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
|
||||
:parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'"
|
||||
:dict="filteredServiceDict"
|
||||
:disable-budget-edit-when-standard-null="true"
|
||||
:exclude-notshow-by-zxflxs="true"
|
||||
:init-budget-value-from-standard="true"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,525 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowDataUpdatedEvent } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { additionalWorkList, reserveList } from '@/sql'
|
||||
|
||||
type SummaryRowType = 'service' | 'additional' | 'reserve' | 'total'
|
||||
|
||||
interface SummaryRow {
|
||||
id: string
|
||||
rowType: SummaryRowType
|
||||
code: string | { richText?: Array<{ text?: string; font?: { italic?: boolean; vertAlign?: string } }> }
|
||||
name: string
|
||||
investScale: number | null
|
||||
landScale: number | null
|
||||
workload: number | null
|
||||
hourly: number | null
|
||||
subtotal: number | null
|
||||
finalFee: number | null
|
||||
}
|
||||
|
||||
interface RateMethodStateLike {
|
||||
rate?: unknown
|
||||
budgetFee?: unknown
|
||||
}
|
||||
interface HourlyMethodRowLike {
|
||||
serviceBudget?: unknown
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
}
|
||||
interface HourlyMethodStateLike {
|
||||
detailRows?: HourlyMethodRowLike[]
|
||||
}
|
||||
interface QuantityMethodRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
interface QuantityMethodStateLike {
|
||||
detailRows?: QuantityMethodRowLike[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const gridApi = shallowRef<GridApi<SummaryRow> | null>(null)
|
||||
const rowData = ref<SummaryRow[]>([])
|
||||
const explanationText = ref('')
|
||||
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const sum3 = (values: Array<number | null | undefined>) => {
|
||||
const valid = values.filter((v): v is number => toFiniteNumberOrNull(v) != null)
|
||||
if (valid.length === 0) return null
|
||||
return roundTo(valid.reduce((a, b) => a + b, 0), 3)
|
||||
}
|
||||
|
||||
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
const rowBudget = toFiniteNumberOrNull(row?.serviceBudget)
|
||||
if (rowBudget != null) {
|
||||
total += rowBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteNumberOrNull(row?.personnelCount)
|
||||
const workday = toFiniteNumberOrNull(row?.workdayCount)
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteNumberOrNull(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteNumberOrNull(row?.quantity)
|
||||
const unitPrice = toFiniteNumberOrNull(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const loadHtMethodSummaryByRow = async (mainStorageKey: string, rowId: string): Promise<{
|
||||
subtotal: number | null
|
||||
m0: { coe: string; fee: number } | null
|
||||
m4: { fee: number } | null
|
||||
m5: { fee: number } | null
|
||||
}> => {
|
||||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||||
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||
])
|
||||
const rateFee = toFiniteNumberOrNull(rateState?.budgetFee)
|
||||
const rateValue = toFiniteNumberOrNull(rateState?.rate)
|
||||
const hourlyFee = sumHourlyMethodFee(hourlyState)
|
||||
const quantityFee = sumQuantityMethodFee(quantityState)
|
||||
const subtotal = sum3([rateFee, hourlyFee, quantityFee])
|
||||
return {
|
||||
subtotal,
|
||||
m0: rateFee != null
|
||||
? {
|
||||
coe: rateValue == null ? '--' : String(rateValue),
|
||||
fee: roundTo(rateFee, 2)
|
||||
}
|
||||
: null,
|
||||
m4: hourlyFee != null ? { fee: roundTo(hourlyFee, 2) } : null,
|
||||
m5: quantityFee != null ? { fee: roundTo(quantityFee, 2) } : null
|
||||
}
|
||||
}
|
||||
|
||||
const buildFeeRows = async (
|
||||
rowType: 'additional' | 'reserve',
|
||||
list: Array<{ id: string | number; name: string; code: unknown }>
|
||||
): Promise<{ rows: SummaryRow[]; explainLines: string[] }> => {
|
||||
const mainStorageKey = `htExtraFee-${props.contractId}-${rowType === 'additional' ? 'additional-work' : 'reserve'}`
|
||||
await zxFwPricingStore.loadHtFeeMainState(mainStorageKey)
|
||||
const tuples = await Promise.all(
|
||||
list.map(async item => {
|
||||
const summary = await loadHtMethodSummaryByRow(mainStorageKey, String(item.id))
|
||||
const lineParts: string[] = []
|
||||
if (summary.m0) {
|
||||
lineParts.push(t('htSummary.explainByRate', { rate: summary.m0.coe, fee: summary.m0.fee }))
|
||||
}
|
||||
if (summary.m4) {
|
||||
lineParts.push(t('htSummary.explainByHourly', { fee: summary.m4.fee }))
|
||||
}
|
||||
if (summary.m5) {
|
||||
lineParts.push(t('htSummary.explainByQuantity', { fee: summary.m5.fee }))
|
||||
}
|
||||
const linePrefix = rowType === 'additional' ? t('htSummary.additionalPrefix') : t('htSummary.reservePrefix')
|
||||
const explainLine = lineParts.length > 0 ? `${linePrefix}-${item.name}:${lineParts.join(';')}` : ''
|
||||
const row: SummaryRow = {
|
||||
id: `${rowType}-${item.id}`,
|
||||
rowType,
|
||||
code: item.code as SummaryRow['code'],
|
||||
name: item.name,
|
||||
investScale: null,
|
||||
landScale: null,
|
||||
workload: null,
|
||||
hourly: null,
|
||||
subtotal: summary.subtotal,
|
||||
finalFee: summary.subtotal
|
||||
}
|
||||
return { row, explainLine }
|
||||
})
|
||||
)
|
||||
const rows = tuples.map(item => item.row).filter(row => row.subtotal != null)
|
||||
const explainLines = tuples
|
||||
.filter(item => item.row.subtotal != null && item.explainLine)
|
||||
.map(item => item.explainLine)
|
||||
return { rows, explainLines }
|
||||
}
|
||||
|
||||
const buildServiceRows = (): SummaryRow[] => {
|
||||
const contractState = zxFwPricingStore.getContractState(props.contractId)
|
||||
const selectedSet = new Set((contractState?.selectedIds || []).map(id => String(id)))
|
||||
const rows = Array.isArray(contractState?.detailRows) ? contractState!.detailRows : []
|
||||
return rows
|
||||
.filter(row => String(row.id) !== 'fixed-budget-c' && selectedSet.has(String(row.id)))
|
||||
.map(row => ({
|
||||
id: `service-${row.id}`,
|
||||
rowType: 'service' as const,
|
||||
code: row.code || '',
|
||||
name: row.name || '',
|
||||
investScale: toFiniteNumberOrNull(row.investScale),
|
||||
landScale: toFiniteNumberOrNull(row.landScale),
|
||||
workload: toFiniteNumberOrNull(row.workload),
|
||||
hourly: toFiniteNumberOrNull(row.hourly),
|
||||
subtotal: toFiniteNumberOrNull(row.subtotal),
|
||||
finalFee: toFiniteNumberOrNull((row as { finalFee?: unknown }).finalFee) ?? toFiniteNumberOrNull(row.subtotal)
|
||||
}))
|
||||
}
|
||||
|
||||
const reloadRows = async () => {
|
||||
await zxFwPricingStore.loadContract(props.contractId)
|
||||
const [additionalResult, reserveResult] = await Promise.all([
|
||||
buildFeeRows(
|
||||
'additional',
|
||||
additionalWorkList.map(item => ({ id: item.id, name: item.name, code: item.code }))
|
||||
),
|
||||
buildFeeRows(
|
||||
'reserve',
|
||||
reserveList.map(item => ({ id: item.id, name: item.name, code: item.code }))
|
||||
)
|
||||
])
|
||||
rowData.value = [...buildServiceRows(), ...additionalResult.rows, ...reserveResult.rows]
|
||||
const lines = [...additionalResult.explainLines, ...reserveResult.explainLines]
|
||||
explanationText.value = lines.join('\n')
|
||||
}
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
reloadTimer = setTimeout(() => {
|
||||
void reloadRows()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const refreshSignature = computed(() => {
|
||||
const additionalKey = `htExtraFee-${props.contractId}-additional-work`
|
||||
const reserveKey = `htExtraFee-${props.contractId}-reserve`
|
||||
return JSON.stringify({
|
||||
contract: zxFwPricingStore.contracts[props.contractId] || null,
|
||||
addMain: zxFwPricingStore.htFeeMainStates[additionalKey] || null,
|
||||
reserveMain: zxFwPricingStore.htFeeMainStates[reserveKey] || null,
|
||||
addMethods: zxFwPricingStore.htFeeMethodStates[additionalKey] || null,
|
||||
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveKey] || null
|
||||
})
|
||||
})
|
||||
|
||||
const totalRow = computed<SummaryRow>(() => {
|
||||
const sumField = (pick: (row: SummaryRow) => number | null | undefined) =>
|
||||
sum3(rowData.value.map(pick))
|
||||
return {
|
||||
id: 'summary-total-row',
|
||||
rowType: 'total',
|
||||
code: '',
|
||||
name: t('htSummary.total'),
|
||||
investScale: sumField(row => row.investScale),
|
||||
landScale: sumField(row => row.landScale),
|
||||
workload: sumField(row => row.workload),
|
||||
hourly: sumField(row => row.hourly),
|
||||
subtotal: null,
|
||||
finalFee: sumField(row => row.finalFee)
|
||||
}
|
||||
})
|
||||
|
||||
const RichCodeRenderer = defineComponent({
|
||||
name: 'RichCodeRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<SummaryRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (props.params.data?.rowType === 'total') {
|
||||
return h('span', props.params.data.name || t('htSummary.total'))
|
||||
}
|
||||
const value = props.params.value as SummaryRow['code']
|
||||
if (!value || typeof value === 'string') {
|
||||
return h('span', value || '')
|
||||
}
|
||||
const runs = Array.isArray(value.richText) ? value.richText : []
|
||||
return h(
|
||||
'span',
|
||||
{ class: 'inline-flex items-baseline gap-[1px]' },
|
||||
runs.map((run, idx) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
key: `${idx}-${run.text || ''}`,
|
||||
style: {
|
||||
fontStyle: run?.font?.italic ? 'italic' : 'normal',
|
||||
verticalAlign: run?.font?.vertAlign === 'subscript' ? 'sub' : run?.font?.vertAlign === 'superscript' ? 'super' : 'baseline',
|
||||
fontSize: run?.font?.vertAlign ? '0.85em' : '1em'
|
||||
}
|
||||
},
|
||||
run?.text || ''
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const columnDefs: ColDef<SummaryRow>[] = [
|
||||
{
|
||||
headerName: t('htSummary.columns.code'),
|
||||
field: 'code',
|
||||
minWidth: 90,
|
||||
maxWidth: 140,
|
||||
colSpan: params => (params.data?.rowType === 'total' ? 2 : 1),
|
||||
cellClassRules: {
|
||||
'ag-summary-label-cell': params => params.data?.rowType === 'total'
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.data?.rowType === 'total') return params.data.name || t('htSummary.total')
|
||||
return typeof params.value === 'string' ? params.value : ''
|
||||
},
|
||||
cellRenderer: RichCodeRenderer
|
||||
},
|
||||
{
|
||||
headerName: t('htSummary.columns.name'),
|
||||
field: 'name',
|
||||
minWidth: 220,
|
||||
flex: 2,
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { 'line-height': 1.6 }
|
||||
},
|
||||
{
|
||||
headerName: t('htSummary.columns.investScale'),
|
||||
field: 'investScale',
|
||||
minWidth: 120,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
colSpan: params => {
|
||||
if (!params.data) return 1
|
||||
if (params.data.rowType === 'total') return 5
|
||||
if (params.data.rowType === 'additional' || params.data.rowType === 'reserve') return 5
|
||||
return 1
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.data?.rowType === 'total') return ''
|
||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htSummary.columns.landScale'),
|
||||
field: 'landScale',
|
||||
minWidth: 120,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.data?.rowType === 'total') return ''
|
||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htSummary.columns.workload'),
|
||||
field: 'workload',
|
||||
minWidth: 110,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.data?.rowType === 'total') return ''
|
||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htSummary.columns.hourly'),
|
||||
field: 'hourly',
|
||||
minWidth: 110,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.data?.rowType === 'total') return ''
|
||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htSummary.columns.subtotal'),
|
||||
field: 'subtotal',
|
||||
minWidth: 120,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
|
||||
},
|
||||
{
|
||||
headerName: t('htSummary.columns.finalFee'),
|
||||
field: 'finalFee',
|
||||
minWidth: 120,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const summaryGridOptions: GridOptions<SummaryRow> = {
|
||||
...gridOptions,
|
||||
treeData: false,
|
||||
getDataPath: undefined,
|
||||
domLayout: 'autoHeight',
|
||||
suppressNoRowsOverlay: true,
|
||||
rowSelection: {
|
||||
mode: 'singleRow',
|
||||
checkboxes: false,
|
||||
enableClickSelection: false
|
||||
},
|
||||
getRowId: params => params.data.id,
|
||||
getRowClass: params => (params.data?.rowType === 'additional' || params.data?.rowType === 'reserve' ? 'ht-summary-fee-row' : '')
|
||||
}
|
||||
|
||||
const onGridReady = (event: GridReadyEvent<SummaryRow>) => {
|
||||
gridApi.value = event.api
|
||||
void syncAutoRowHeights()
|
||||
}
|
||||
|
||||
const isGridApiAlive = (api: GridApi<SummaryRow> | null | undefined): api is GridApi<SummaryRow> =>
|
||||
Boolean(api && !api.isDestroyed?.())
|
||||
|
||||
const syncAutoRowHeights = async () => {
|
||||
await nextTick()
|
||||
const api = gridApi.value
|
||||
if (!isGridApiAlive(api)) return
|
||||
api.onRowHeightChanged()
|
||||
api.refreshCells({ force: true })
|
||||
}
|
||||
|
||||
const onFirstDataRendered = (_event: FirstDataRenderedEvent<SummaryRow>) => {
|
||||
void syncAutoRowHeights()
|
||||
}
|
||||
|
||||
const onRowDataUpdated = (_event: RowDataUpdatedEvent<SummaryRow>) => {
|
||||
void syncAutoRowHeights()
|
||||
}
|
||||
|
||||
watch(refreshSignature, (next, prev) => {
|
||||
if (next === prev) return
|
||||
scheduleReload()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => rowData.value.length,
|
||||
() => {
|
||||
void syncAutoRowHeights()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void reloadRows()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void reloadRows()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (isGridApiAlive(gridApi.value)) {
|
||||
gridApi.value.stopEditing()
|
||||
}
|
||||
gridApi.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="rounded-lg border bg-card xmMx flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b px-3 py-2">
|
||||
<h3 class="text-xs font-semibold text-foreground leading-none">
|
||||
{{ t('htSummary.title') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="ag-theme-quartz w-full">
|
||||
<AgGridVue
|
||||
:style="{ width: '100%' }"
|
||||
:rowData="rowData"
|
||||
:pinnedBottomRowData="[totalRow]"
|
||||
:columnDefs="gridColumnDefs"
|
||||
:gridOptions="summaryGridOptions"
|
||||
:theme="myTheme"
|
||||
:animateRows="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
@grid-ready="onGridReady"
|
||||
@first-data-rendered="onFirstDataRendered"
|
||||
@row-data-updated="onRowDataUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="rounded-lg border bg-card p-3 space-y-2">
|
||||
<div class="text-xs font-semibold text-foreground">{{ t('htSummary.remark') }}</div>
|
||||
<textarea
|
||||
:value="explanationText"
|
||||
rows="3"
|
||||
:placeholder="t('htSummary.placeholder')"
|
||||
readonly
|
||||
class="w-full rounded-md border bg-muted/40 px-3 py-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ag-layout-auto-height .ag-center-cols-viewport),
|
||||
:deep(.ag-layout-auto-height .ag-center-cols-container) {
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.ht-summary-fee-row .ag-cell) {
|
||||
background: color-mix(in oklab, var(--muted) 45%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
|
||||
interface RateMethodState {
|
||||
rate: number | null
|
||||
budgetFee?: number | null
|
||||
remark: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
storageKey: string
|
||||
contractId?: string
|
||||
htMainStorageKey?: string
|
||||
htRowId?: string
|
||||
htMethodType?: 'rate-fee'
|
||||
}>()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const rate = ref<number | null>(null)
|
||||
const remark = ref('')
|
||||
const rateInput = ref('')
|
||||
const baseValue = ref<number | null>(null)
|
||||
const lastSavedSnapshot = ref('')
|
||||
const useHtMethodState = computed(
|
||||
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
|
||||
)
|
||||
const contractIdText = computed(() => {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
return contractId
|
||||
})
|
||||
const isReserveFee = computed(() => {
|
||||
const mainKey = String(props.htMainStorageKey || '').trim()
|
||||
if (mainKey) return mainKey.endsWith('-reserve')
|
||||
return String(props.storageKey || '').includes('-reserve')
|
||||
})
|
||||
const contractStateSignature = computed(() => {
|
||||
const contractId = contractIdText.value
|
||||
if (!contractId) return ''
|
||||
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
|
||||
})
|
||||
const additionalWorkStateSignature = computed(() => {
|
||||
if (!isReserveFee.value) return ''
|
||||
const contractId = contractIdText.value
|
||||
if (!contractId) return ''
|
||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
|
||||
})
|
||||
const baseLabel = computed(() =>
|
||||
isReserveFee.value ? t('htFeeRate.reserveBaseLabel') : t('htFeeRate.baseLabel')
|
||||
)
|
||||
|
||||
const budgetFee = computed<number | null>(() => {
|
||||
if (baseValue.value == null || rate.value == null) return null
|
||||
return roundTo(baseValue.value * rate.value / 100, 2)
|
||||
})
|
||||
|
||||
const formatAmount = (value: number | null) =>
|
||||
value == null ? '' : formatThousandsFlexible(value, 3)
|
||||
|
||||
const ensureContractLoaded = async () => {
|
||||
const contractId = contractIdText.value
|
||||
if (!contractId) return
|
||||
try {
|
||||
await zxFwPricingStore.loadContract(contractId)
|
||||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||||
if (!isReserveFee.value) {
|
||||
baseValue.value = serviceBase == null ? null : roundTo(serviceBase, 3)
|
||||
return
|
||||
}
|
||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||
const additionalState = await zxFwPricingStore.loadHtFeeMainState<{
|
||||
rateFee?: unknown
|
||||
hourlyFee?: unknown
|
||||
quantityUnitPriceFee?: unknown
|
||||
}>(additionalStorageKey)
|
||||
const additionalTotal = (additionalState?.detailRows || []).reduce((total, row) => {
|
||||
const rateFee = Number(row?.rateFee)
|
||||
const hourlyFee = Number(row?.hourlyFee)
|
||||
const quantityFee = Number(row?.quantityUnitPriceFee)
|
||||
const safeRateFee = Number.isFinite(rateFee) ? rateFee : 0
|
||||
const safeHourlyFee = Number.isFinite(hourlyFee) ? hourlyFee : 0
|
||||
const safeQuantityFee = Number.isFinite(quantityFee) ? quantityFee : 0
|
||||
return total + safeRateFee + safeHourlyFee + safeQuantityFee
|
||||
}, 0)
|
||||
const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0
|
||||
const hasAny = (serviceBase != null) || additionalTotal !== 0
|
||||
baseValue.value = hasAny ? roundTo(serviceBaseSafe + additionalTotal, 3) : null
|
||||
} catch (error) {
|
||||
console.error('load contract for rate base failed:', error)
|
||||
baseValue.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const loadForm = async () => {
|
||||
try {
|
||||
const data = useHtMethodState.value
|
||||
? await zxFwPricingStore.loadHtFeeMethodState<RateMethodState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
: await zxFwPricingStore.loadKeyState<RateMethodState>(props.storageKey)
|
||||
rate.value = typeof data?.rate === 'number' ? data.rate : null
|
||||
remark.value = typeof data?.remark === 'string' ? data.remark : ''
|
||||
rateInput.value = rate.value == null ? '' : String(rate.value)
|
||||
const snapshot: RateMethodState = {
|
||||
rate: rate.value,
|
||||
budgetFee: budgetFee.value,
|
||||
remark: remark.value
|
||||
}
|
||||
lastSavedSnapshot.value = JSON.stringify(snapshot)
|
||||
} catch (error) {
|
||||
console.error('load rate form failed:', error)
|
||||
rate.value = null
|
||||
remark.value = ''
|
||||
rateInput.value = ''
|
||||
lastSavedSnapshot.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const saveForm = async (force = false) => {
|
||||
try {
|
||||
const payload: RateMethodState = {
|
||||
rate: rate.value,
|
||||
budgetFee: budgetFee.value,
|
||||
remark: remark.value
|
||||
}
|
||||
const snapshot = JSON.stringify(payload)
|
||||
if (!force && snapshot === lastSavedSnapshot.value) return
|
||||
if (useHtMethodState.value) {
|
||||
zxFwPricingStore.setHtFeeMethodState(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!,
|
||||
payload,
|
||||
{ force }
|
||||
)
|
||||
} else {
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload, { force })
|
||||
}
|
||||
lastSavedSnapshot.value = snapshot
|
||||
} catch (error) {
|
||||
console.error('save rate form failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const applyRateInput = () => {
|
||||
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 2 })
|
||||
rate.value = next
|
||||
rateInput.value = next == null ? '' : String(next)
|
||||
}
|
||||
|
||||
|
||||
|
||||
watch([rate, remark, budgetFee], () => {
|
||||
void saveForm()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
void loadForm()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => contractIdText.value,
|
||||
() => {
|
||||
void ensureContractLoaded()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.storageKey, props.htMainStorageKey, props.htRowId, props.htMethodType],
|
||||
() => {
|
||||
void ensureContractLoaded()
|
||||
}
|
||||
)
|
||||
|
||||
watch([contractStateSignature, additionalWorkStateSignature], ([nextContractSig, nextAdditionalSig], [prevContractSig, prevAdditionalSig]) => {
|
||||
if (nextContractSig === prevContractSig && nextAdditionalSig === prevAdditionalSig) return
|
||||
void ensureContractLoaded()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([ensureContractLoaded(), loadForm()])
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
await Promise.all([ensureContractLoaded(), loadForm()])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
void saveForm(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card p-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs text-muted-foreground">{{ baseLabel }}</div>
|
||||
<input type="text" :value="baseValue" readonly disabled tabindex="-1"
|
||||
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
|
||||
</label>
|
||||
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.rateLabel') }}</div>
|
||||
<input v-model="rateInput" type="text" inputmode="decimal" :placeholder="t('htFeeRate.ratePlaceholder')"
|
||||
class="rate-input h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
|
||||
@blur="applyRateInput"
|
||||
@keydown.enter.prevent="applyRateInput" />
|
||||
</label>
|
||||
|
||||
<label class="space-y-1.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.budgetFeeLabel') }}</div>
|
||||
<input type="text" :value="formatAmount(budgetFee)" readonly disabled tabindex="-1"
|
||||
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
|
||||
</label>
|
||||
|
||||
<label class="space-y-1.5 md:col-span-2">
|
||||
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.remarkLabel') }}</div>
|
||||
<textarea v-model="remark" rows="4" :placeholder="t('htFeeRate.remarkPlaceholder')"
|
||||
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.rate-input) {
|
||||
text-align: left !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
type MajorItem = {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
projectInfoKey?: string
|
||||
parentStorageKey?: string
|
||||
}>()
|
||||
|
||||
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const projectIndustry = ref('')
|
||||
const kvStore = useKvStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
console.error('loadProjectIndustry failed:', error)
|
||||
projectIndustry.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
|
||||
const industry = projectIndustry.value
|
||||
if (!industry) return {}
|
||||
const entries = getMajorDictEntries()
|
||||
.filter(({ id }) => isMajorIdInIndustryScope(id, industry))
|
||||
.map(({ id, item }) => [id, item as MajorItem] as const)
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<XmFactorGrid
|
||||
:title="t('htFactors.majorTitle')"
|
||||
:storage-key="`ht-major-factor-v1-${props.contractId}`"
|
||||
:parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'"
|
||||
:dict="filteredMajorDict"
|
||||
:disable-budget-edit-when-standard-null="true"
|
||||
:exclude-notshow-by-zxflxs="true"
|
||||
:init-budget-value-from-standard="true"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
|
||||
import { getReserveListEntries } from '@/sql'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
contractName?: string
|
||||
}>()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
|
||||
const reserveFeeNames = computed(() =>
|
||||
getReserveListEntries(locale.value).map(item => ({ name: item.name, id: item.id }))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HtFeeMethodGrid
|
||||
:title="t('htFee.reserveTitle')"
|
||||
:storageKey="STORAGE_KEY"
|
||||
:contract-id="props.contractId"
|
||||
:contract-name="props.contractName"
|
||||
:fixed-names="reserveFeeNames"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
|
||||
<TypeLine
|
||||
scene="ht-tab"
|
||||
:title="t('htCard.title', { name: contractName })"
|
||||
:subtitle="t('htCard.subtitle', { id: contractId })"
|
||||
:meta-text="t('htCard.metaBudget', { amount: formatBudgetAmount(contractBudget) })"
|
||||
:copy-text="contractId"
|
||||
:storage-key="typeLineStorageKey"
|
||||
default-category="base-info"
|
||||
:categories="xmCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TypeLine from '@/layout/typeLine.vue';
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
|
||||
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
||||
const props = defineProps<{
|
||||
contractId: string; // 合同ID(必传)
|
||||
contractName: string; // 合同名称(必传)
|
||||
projectInfoKey?: string; // 工作区基础信息键
|
||||
projectScaleKey?: string | null; // 工作区规模信息键
|
||||
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
|
||||
projectMajorFactorKey?: string; // 工作区工程专业系数键
|
||||
}>();
|
||||
const { t } = useI18n()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
|
||||
interface HtFeeMainRowLike {
|
||||
id?: unknown
|
||||
}
|
||||
|
||||
interface RateMethodStateLike {
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
interface HourlyMethodRowLike {
|
||||
serviceBudget?: unknown
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
}
|
||||
|
||||
interface HourlyMethodStateLike {
|
||||
detailRows?: HourlyMethodRowLike[]
|
||||
}
|
||||
|
||||
interface QuantityMethodRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
|
||||
interface QuantityMethodStateLike {
|
||||
detailRows?: QuantityMethodRowLike[]
|
||||
}
|
||||
|
||||
const contractBudget = ref<number | null>(null)
|
||||
const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId}`)
|
||||
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const formatBudgetAmount = (value: number | null | undefined) =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
? `${formatThousands(value, 2)} ${t('htCard.currencySuffix')}`
|
||||
: '--'
|
||||
|
||||
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
const serviceBudget = toFiniteNumber(row?.serviceBudget)
|
||||
if (serviceBudget != null) {
|
||||
total += serviceBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteNumber(row?.personnelCount)
|
||||
const workday = toFiniteNumber(row?.workdayCount)
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 2) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||||
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
|
||||
if (subtotal != null) return roundTo(subtotal, 2)
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteNumber(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteNumber(row?.quantity)
|
||||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 2) : null
|
||||
}
|
||||
|
||||
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
|
||||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||||
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||
])
|
||||
const parts = [
|
||||
toFiniteNumber(rateState?.budgetFee),
|
||||
sumHourlyMethodFee(hourlyState),
|
||||
sumQuantityMethodFee(quantityState)
|
||||
]
|
||||
const total = sumNullableNumbers(parts)
|
||||
return total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
|
||||
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
|
||||
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
||||
if (rowIds.length === 0) return null
|
||||
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
||||
const total = sumNullableNumbers(rowTotals)
|
||||
return total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const refreshContractBudget = async () => {
|
||||
await zxFwPricingStore.loadContract(props.contractId)
|
||||
const serviceFee = zxFwPricingStore.getBaseSubtotal(props.contractId)
|
||||
const [additionalFee, reserveFee] = await Promise.all([
|
||||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-additional-work`),
|
||||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
|
||||
])
|
||||
const parts = [serviceFee, additionalFee, reserveFee]
|
||||
const total = sumNullableNumbers(parts)
|
||||
contractBudget.value = total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const budgetRefreshSignature = computed(() => {
|
||||
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
|
||||
const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
|
||||
return JSON.stringify({
|
||||
contractState: zxFwPricingStore.contracts[props.contractId] || null,
|
||||
addMain: zxFwPricingStore.htFeeMainStates[additionalMainKey] || null,
|
||||
reserveMain: zxFwPricingStore.htFeeMainStates[reserveMainKey] || null,
|
||||
addMethods: zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null,
|
||||
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
|
||||
})
|
||||
})
|
||||
|
||||
const scheduleRefreshContractBudget = () => {
|
||||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||
budgetRefreshTimer = setTimeout(() => {
|
||||
void refreshContractBudget()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
|
||||
interface XmCategoryItem {
|
||||
key:
|
||||
| 'info'
|
||||
| 'base-info'
|
||||
| 'consult-category-factor'
|
||||
| 'major-factor'
|
||||
| 'work-grid'
|
||||
| 'contract'
|
||||
| 'additional-work-fee'
|
||||
| 'reserve-fee'
|
||||
| 'all';
|
||||
label: string;
|
||||
component: Component; // 标记为 Vue 组件类型
|
||||
}
|
||||
|
||||
// 3. 优化异步组件导入(添加加载失败兜底 + 类型标注)
|
||||
const htView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtInfoWithProps',
|
||||
setup() {
|
||||
const AsyncHtInfo = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/htInfo.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 htInfo 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtInfo, {
|
||||
contractId: props.contractId,
|
||||
projectScaleKey: props.projectScaleKey,
|
||||
projectInfoKey: props.projectInfoKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const zxfwView = markRaw(
|
||||
defineComponent({
|
||||
name: 'ZxFwWithProps',
|
||||
setup() {
|
||||
const AsyncZxFw = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/zxFw.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 zxFw 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncZxFw, {
|
||||
contractId: props.contractId,
|
||||
contractName: props.contractName,
|
||||
projectInfoKey: props.projectInfoKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const consultCategoryFactorView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtConsultCategoryFactorWithProps',
|
||||
setup() {
|
||||
const AsyncHtConsultCategoryFactor = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/HtConsultCategoryFactor.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtConsultCategoryFactor, {
|
||||
contractId: props.contractId,
|
||||
projectInfoKey: props.projectInfoKey,
|
||||
parentStorageKey: props.projectConsultCategoryFactorKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const majorFactorView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtMajorFactorWithProps',
|
||||
setup() {
|
||||
const AsyncHtMajorFactor = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/HtMajorFactor.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 HtMajorFactor 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtMajorFactor, {
|
||||
contractId: props.contractId,
|
||||
projectInfoKey: props.projectInfoKey,
|
||||
parentStorageKey: props.projectMajorFactorKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
|
||||
const htBaseInfoView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtBaseInfoWithProps',
|
||||
setup() {
|
||||
const AsyncHtBaseInfo = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/HtBaseInfo.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 HtBaseInfo 组件失败:', err)
|
||||
}
|
||||
})
|
||||
return () => h(AsyncHtBaseInfo, { contractId: props.contractId })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const additionalWorkFeeView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtAdditionalWorkFeeWithProps',
|
||||
setup() {
|
||||
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/HtAdditionalWorkFee.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId, contractName: props.contractName });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const reserveFeeView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtReserveFeeWithProps',
|
||||
setup() {
|
||||
const AsyncHtReserveFee = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/HtReserveFee.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 HtReserveFee 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtReserveFee, { contractId: props.contractId, contractName: props.contractName });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const summaryView = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtContractSummaryWithProps',
|
||||
setup() {
|
||||
const AsyncSummary = defineAsyncComponent({
|
||||
loader: () => import('@/features/ht/components/HtContractSummary.vue'),
|
||||
onError: (err) => {
|
||||
console.error('加载 HtContractSummary 组件失败:', err)
|
||||
}
|
||||
})
|
||||
return () => h(AsyncSummary, { contractId: props.contractId })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 4. 给分类数组添加严格类型标注
|
||||
const xmCategories = computed<XmCategoryItem[]>(() => [
|
||||
{ key: 'base-info', label: t('htCard.categories.baseInfo'), component: htBaseInfoView },
|
||||
{ key: 'info', label: t('htCard.categories.scaleInfo'), component: htView },
|
||||
{ key: 'contract', label: t('htCard.categories.services'), component: zxfwView },
|
||||
{ key: 'consult-category-factor', label: t('htCard.categories.consultFactor'), component: consultCategoryFactorView },
|
||||
{ key: 'major-factor', label: t('htCard.categories.majorFactor'), component: majorFactorView },
|
||||
{ key: 'additional-work-fee', label: t('htCard.categories.additionalFee'), component: additionalWorkFeeView },
|
||||
{ key: 'reserve-fee', label: t('htCard.categories.reserveFee'), component: reserveFeeView },
|
||||
{ key: 'all', label: t('htCard.categories.summary'), component: summaryView },
|
||||
]);
|
||||
|
||||
watch(budgetRefreshSignature, (next, prev) => {
|
||||
if (next === prev) return
|
||||
scheduleRefreshContractBudget()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void refreshContractBudget()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void refreshContractBudget()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
projectScaleKey?: string | null
|
||||
projectInfoKey?: string
|
||||
}>()
|
||||
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const XM_DB_KEY = computed(() => {
|
||||
if (props.projectScaleKey === null) return undefined
|
||||
return props.projectScaleKey || 'xm-info-v3'
|
||||
})
|
||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const { t } = useI18n()
|
||||
const titleText = computed(() => `${t('htInfo.scaleDetailTitle')}:${t('htInfo.scaleDetailHint')}`)
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonAgGrid
|
||||
:title="titleText"
|
||||
:dbKey="DB_KEY"
|
||||
:xmInfoKey="XM_DB_KEY"
|
||||
:base-info-key="BASE_INFO_KEY"
|
||||
/>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
export interface ContractItem {
|
||||
id: string
|
||||
name: string
|
||||
order: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
|
||||
list.map((item, index) => ({
|
||||
...item,
|
||||
order: index,
|
||||
createdAt: item.createdAt || new Date().toISOString()
|
||||
}))
|
||||
|
||||
export const formatDateTime = (value: string) => {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
export const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map((item, index) => {
|
||||
const row = item as Partial<ContractItem>
|
||||
const name = typeof row.name === 'string' ? row.name.trim() : ''
|
||||
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
|
||||
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
|
||||
return {
|
||||
id,
|
||||
name: name || `导入合同段-${index + 1}`,
|
||||
order: index,
|
||||
createdAt
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const isEntryRelatedToAnyContract = (
|
||||
key: string,
|
||||
contractIds: Set<string>,
|
||||
matcher: (entryKey: string, contractId: string) => boolean
|
||||
) => {
|
||||
for (const contractId of contractIds) {
|
||||
if (matcher(key, contractId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
|
||||
overscroll-behavior: contain;
|
||||
scroll-snap-type: y mandatory;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
||||
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
|
||||
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
|
||||
will-change: transform, opacity;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.ht-contract-card {
|
||||
will-change: transform, opacity;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
isolation: isolate;
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
box-shadow 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
border-color 180ms ease;
|
||||
box-shadow:
|
||||
0 1px 2px hsl(var(--foreground) / 0.04),
|
||||
0 6px 16px hsl(var(--foreground) / 0.06);
|
||||
}
|
||||
|
||||
.ht-contract-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
hsl(var(--primary) / 0.42) 0%,
|
||||
hsl(var(--primary) / 0.22) 36%,
|
||||
hsl(var(--foreground) / 0.09) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.ht-contract-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
bottom: -30px;
|
||||
height: 52px;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at center,
|
||||
hsl(var(--primary) / 0.42) 0%,
|
||||
hsl(var(--primary) / 0.24) 34%,
|
||||
hsl(var(--foreground) / 0.20) 58%,
|
||||
transparent 86%
|
||||
);
|
||||
filter: blur(18px);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.ht-contract-card:hover {
|
||||
transform: translate3d(0, -5px, 0);
|
||||
z-index: 14;
|
||||
box-shadow:
|
||||
0 0 0 1.5px hsl(var(--primary) / 0.62),
|
||||
0 0 28px hsl(var(--primary) / 0.34),
|
||||
0 0 56px hsl(var(--primary) / 0.22),
|
||||
0 16px 34px hsl(var(--foreground) / 0.22),
|
||||
0 32px 60px hsl(var(--foreground) / 0.18);
|
||||
border-color: hsl(var(--primary) / 0.72);
|
||||
}
|
||||
|
||||
.ht-contract-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ht-contract-card:hover::after {
|
||||
opacity: 0.95;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ht-contract-card:active {
|
||||
transform: translate3d(0, -2px, 0);
|
||||
box-shadow:
|
||||
0 5px 12px hsl(var(--foreground) / 0.10),
|
||||
0 10px 20px hsl(var(--foreground) / 0.10);
|
||||
}
|
||||
|
||||
.ht-contract-card--ready {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.ht-contract-card--enter {
|
||||
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
animation-delay: var(--ht-card-enter-delay, 0ms);
|
||||
}
|
||||
|
||||
.ht-contract-card--selecting {
|
||||
transform-origin: 50% 100%;
|
||||
animation: ht-card-select-wave 2200ms linear infinite both;
|
||||
animation-delay: var(--ht-card-select-delay, 0ms);
|
||||
}
|
||||
|
||||
.ht-contract-card--selecting:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.ht-contract-card--selecting.ht-contract-card--selected {
|
||||
animation: none;
|
||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||
}
|
||||
|
||||
.ht-contract-card--selected {
|
||||
border-color: hsl(var(--primary));
|
||||
transform: translate3d(0, -4px, 0);
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary) / 0.34),
|
||||
0 12px 24px hsl(var(--primary) / 0.18),
|
||||
0 22px 36px hsl(var(--foreground) / 0.10);
|
||||
}
|
||||
|
||||
.ht-contract-card--selected::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ht-contract-card--selected::after {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes ht-card-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(44px, 0, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ht-card-select-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||
}
|
||||
11% {
|
||||
transform: translate3d(-0.4px, 0, 0) rotate(-0.7deg);
|
||||
}
|
||||
22% {
|
||||
transform: translate3d(-0.9px, 0, 0) rotate(-1.6deg);
|
||||
}
|
||||
34% {
|
||||
transform: translate3d(-1.2px, 0, 0) rotate(-2.3deg);
|
||||
}
|
||||
48% {
|
||||
transform: translate3d(-0.2px, 0, 0) rotate(-0.4deg);
|
||||
}
|
||||
62% {
|
||||
transform: translate3d(0.8px, 0, 0) rotate(1.5deg);
|
||||
}
|
||||
76% {
|
||||
transform: translate3d(1.25px, 0, 0) rotate(2.35deg);
|
||||
}
|
||||
88% {
|
||||
transform: translate3d(0.35px, 0, 0) rotate(0.65deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ht-contract-card--enter,
|
||||
.ht-contract-card--selecting {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ht-contract-card,
|
||||
.ht-contract-card:hover,
|
||||
.ht-contract-card:active,
|
||||
.ht-contract-card--selected {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ht-contract-card::before,
|
||||
.ht-contract-card::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
cloneJson,
|
||||
isContractRelatedForageKey,
|
||||
isContractRelatedKeyedStateKey,
|
||||
isRecord,
|
||||
SERVICE_PRICING_METHODS
|
||||
} from '@/lib/contractSegment'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface KvStoreLike {
|
||||
keys: () => Promise<string[]>
|
||||
getItem: (key: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export interface ZxFwPricingStoreLike {
|
||||
contracts: Record<string, unknown>
|
||||
servicePricingStates: Record<string, unknown>
|
||||
htFeeMainStates: Record<string, unknown>
|
||||
htFeeMethodStates: Record<string, unknown>
|
||||
keyedStates: Record<string, unknown>
|
||||
loadContract: (...args: any[]) => Promise<unknown>
|
||||
getContractState: (...args: any[]) => unknown
|
||||
setContractState: (...args: any[]) => Promise<unknown>
|
||||
setServicePricingMethodState: (...args: any[]) => unknown
|
||||
setHtFeeMainState: (...args: any[]) => unknown
|
||||
setHtFeeMethodState: (...args: any[]) => unknown
|
||||
setKeyState: (...args: any[]) => unknown
|
||||
}
|
||||
|
||||
export const buildContractPiniaPayload = async (
|
||||
store: ZxFwPricingStoreLike,
|
||||
contractIds: string[]
|
||||
) => {
|
||||
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
|
||||
const payload = {
|
||||
contracts: {} as AnyRecord,
|
||||
servicePricingStates: {} as AnyRecord,
|
||||
htFeeMainStates: {} as AnyRecord,
|
||||
htFeeMethodStates: {} as AnyRecord
|
||||
}
|
||||
if (idSet.size === 0) return payload
|
||||
|
||||
await Promise.all(Array.from(idSet).map(id => store.loadContract(id)))
|
||||
|
||||
for (const contractId of idSet) {
|
||||
const contractState = store.getContractState(contractId)
|
||||
if (contractState) {
|
||||
payload.contracts[contractId] = cloneJson(contractState)
|
||||
}
|
||||
|
||||
const servicePricingState = store.servicePricingStates[contractId]
|
||||
if (isRecord(servicePricingState)) {
|
||||
payload.servicePricingStates[contractId] = cloneJson(servicePricingState)
|
||||
}
|
||||
|
||||
const mainPrefix = `htExtraFee-${contractId}-`
|
||||
for (const [mainKey, mainState] of Object.entries(store.htFeeMainStates)) {
|
||||
if (!mainKey.startsWith(mainPrefix)) continue
|
||||
payload.htFeeMainStates[mainKey] = cloneJson(mainState)
|
||||
}
|
||||
|
||||
for (const [mainKey, methodState] of Object.entries(store.htFeeMethodStates)) {
|
||||
if (!mainKey.startsWith(mainPrefix)) continue
|
||||
payload.htFeeMethodStates[mainKey] = cloneJson(methodState)
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export const applyImportedContractPiniaPayload = async (
|
||||
store: ZxFwPricingStoreLike,
|
||||
piniaPayload: unknown,
|
||||
oldToNewIdMap: Map<string, string>
|
||||
) => {
|
||||
if (!isRecord(piniaPayload)) return
|
||||
const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null
|
||||
if (!zxFwPayload) return
|
||||
|
||||
const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {}
|
||||
const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {}
|
||||
const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {}
|
||||
const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {}
|
||||
|
||||
for (const [oldId, newId] of oldToNewIdMap.entries()) {
|
||||
const rawContractState = contractsMap[oldId]
|
||||
if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) {
|
||||
await store.setContractState(newId, rawContractState as any)
|
||||
}
|
||||
|
||||
const rawServicePricingByService = servicePricingStatesMap[oldId]
|
||||
if (isRecord(rawServicePricingByService)) {
|
||||
for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) {
|
||||
if (!isRecord(rawServiceMethods)) continue
|
||||
for (const method of SERVICE_PRICING_METHODS) {
|
||||
const methodState = rawServiceMethods[method]
|
||||
if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue
|
||||
store.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oldMainPrefix = `htExtraFee-${oldId}-`
|
||||
const newMainPrefix = `htExtraFee-${newId}-`
|
||||
for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) {
|
||||
if (!oldMainKey.startsWith(oldMainPrefix)) continue
|
||||
if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue
|
||||
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
|
||||
store.setHtFeeMainState(newMainKey, rawMainState as any, { force: true })
|
||||
}
|
||||
|
||||
for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) {
|
||||
if (!oldMainKey.startsWith(oldMainPrefix)) continue
|
||||
if (!isRecord(rawByRow)) continue
|
||||
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
|
||||
for (const [rowId, rawByMethod] of Object.entries(rawByRow)) {
|
||||
if (!isRecord(rawByMethod)) continue
|
||||
const ratePayload = rawByMethod['rate-fee']
|
||||
const hourlyPayload = rawByMethod['hourly-fee']
|
||||
const quantityPayload = rawByMethod['quantity-unit-price-fee']
|
||||
if (ratePayload != null) {
|
||||
store.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true })
|
||||
}
|
||||
if (hourlyPayload != null) {
|
||||
store.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true })
|
||||
}
|
||||
if (quantityPayload != null) {
|
||||
store.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const readContractRelatedForageEntries = async (
|
||||
kvStore: KvStoreLike,
|
||||
contractIds: string[]
|
||||
) => {
|
||||
const keys = await kvStore.keys()
|
||||
const idSet = new Set(contractIds)
|
||||
const targetKeys = keys.filter(key => {
|
||||
for (const id of idSet) {
|
||||
if (isContractRelatedForageKey(key, id)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
return Promise.all(
|
||||
targetKeys.map(async key => ({
|
||||
key,
|
||||
value: await kvStore.getItem(key)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export const readContractRelatedKeyedEntries = (
|
||||
store: Pick<ZxFwPricingStoreLike, 'keyedStates'>,
|
||||
contractIds: string[]
|
||||
) => {
|
||||
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
|
||||
return Object.entries(store.keyedStates)
|
||||
.filter(([key]) => {
|
||||
for (const id of idSet) {
|
||||
if (isContractRelatedKeyedStateKey(key, id)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: cloneJson(value)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
export interface XmScaleState {
|
||||
detailRows?: unknown[]
|
||||
roughCalcEnabled?: boolean
|
||||
totalAmount?: number | null
|
||||
}
|
||||
|
||||
export interface HtFeeMainRowLike {
|
||||
id?: unknown
|
||||
}
|
||||
|
||||
export interface RateMethodStateLike {
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
export interface HourlyMethodRowLike {
|
||||
serviceBudget?: unknown
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
}
|
||||
|
||||
export interface HourlyMethodStateLike {
|
||||
detailRows?: HourlyMethodRowLike[]
|
||||
}
|
||||
|
||||
export interface QuantityMethodRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
|
||||
export interface QuantityMethodStateLike {
|
||||
detailRows?: QuantityMethodRowLike[]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
serviceId: string | number
|
||||
}>()
|
||||
|
||||
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HourlyFeeGrid
|
||||
:title="t('hourlyFeeGrid.title')"
|
||||
:storage-key="DB_KEY"
|
||||
:contract-id="props.contractId"
|
||||
:service-id="props.serviceId"
|
||||
:enable-zx-fw-sync="true"
|
||||
sync-field="hourly"
|
||||
/>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { createScaleAutoGroupColumn } from '@/lib/pricingScaleColumns'
|
||||
import { buildScaleDetailDict, buildScaleIdLabelMap } from '@/lib/pricingScaleDict'
|
||||
import { parseProjectIndexFromPathKey } from '@/lib/pricingScaleLink'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { getMajorDictEntries } from '@/sql'
|
||||
import type { ScaleDetailRow } from '@/types/pricing'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
serviceId: string | number
|
||||
method: 'investScale' | 'landScale'
|
||||
}>()
|
||||
|
||||
type MajorLite = {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
hasCost?: boolean
|
||||
hasArea?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const gridApi = ref<GridApi<ScaleDetailRow> | null>(null)
|
||||
let autoHeightTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const isInvestmentFormula = computed(() => props.method === 'investScale')
|
||||
const methodLabel = computed(() =>
|
||||
isInvestmentFormula.value
|
||||
? t('zxFwView.categories.investmentScaleFormula')
|
||||
: t('zxFwView.categories.landScaleFormula')
|
||||
)
|
||||
|
||||
const ensureMethodStateLoaded = async () => {
|
||||
await zxFwPricingStore.loadServicePricingMethodState<ScaleDetailRow>(
|
||||
props.contractId,
|
||||
props.serviceId,
|
||||
props.method
|
||||
)
|
||||
}
|
||||
|
||||
const methodState = computed(() =>
|
||||
zxFwPricingStore.getServicePricingMethodState<ScaleDetailRow>(
|
||||
props.contractId,
|
||||
props.serviceId,
|
||||
props.method
|
||||
)
|
||||
)
|
||||
|
||||
const rowData = computed<ScaleDetailRow[]>(() => {
|
||||
const rows = methodState.value?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
})
|
||||
|
||||
const idLabelMap = computed(() => {
|
||||
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
|
||||
const detailDict = buildScaleDetailDict(
|
||||
majorEntries,
|
||||
isInvestmentFormula.value
|
||||
? ({ hasCost, hasArea }) => hasCost && !hasArea
|
||||
: ({ hasArea }) => hasArea
|
||||
)
|
||||
return buildScaleIdLabelMap(detailDict)
|
||||
})
|
||||
|
||||
const numberFormatter = (params: { value?: unknown }) =>
|
||||
typeof params.value === 'number' && Number.isFinite(params.value)
|
||||
? formatThousandsFlexible(params.value, 3)
|
||||
: ''
|
||||
|
||||
const scaleValueColumnField = computed<keyof ScaleDetailRow>(() =>
|
||||
isInvestmentFormula.value ? 'amount' : 'landArea'
|
||||
)
|
||||
|
||||
const scaleValueColumnHeader = computed(() =>
|
||||
isInvestmentFormula.value
|
||||
? t('pricingScale.columns.investAmount')
|
||||
: t('pricingScale.columns.landArea')
|
||||
)
|
||||
|
||||
const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() =>
|
||||
withReadonlyAutoHeight<ScaleDetailRow>([
|
||||
{
|
||||
headerName: scaleValueColumnHeader.value,
|
||||
field: scaleValueColumnField.value,
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: numberFormatter
|
||||
},
|
||||
{
|
||||
headerName: t('pricingScale.columns.basicWork'),
|
||||
field: 'benchmarkBudgetBasic',
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: numberFormatter
|
||||
},
|
||||
{
|
||||
headerName: t('zxFwView.formulaColumns.basicFormula'),
|
||||
field: 'basicFormula',
|
||||
minWidth: 260,
|
||||
flex: 2
|
||||
},
|
||||
{
|
||||
headerName: t('pricingScale.columns.optionalWork'),
|
||||
field: 'benchmarkBudgetOptional',
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: numberFormatter
|
||||
},
|
||||
{
|
||||
headerName: t('zxFwView.formulaColumns.optionalFormula'),
|
||||
field: 'optionalFormula',
|
||||
minWidth: 260,
|
||||
flex: 2
|
||||
}
|
||||
]) as ColDef<ScaleDetailRow>[]
|
||||
)
|
||||
|
||||
const autoGroupColumnDef = computed<ColDef<ScaleDetailRow>>(() =>
|
||||
createScaleAutoGroupColumn<ScaleDetailRow>({
|
||||
totalLabel: methodLabel.value,
|
||||
idLabelMap: idLabelMap.value,
|
||||
parseProjectIndexFromPathKey
|
||||
})
|
||||
)
|
||||
|
||||
const detailGridOptions: GridOptions<ScaleDetailRow> = {
|
||||
...gridOptions
|
||||
}
|
||||
|
||||
const syncAutoRowHeights = async () => {
|
||||
await nextTick()
|
||||
const api = gridApi.value
|
||||
if (!api || api.isDestroyed?.()) return
|
||||
api.resetRowHeights()
|
||||
api.onRowHeightChanged()
|
||||
api.refreshCells({ force: true })
|
||||
}
|
||||
|
||||
const scheduleAutoRowHeights = () => {
|
||||
if (autoHeightTimer) clearTimeout(autoHeightTimer)
|
||||
autoHeightTimer = setTimeout(() => {
|
||||
autoHeightTimer = null
|
||||
void syncAutoRowHeights()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const onGridReady = (event: GridReadyEvent<ScaleDetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
|
||||
watch(rowData, () => {
|
||||
scheduleAutoRowHeights()
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.method, () => {
|
||||
void ensureMethodStateLoaded()
|
||||
scheduleAutoRowHeights()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void ensureMethodStateLoaded()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void ensureMethodStateLoaded()
|
||||
scheduleAutoRowHeights()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (autoHeightTimer) clearTimeout(autoHeightTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ methodLabel }}</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ t('zxFwView.formulaColumns.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue
|
||||
:theme="myTheme"
|
||||
:style="agGridStyle"
|
||||
:row-data="rowData"
|
||||
:column-defs="columnDefs"
|
||||
:auto-group-column-def="autoGroupColumnDef"
|
||||
:grid-options="detailGridOptions"
|
||||
:locale-text="AG_GRID_LOCALE_CN"
|
||||
@grid-ready="onGridReady"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,695 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
||||
import { taskList } from '@/sql'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
|
||||
import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
|
||||
import { sumNullableBy } from '@/lib/pricingScaleCalc'
|
||||
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
|
||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
taskCode: string
|
||||
taskName: string
|
||||
unit: string
|
||||
conversion: number | null
|
||||
workload: number | null
|
||||
basicFee: number | null
|
||||
budgetBase: string
|
||||
budgetReferenceUnitPrice: string
|
||||
budgetAdoptedUnitPrice: number | null
|
||||
consultCategoryFactor: number | null
|
||||
serviceFee: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface XmInfoState {
|
||||
projectName: string
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
|
||||
serviceId: string | number
|
||||
}>()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const { t, locale } = useI18n()
|
||||
const kvStore = useKvStore()
|
||||
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
let factorDefaultsLoaded = false
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
|
||||
const getDefaultConsultCategoryFactor = () =>
|
||||
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
||||
|
||||
const ensureFactorDefaultsLoaded = async () => {
|
||||
if (factorDefaultsLoaded) return
|
||||
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
|
||||
factorDefaultsLoaded = true
|
||||
}
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [issuedRaw, untilRaw] = raw.split(':')
|
||||
const issuedAt = Number(issuedRaw)
|
||||
const skipUntil = Number(untilRaw)
|
||||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
||||
return paneInstanceCreatedAt <= issuedAt
|
||||
}
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const skipUntil = Number(raw)
|
||||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
||||
}
|
||||
|
||||
const getMethodState = () =>
|
||||
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
|
||||
|
||||
const detailRows = computed<DetailRow[]>({
|
||||
get: () => {
|
||||
const rows = getMethodState()?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
},
|
||||
set: rows => {
|
||||
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'workload', {
|
||||
detailRows: rows
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type taskLite = {
|
||||
serviceID: number
|
||||
code?: string
|
||||
ref?: string
|
||||
name: string
|
||||
basicParam: string
|
||||
unit: string
|
||||
conversion: number | null
|
||||
maxPrice: number | null
|
||||
minPrice: number | null
|
||||
defPrice: number | null
|
||||
desc: string | null
|
||||
}
|
||||
|
||||
const getTaskDisplayName = (task: taskLite | undefined) => {
|
||||
if (!task) return ''
|
||||
return String(locale.value).toLowerCase().startsWith('en')
|
||||
? (task as taskLite & { nameEn?: string }).nameEn || task.name
|
||||
: task.name
|
||||
}
|
||||
|
||||
const formatTaskReferenceUnitPrice = (task: taskLite) => {
|
||||
const unit = task.unit || ''
|
||||
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
|
||||
const hasMax = typeof task.maxPrice === 'number' && Number.isFinite(task.maxPrice)
|
||||
if (hasMin && hasMax) return `${task.minPrice}${unit}-${task.maxPrice}${unit}`
|
||||
if (hasMin) return `${task.minPrice}${unit}`
|
||||
if (hasMax) return `${task.maxPrice}${unit}`
|
||||
return ''
|
||||
}
|
||||
|
||||
const getSourceTaskIds = () => {
|
||||
const currentServiceId = Number(props.serviceId)
|
||||
return Object.entries(taskList as Record<string, taskLite>)
|
||||
.filter(([, task]) => Number(task.serviceID) === currentServiceId)
|
||||
.map(([key]) => Number(key))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
const isWorkloadMethodApplicable = computed(() => getSourceTaskIds().length > 0)
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
const sourceTaskIds = getSourceTaskIds()
|
||||
|
||||
for (const [order, taskId] of sourceTaskIds.entries()) {
|
||||
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
|
||||
const taskCode = task?.code || task?.ref || ''
|
||||
if (!taskCode || !task?.name) continue
|
||||
const rowId = `task-${taskId}-${order}`
|
||||
rows.push({
|
||||
id: rowId,
|
||||
taskCode,
|
||||
taskName: getTaskDisplayName(task),
|
||||
unit: task.unit || '',
|
||||
conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null,
|
||||
workload: null,
|
||||
basicFee: null,
|
||||
budgetBase: task.basicParam || '',
|
||||
budgetReferenceUnitPrice: formatTaskReferenceUnitPrice(task),
|
||||
budgetAdoptedUnitPrice:
|
||||
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
|
||||
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
||||
serviceFee: null,
|
||||
remark: task.desc|| '',
|
||||
path: [rowId]
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const isNoTaskRow = (row: DetailRow | undefined) => row?.id?.startsWith('task-none-') ?? false
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
|
||||
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
|
||||
|
||||
return {
|
||||
...row,
|
||||
workload: typeof fromDb.workload === 'number' ? fromDb.workload : null,
|
||||
basicFee: typeof fromDb.basicFee === 'number' ? fromDb.basicFee : null,
|
||||
budgetAdoptedUnitPrice:
|
||||
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
|
||||
consultCategoryFactor:
|
||||
typeof fromDb.consultCategoryFactor === 'number'
|
||||
? fromDb.consultCategoryFactor
|
||||
: hasConsultCategoryFactor
|
||||
? null
|
||||
: getDefaultConsultCategoryFactor(),
|
||||
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseSanitizedNumberOrNull = (value: unknown) =>
|
||||
parseNumberOrNull(value, { sanitize: true, precision: 3 })
|
||||
|
||||
const parseSanitizedAdoptedPriceOrNull = (value: unknown) =>
|
||||
parseNumberOrNull(value, { sanitize: true, precision: 6 })
|
||||
|
||||
const calcBasicFee = (row: DetailRow | undefined) => {
|
||||
if (!row || isNoTaskRow(row)) return null
|
||||
const price = row.budgetAdoptedUnitPrice
|
||||
const conversion = row.conversion
|
||||
const workload = row.workload
|
||||
if (
|
||||
typeof price !== 'number' ||
|
||||
!Number.isFinite(price) ||
|
||||
typeof conversion !== 'number' ||
|
||||
!Number.isFinite(conversion) ||
|
||||
typeof workload !== 'number' ||
|
||||
!Number.isFinite(workload)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
|
||||
}
|
||||
|
||||
const calcServiceFee = (row: DetailRow | undefined) => {
|
||||
if (!row || isNoTaskRow(row)) return null
|
||||
const factor = row.consultCategoryFactor
|
||||
const basicFee = calcBasicFee(row)
|
||||
if (
|
||||
basicFee == null ||
|
||||
typeof factor !== 'number' ||
|
||||
!Number.isFinite(factor)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(basicFee).mul(factor), 2)
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (isNoTaskRow(params.data)) return t('workloadPricing.none')
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return t('workloadPricing.clickToInput')
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const spanRowsByTaskName = (params: any) => {
|
||||
const rowA = params?.nodeA?.data as DetailRow | undefined
|
||||
const rowB = params?.nodeB?.data as DetailRow | undefined
|
||||
if (!rowA || !rowB) return false
|
||||
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
|
||||
|
||||
return Boolean(rowA.taskName) && Boolean(rowA.budgetBase) && rowA.taskName === rowB.taskName && rowA.budgetBase === rowB.budgetBase
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: t('workloadPricing.columns.code'),
|
||||
field: 'taskCode',
|
||||
minWidth: 100,
|
||||
width: 120,
|
||||
pinned: 'left',
|
||||
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
||||
cellClassRules: {
|
||||
'ag-summary-label-cell': params => Boolean(params.node?.rowPinned)
|
||||
},
|
||||
valueFormatter: params => (params.node?.rowPinned ? t('workloadPricing.total') : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.name'),
|
||||
field: 'taskName',
|
||||
minWidth: 150,
|
||||
width: 220,
|
||||
pinned: 'left',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
|
||||
spanRows: true,
|
||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.budgetBase'),
|
||||
field: 'budgetBase',
|
||||
minWidth: 150,
|
||||
autoHeight: true,
|
||||
|
||||
width: 180,
|
||||
colSpan: params => (params.node?.rowPinned ? 3 : 1),
|
||||
spanRows: spanRowsByTaskName,
|
||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.budgetReferenceUnitPrice'),
|
||||
field: 'budgetReferenceUnitPrice',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
valueFormatter: params => params.value || ''
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.budgetAdoptedUnitPrice'),
|
||||
field: 'budgetAdoptedUnitPrice',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group &&
|
||||
!params.node?.rowPinned &&
|
||||
!isNoTaskRow(params.data) &&
|
||||
(params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseSanitizedAdoptedPriceOrNull(params.newValue),
|
||||
valueFormatter: params => {
|
||||
if (isNoTaskRow(params.data)) return t('workloadPricing.none')
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return t('workloadPricing.clickToInput')
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
const unit = params.data?.unit || ''
|
||||
return `${formatThousandsFlexible(params.value, 6)}${unit}`
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.workload'),
|
||||
field: 'workload',
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group &&
|
||||
!params.node?.rowPinned &&
|
||||
!isNoTaskRow(params.data) &&
|
||||
(params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.consultCategoryFactor'),
|
||||
field: 'consultCategoryFactor',
|
||||
width: 80,
|
||||
minWidth: 70,
|
||||
maxWidth: 90,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group &&
|
||||
!params.node?.rowPinned &&
|
||||
!isNoTaskRow(params.data) &&
|
||||
(params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.serviceFee'),
|
||||
field: 'serviceFee',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: false,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)),
|
||||
aggFunc: decimalAggSum,
|
||||
valueFormatter: params => {
|
||||
if (isNoTaskRow(params.data)) return t('workloadPricing.none')
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(roundTo(params.value, 3), 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('workloadPricing.columns.remark'),
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
|
||||
editable: false,
|
||||
valueFormatter: params => {
|
||||
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group &&
|
||||
!params.node?.rowPinned &&
|
||||
!isNoTaskRow(params.data) &&
|
||||
(params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
|
||||
const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row)))
|
||||
const totalServiceFee = computed(() => sumNullableBy(detailRows.value, row => calcServiceFee(row)))
|
||||
const pinnedTopRowData = computed(() =>
|
||||
createPinnedTopRowData({
|
||||
id: 'pinned-total-row',
|
||||
taskCode: t('workloadPricing.total'),
|
||||
taskName: '',
|
||||
unit: '',
|
||||
conversion: null,
|
||||
workload: totalWorkload.value,
|
||||
basicFee: totalBasicFee.value,
|
||||
budgetBase: '',
|
||||
budgetReferenceUnitPrice: '',
|
||||
budgetAdoptedUnitPrice: null,
|
||||
consultCategoryFactor: null,
|
||||
serviceFee: totalServiceFee.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
|
||||
const buildPersistDetailRows = () =>
|
||||
detailRows.value.map(row => ({
|
||||
...row,
|
||||
basicFee: calcBasicFee(row),
|
||||
serviceFee: calcServiceFee(row)
|
||||
}))
|
||||
const saveToIndexedDB = async () => {
|
||||
if (!isWorkloadMethodApplicable.value) return
|
||||
if (shouldSkipPersist()) return
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||
}
|
||||
zxFwPricingStore.setServicePricingMethodState(
|
||||
props.contractId,
|
||||
props.serviceId,
|
||||
'workload',
|
||||
payload,
|
||||
{ force: true }
|
||||
)
|
||||
const synced = await syncPricingTotalToZxFw({
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
field: 'workload',
|
||||
value: totalServiceFee.value
|
||||
})
|
||||
if (!synced) return
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
|
||||
if (left == null && right == null) return true
|
||||
if (left == null || right == null) return false
|
||||
return roundTo(left, 6) === roundTo(right, 6)
|
||||
}
|
||||
|
||||
const syncLinkedConsultFactorFromHt = async () => {
|
||||
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
|
||||
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
|
||||
factorDefaultsLoaded = true
|
||||
const nextDefaultFactor = getDefaultConsultCategoryFactor()
|
||||
let changed = false
|
||||
detailRows.value = detailRows.value.map(row => {
|
||||
if (isSameNullableNumber(row.consultCategoryFactor, nextDefaultFactor)) return row
|
||||
changed = true
|
||||
return {
|
||||
...row,
|
||||
consultCategoryFactor: nextDefaultFactor
|
||||
}
|
||||
})
|
||||
if (!changed) return
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
const linkedConsultFactorSignature = computed(() => JSON.stringify({
|
||||
consultFactor:
|
||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
|
||||
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
|
||||
?? null
|
||||
}))
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
if (!isWorkloadMethodApplicable.value) {
|
||||
detailRows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
await ensureFactorDefaultsLoaded()
|
||||
|
||||
if (shouldForceDefaultLoad()) {
|
||||
detailRows.value = buildDefaultRows()
|
||||
return
|
||||
}
|
||||
|
||||
const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
const relabelRowsFromTaskDict = async () => {
|
||||
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
|
||||
let changed = false
|
||||
detailRows.value = detailRows.value.map(row => {
|
||||
if (isNoTaskRow(row)) return row
|
||||
const match = String(row.id || '').match(/^task-(\d+)-\d+$/)
|
||||
if (!match) return row
|
||||
const task = (taskList as Record<string, taskLite | undefined>)[match[1]]
|
||||
if (!task) return row
|
||||
const nextTaskName = getTaskDisplayName(task)
|
||||
const nextUnit = task.unit || ''
|
||||
const nextBudgetBase = task.basicParam || ''
|
||||
const nextBudgetReferenceUnitPrice = formatTaskReferenceUnitPrice(task)
|
||||
if (
|
||||
row.taskName === nextTaskName &&
|
||||
row.unit === nextUnit &&
|
||||
row.budgetBase === nextBudgetBase &&
|
||||
row.budgetReferenceUnitPrice === nextBudgetReferenceUnitPrice
|
||||
) {
|
||||
return row
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...row,
|
||||
taskName: nextTaskName,
|
||||
unit: nextUnit,
|
||||
budgetBase: nextBudgetBase,
|
||||
budgetReferenceUnitPrice: nextBudgetReferenceUnitPrice
|
||||
}
|
||||
})
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
if (!changed) return
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
if (isBulkClipboardMutation) return
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
usePricingPaneLifecycle({
|
||||
gridApi,
|
||||
loadFromIndexedDB,
|
||||
syncLinkedFields: syncLinkedConsultFactorFromHt,
|
||||
linkedSourceSignature: linkedConsultFactorSignature,
|
||||
saveToIndexedDB
|
||||
})
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => {
|
||||
void relabelRowsFromTaskDict()
|
||||
}
|
||||
)
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'budgetAdoptedUnitPrice') {
|
||||
return parseSanitizedAdoptedPriceOrNull(params.value)
|
||||
}
|
||||
if (field === 'workload' || field === 'consultCategoryFactor') {
|
||||
return parseSanitizedNumberOrNull(params.value)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const mydiyTheme = myTheme.withParams({
|
||||
rowBorder: {
|
||||
style: "solid",
|
||||
width: 0.8,
|
||||
color: "var(--border)"
|
||||
},
|
||||
columnBorder: {
|
||||
style: "solid",
|
||||
width: 0.8,
|
||||
color: "var(--border)"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ t('workloadPricing.title') }}</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="isWorkloadMethodApplicable" :class="agGridWrapClass">
|
||||
<AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="gridColumnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
|
||||
:animateRows="true"
|
||||
:enableCellSpan="true"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
||||
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
|
||||
:suppressRowVirtualisation="true"
|
||||
|
||||
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||
|
||||
|
||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||
</div>
|
||||
<MethodUnavailableNotice
|
||||
v-else
|
||||
:title="t('workloadPricing.unavailableTitle')"
|
||||
:message="t('workloadPricing.unavailableMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,772 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type {
|
||||
ColDef,
|
||||
ColGroupDef,
|
||||
FirstDataRenderedEvent,
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
RowDataUpdatedEvent
|
||||
} from 'ag-grid-community'
|
||||
import { expertList } from '@/sql'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber, sumNullableNumbers, toDecimal } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
|
||||
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
expertCode: string
|
||||
expertName: string
|
||||
laborBudgetUnitPrice: string
|
||||
compositeBudgetUnitPrice: string
|
||||
adoptedBudgetUnitPrice: number | null
|
||||
personnelCount: number | null
|
||||
workdayCount: number | null
|
||||
serviceBudget: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface GridState {
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
storageKey: string
|
||||
title?: string
|
||||
contractId?: string
|
||||
serviceId?: string | number
|
||||
enableZxFwSync?: boolean
|
||||
syncField?: ZxFwPricingField
|
||||
htMainStorageKey?: string
|
||||
htRowId?: string
|
||||
htMethodType?: HtFeeMethodType
|
||||
}>(),
|
||||
{
|
||||
title: undefined,
|
||||
enableZxFwSync: false,
|
||||
syncField: 'hourly'
|
||||
}
|
||||
)
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, props.storageKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [issuedRaw, untilRaw] = raw.split(':')
|
||||
const issuedAt = Number(issuedRaw)
|
||||
const skipUntil = Number(untilRaw)
|
||||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
||||
return paneInstanceCreatedAt <= issuedAt
|
||||
}
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const skipUntil = Number(raw)
|
||||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, props.storageKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
||||
}
|
||||
|
||||
const fallbackDetailRows = ref<DetailRow[]>([])
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
const serviceMethod = computed<ServicePricingMethod | null>(() => {
|
||||
if (props.syncField === 'investScale') return 'investScale'
|
||||
if (props.syncField === 'landScale') return 'landScale'
|
||||
if (props.syncField === 'workload') return 'workload'
|
||||
if (props.syncField === 'hourly') return 'hourly'
|
||||
return null
|
||||
})
|
||||
const useServicePricingState = computed(
|
||||
() => Boolean(props.enableZxFwSync && props.contractId && props.serviceId != null && serviceMethod.value)
|
||||
)
|
||||
const useHtMethodState = computed(
|
||||
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
|
||||
)
|
||||
const getServiceMethodState = () => {
|
||||
if (!useServicePricingState.value || !serviceMethod.value) return null
|
||||
return zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
|
||||
}
|
||||
const getHtMethodState = () => {
|
||||
if (!useHtMethodState.value) return null
|
||||
return zxFwPricingStore.getHtFeeMethodState<GridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
}
|
||||
const detailRows = computed<DetailRow[]>({
|
||||
get: () => {
|
||||
if (useServicePricingState.value) {
|
||||
const rows = getServiceMethodState()?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
}
|
||||
if (useHtMethodState.value) {
|
||||
const rows = getHtMethodState()?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
}
|
||||
return fallbackDetailRows.value
|
||||
},
|
||||
set: rows => {
|
||||
if (useServicePricingState.value && serviceMethod.value) {
|
||||
const currentState = getServiceMethodState()
|
||||
zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, {
|
||||
detailRows: rows,
|
||||
projectCount: currentState?.projectCount ?? null
|
||||
})
|
||||
return
|
||||
}
|
||||
if (useHtMethodState.value) {
|
||||
zxFwPricingStore.setHtFeeMethodState(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!,
|
||||
{ detailRows: rows }
|
||||
)
|
||||
return
|
||||
}
|
||||
fallbackDetailRows.value = rows
|
||||
}
|
||||
})
|
||||
|
||||
type ExpertLite = {
|
||||
code: string
|
||||
name: string
|
||||
maxPrice: number | null
|
||||
minPrice: number | null
|
||||
defPrice: number | null
|
||||
manageCoe: number | null
|
||||
}
|
||||
|
||||
const getExpertDisplayName = (expert: ExpertLite | undefined) => {
|
||||
if (!expert) return ''
|
||||
return String(locale.value).toLowerCase().startsWith('en')
|
||||
? (expert as ExpertLite & { nameEn?: string }).nameEn || expert.name
|
||||
: expert.name
|
||||
}
|
||||
|
||||
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, ExpertLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const formatPriceRange = (min: number | null, max: number | null) => {
|
||||
const hasMin = typeof min === 'number' && Number.isFinite(min)
|
||||
const hasMax = typeof max === 'number' && Number.isFinite(max)
|
||||
if (hasMin && hasMax) return `${min}-${max}`
|
||||
if (hasMin) return String(min)
|
||||
if (hasMax) return String(max)
|
||||
return ''
|
||||
}
|
||||
|
||||
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
|
||||
if (typeof expert.manageCoe !== 'number' || !Number.isFinite(expert.manageCoe)) return ''
|
||||
const min =
|
||||
typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
|
||||
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
const max =
|
||||
typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
|
||||
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
return formatPriceRange(min, max)
|
||||
}
|
||||
|
||||
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
|
||||
if (
|
||||
typeof expert.defPrice !== 'number' ||
|
||||
!Number.isFinite(expert.defPrice) ||
|
||||
typeof expert.manageCoe !== 'number' ||
|
||||
!Number.isFinite(expert.manageCoe)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const [expertId, expert] of expertEntries) {
|
||||
const rowId = `expert-${expertId}`
|
||||
rows.push({
|
||||
id: rowId,
|
||||
expertCode: expert.code,
|
||||
expertName: getExpertDisplayName(expert),
|
||||
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
|
||||
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
|
||||
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
|
||||
personnelCount: null,
|
||||
workdayCount: null,
|
||||
serviceBudget: null,
|
||||
remark: '',
|
||||
path: [rowId]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
return {
|
||||
...row,
|
||||
adoptedBudgetUnitPrice:
|
||||
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
||||
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
||||
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
|
||||
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNonNegativeIntegerOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
const parsed = parseNumberOrNull(value, { sanitize: true, precision: 0 })
|
||||
if (parsed == null) return null
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return t('hourlyFeeGrid.clickToInput')
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatEditableInteger = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return t('hourlyFeeGrid.clickToInput')
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return String(Number(params.value))
|
||||
}
|
||||
|
||||
const calcServiceBudget = (row: DetailRow | undefined) => {
|
||||
const adopted = row?.adoptedBudgetUnitPrice
|
||||
const personnel = row?.personnelCount
|
||||
const workday = row?.workdayCount
|
||||
if (
|
||||
typeof adopted !== 'number' ||
|
||||
!Number.isFinite(adopted) ||
|
||||
typeof personnel !== 'number' ||
|
||||
!Number.isFinite(personnel) ||
|
||||
typeof workday !== 'number' ||
|
||||
!Number.isFinite(workday)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
|
||||
}
|
||||
|
||||
const syncServiceBudgetToRows = () => {
|
||||
for (const row of detailRows.value) {
|
||||
row.serviceBudget = calcServiceBudget(row)
|
||||
}
|
||||
}
|
||||
|
||||
const editableNumberCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 120,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell':()=>true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: formatEditableNumber,
|
||||
...extra
|
||||
})
|
||||
|
||||
const editableMoneyCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 120,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return t('hourlyFeeGrid.clickToInput')
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
},
|
||||
...extra
|
||||
})
|
||||
|
||||
const readonlyTextCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 120,
|
||||
flex: 1,
|
||||
editable: false,
|
||||
valueFormatter: params => params.value || '',
|
||||
...extra
|
||||
})
|
||||
|
||||
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
||||
{
|
||||
headerName: t('hourlyFeeGrid.columns.code'),
|
||||
field: 'expertCode',
|
||||
minWidth: 90,
|
||||
width: 100,
|
||||
pinned: 'left',
|
||||
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
||||
cellClassRules: {
|
||||
'ag-summary-label-cell': params => Boolean(params.node?.rowPinned)
|
||||
},
|
||||
valueFormatter: params => (params.node?.rowPinned ? t('hourlyFeeGrid.total') : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: t('hourlyFeeGrid.columns.name'),
|
||||
field: 'expertName',
|
||||
minWidth: 210,
|
||||
width: 230,
|
||||
pinned: 'left',
|
||||
tooltipField: 'expertName',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellClass: 'hourly-fee-name-cell',
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.2' },
|
||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: t('hourlyFeeGrid.columns.referenceUnitPrice'),
|
||||
marryChildren: true,
|
||||
children: [
|
||||
readonlyTextCol('laborBudgetUnitPrice', t('hourlyFeeGrid.columns.laborBudgetUnitPrice'), {
|
||||
colSpan: params => (params.node?.rowPinned ? 3 : 1),
|
||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||
}),
|
||||
readonlyTextCol('compositeBudgetUnitPrice', t('hourlyFeeGrid.columns.compositeBudgetUnitPrice'))
|
||||
]
|
||||
},
|
||||
editableMoneyCol('adoptedBudgetUnitPrice', t('hourlyFeeGrid.columns.adoptedBudgetUnitPrice')),
|
||||
editableNumberCol('personnelCount', t('hourlyFeeGrid.columns.personnelCount'), {
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
|
||||
valueFormatter: formatEditableInteger
|
||||
}),
|
||||
editableNumberCol('workdayCount', t('hourlyFeeGrid.columns.workdayCount'), { aggFunc: decimalAggSum }),
|
||||
{
|
||||
headerName: t('hourlyFeeGrid.columns.serviceBudget'),
|
||||
field: 'serviceBudget',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 120,
|
||||
flex: 1,
|
||||
editable: false,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
|
||||
valueFormatter: params => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('hourlyFeeGrid.columns.remark'),
|
||||
field: 'remark',
|
||||
minWidth: 120,
|
||||
flex: 1,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return t('hourlyFeeGrid.clickToInput')
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
||||
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
||||
const totalServiceBudget = computed(() => sumNullableNumbers(detailRows.value.map(row => calcServiceBudget(row))))
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
expertCode: t('hourlyFeeGrid.total'),
|
||||
expertName: '',
|
||||
laborBudgetUnitPrice: '',
|
||||
compositeBudgetUnitPrice: '',
|
||||
adoptedBudgetUnitPrice: null,
|
||||
personnelCount: totalPersonnelCount.value,
|
||||
workdayCount: totalWorkdayCount.value,
|
||||
serviceBudget: totalServiceBudget.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
if (shouldSkipPersist()) return
|
||||
try {
|
||||
syncServiceBudgetToRows()
|
||||
const payload: GridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
|
||||
if (useServicePricingState.value && serviceMethod.value) {
|
||||
zxFwPricingStore.setServicePricingMethodState(
|
||||
props.contractId!,
|
||||
props.serviceId!,
|
||||
serviceMethod.value,
|
||||
payload,
|
||||
{ force: true }
|
||||
)
|
||||
} else if (useHtMethodState.value) {
|
||||
zxFwPricingStore.setHtFeeMethodState(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!,
|
||||
payload,
|
||||
{ force: true }
|
||||
)
|
||||
} else {
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||||
}
|
||||
|
||||
if (props.enableZxFwSync && props.contractId && props.serviceId != null) {
|
||||
const synced = await syncPricingTotalToZxFw({
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
field: props.syncField,
|
||||
value: totalServiceBudget.value
|
||||
})
|
||||
if (!synced) return
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
if (shouldForceDefaultLoad()) {
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
return
|
||||
}
|
||||
const data = useServicePricingState.value && serviceMethod.value
|
||||
? await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
|
||||
: useHtMethodState.value
|
||||
? await zxFwPricingStore.loadHtFeeMethodState<GridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
: await zxFwPricingStore.loadKeyState<GridState>(props.storageKey)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
syncServiceBudgetToRows()
|
||||
return
|
||||
}
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
}
|
||||
}
|
||||
|
||||
const relabelRowsFromExpertDict = async () => {
|
||||
if (detailRows.value.length === 0) return
|
||||
let changed = false
|
||||
detailRows.value = detailRows.value.map(row => {
|
||||
const match = String(row.id || '').match(/^expert-(\d+)$/)
|
||||
if (!match) return row
|
||||
const expert = (expertList as Record<string, ExpertLite | undefined>)[match[1]]
|
||||
if (!expert) return row
|
||||
const nextName = getExpertDisplayName(expert)
|
||||
if (row.expertName === nextName) return row
|
||||
changed = true
|
||||
return {
|
||||
...row,
|
||||
expertName: nextName
|
||||
}
|
||||
})
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
if (!changed) return
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = (source: string) => {
|
||||
syncServiceBudgetToRows()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
scheduleAutoRowHeights()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event?: any) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
commitGridChanges('cell-value-changed')
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = (event?: any) => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges(event?.type || 'bulk-end')
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
|
||||
let autoHeightSyncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const isGridApiAlive = (api: GridApi<DetailRow> | null | undefined): api is GridApi<DetailRow> =>
|
||||
Boolean(api && !api.isDestroyed?.())
|
||||
|
||||
const forceRefreshCellsOnLiveApi = () => {
|
||||
// 再次触发一轮强制刷新,覆盖 AG Grid 异步布局后的高度计算。
|
||||
setTimeout(() => {
|
||||
const liveApi = gridApi.value
|
||||
if (!isGridApiAlive(liveApi)) return
|
||||
liveApi.refreshCells({ force: true })
|
||||
liveApi.redrawRows()
|
||||
}, 16)
|
||||
}
|
||||
|
||||
const syncAutoRowHeights = async () => {
|
||||
await nextTick()
|
||||
const api = gridApi.value
|
||||
if (!isGridApiAlive(api)) return
|
||||
api.onRowHeightChanged()
|
||||
api.refreshCells({ force: true })
|
||||
api.redrawRows()
|
||||
forceRefreshCellsOnLiveApi()
|
||||
}
|
||||
const scheduleAutoRowHeights = () => {
|
||||
if (autoHeightSyncTimer) clearTimeout(autoHeightSyncTimer)
|
||||
autoHeightSyncTimer = setTimeout(() => {
|
||||
autoHeightSyncTimer = null
|
||||
if (!isGridApiAlive(gridApi.value)) return
|
||||
void syncAutoRowHeights()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const onGridSizeChanged = () => {
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
|
||||
const onColumnResized = () => {
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
|
||||
const onFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
|
||||
const onRowDataUpdated = (_event: RowDataUpdatedEvent<DetailRow>) => {
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) return JSON.stringify(params.value)
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'personnelCount') {
|
||||
return parseNonNegativeIntegerOrNull(params.value)
|
||||
}
|
||||
if (field === 'adoptedBudgetUnitPrice' || field === 'workdayCount') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (_error) {
|
||||
return params.value
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
scheduleAutoRowHeights()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
await loadFromIndexedDB()
|
||||
scheduleAutoRowHeights()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
void loadFromIndexedDB()
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => detailRows.value.length,
|
||||
() => {
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => {
|
||||
void relabelRowsFromExpertDict()
|
||||
}
|
||||
)
|
||||
|
||||
onDeactivated(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
gridApi.value = null
|
||||
if (autoHeightSyncTimer) {
|
||||
clearTimeout(autoHeightSyncTimer)
|
||||
autoHeightSyncTimer = null
|
||||
}
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ props.title || t('hourlyFeeGrid.title') }}</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue
|
||||
:style="agGridStyle"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="gridColumnDefs"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
:animateRows="true"
|
||||
:treeData="false"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@first-data-rendered="onFirstDataRendered"
|
||||
@row-data-updated="onRowDataUpdated"
|
||||
@grid-size-changed="onGridSizeChanged"
|
||||
@column-resized="onColumnResized"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.hourly-fee-name-cell.ag-cell-auto-height) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.hourly-fee-name-cell.ag-cell-auto-height .ag-cell-wrapper),
|
||||
:deep(.hourly-fee-name-cell.ag-cell-auto-height .ag-cell-value) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
|
||||
interface FeeRow {
|
||||
id: string
|
||||
feeItem: string
|
||||
unit: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
budgetFee: number | null
|
||||
remark: string
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
interface FeeGridState {
|
||||
detailRows: FeeRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
htMainStorageKey?: string
|
||||
htRowId?: string
|
||||
htMethodType?: 'quantity-unit-price-fee'
|
||||
}>()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
|
||||
|
||||
const createDefaultRow = (): FeeRow => ({
|
||||
id: createRowId(),
|
||||
feeItem: '',
|
||||
unit: '',
|
||||
quantity: null,
|
||||
unitPrice: null,
|
||||
budgetFee: null,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const createSubtotalRow = (): FeeRow => ({
|
||||
id: SUBTOTAL_ROW_ID,
|
||||
feeItem: t('htFeeDetail.subtotal'),
|
||||
unit: '',
|
||||
quantity: null,
|
||||
unitPrice: null,
|
||||
budgetFee: 0,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const isSubtotalRow = (row?: FeeRow | null) => row?.id === SUBTOTAL_ROW_ID
|
||||
|
||||
const ensureSubtotalRow = (rows: FeeRow[]) => {
|
||||
const normalRows = rows.filter(row => !isSubtotalRow(row))
|
||||
if (normalRows.length === 0) return []
|
||||
return [...normalRows, createSubtotalRow()]
|
||||
}
|
||||
|
||||
const fallbackDetailRows = ref<FeeRow[]>([])
|
||||
const gridApi = ref<GridApi<FeeRow> | null>(null)
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const pendingDeleteRowId = ref<string | null>(null)
|
||||
const pendingDeleteRowName = ref('')
|
||||
const useHtMethodState = computed(
|
||||
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
|
||||
)
|
||||
const detailRows = computed<FeeRow[]>({
|
||||
get: () => {
|
||||
if (!useHtMethodState.value) return fallbackDetailRows.value
|
||||
const state = zxFwPricingStore.getHtFeeMethodState<FeeGridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
const rows = state?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
},
|
||||
set: rows => {
|
||||
if (!useHtMethodState.value) {
|
||||
fallbackDetailRows.value = rows
|
||||
return
|
||||
}
|
||||
zxFwPricingStore.setHtFeeMethodState(props.htMainStorageKey!, props.htRowId!, props.htMethodType!, {
|
||||
detailRows: rows
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
|
||||
detailRows.value = ensureSubtotalRow([...normalRows, createDefaultRow()])
|
||||
syncComputedValuesToRows()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const deleteRow = (id: string) => {
|
||||
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row) && row.id !== id)
|
||||
detailRows.value = ensureSubtotalRow(normalRows)
|
||||
syncComputedValuesToRows()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const requestDeleteRow = (id: string, name?: string) => {
|
||||
pendingDeleteRowId.value = id
|
||||
pendingDeleteRowName.value = String(name || '').trim() || t('htFeeDetail.currentRow')
|
||||
deleteConfirmOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
||||
deleteConfirmOpen.value = open
|
||||
}
|
||||
|
||||
const confirmDeleteRow = () => {
|
||||
const id = pendingDeleteRowId.value
|
||||
if (!id) return
|
||||
deleteRow(id)
|
||||
deleteConfirmOpen.value = false
|
||||
pendingDeleteRowId.value = null
|
||||
pendingDeleteRowName.value = ''
|
||||
}
|
||||
|
||||
const formatEditableText = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
|
||||
return String(params.value)
|
||||
}
|
||||
|
||||
const formatEditableQuantity = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatEditableUnitPrice = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatReadonlyBudgetFee = (params: any) => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const syncComputedValuesToRows = () => {
|
||||
let totalBudgetFee = 0
|
||||
let hasValidRow = false
|
||||
for (const row of detailRows.value) {
|
||||
if (isSubtotalRow(row)) continue
|
||||
const hasFeeItem = typeof row.feeItem === 'string' && row.feeItem.trim() !== ''
|
||||
const hasUnit = typeof row.unit === 'string' && row.unit.trim() !== ''
|
||||
const quantity = typeof row.quantity === 'number' && Number.isFinite(row.quantity) ? row.quantity : null
|
||||
const unitPrice = typeof row.unitPrice === 'number' && Number.isFinite(row.unitPrice) ? row.unitPrice : null
|
||||
if (!hasFeeItem || !hasUnit || quantity == null || unitPrice == null) {
|
||||
row.budgetFee = null
|
||||
continue
|
||||
}
|
||||
row.budgetFee = roundTo(toDecimal(quantity).mul(unitPrice), 2)
|
||||
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
|
||||
hasValidRow = true
|
||||
totalBudgetFee = roundTo(toDecimal(totalBudgetFee).add(row.budgetFee), 2)
|
||||
}
|
||||
}
|
||||
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
|
||||
if (subtotalRow) {
|
||||
subtotalRow.feeItem = t('htFeeDetail.subtotal')
|
||||
subtotalRow.unit = ''
|
||||
subtotalRow.quantity = null
|
||||
subtotalRow.unitPrice = null
|
||||
subtotalRow.budgetFee = hasValidRow ? totalBudgetFee : null
|
||||
subtotalRow.remark = ''
|
||||
}
|
||||
}
|
||||
|
||||
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
|
||||
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows: FeeRow[] = rowsFromDb.map(item => {
|
||||
const row = item as Partial<FeeRow>
|
||||
return {
|
||||
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
||||
feeItem: typeof row.feeItem === 'string' ? row.feeItem : '',
|
||||
unit: typeof row.unit === 'string' ? row.unit : '',
|
||||
quantity: typeof row.quantity === 'number' ? row.quantity : null,
|
||||
unitPrice: typeof row.unitPrice === 'number' ? row.unitPrice : null,
|
||||
budgetFee: typeof row.budgetFee === 'number' ? row.budgetFee : null,
|
||||
remark: typeof row.remark === 'string' ? row.remark : ''
|
||||
}
|
||||
})
|
||||
|
||||
return ensureSubtotalRow(rows)
|
||||
}
|
||||
|
||||
const buildPersistDetailRows = () => {
|
||||
syncComputedValuesToRows()
|
||||
return detailRows.value.map(row => ({ ...row }))
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: FeeGridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||
}
|
||||
if (useHtMethodState.value) {
|
||||
zxFwPricingStore.setHtFeeMethodState(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!,
|
||||
payload,
|
||||
{ force: true }
|
||||
)
|
||||
} else {
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = useHtMethodState.value
|
||||
? await zxFwPricingStore.loadHtFeeMethodState<FeeGridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
: await zxFwPricingStore.loadKeyState<FeeGridState>(props.storageKey)
|
||||
detailRows.value = mergeWithStoredRows(data?.detailRows)
|
||||
syncComputedValuesToRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = []
|
||||
syncComputedValuesToRows()
|
||||
}
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<FeeRow>[] = [
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.no'),
|
||||
colId: 'rowNo',
|
||||
minWidth: 68,
|
||||
maxWidth: 80,
|
||||
flex: 0.6,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
cellStyle: { textAlign: 'center' },
|
||||
valueGetter: params =>
|
||||
params.node?.rowPinned
|
||||
? ''
|
||||
: isSubtotalRow(params.data)
|
||||
? t('htFeeDetail.subtotal')
|
||||
: typeof params.node?.rowIndex === 'number'
|
||||
? params.node.rowIndex + 1
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-summary-label-cell': params => isSubtotalRow(params.data)
|
||||
},
|
||||
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.feeItem'),
|
||||
field: 'feeItem',
|
||||
minWidth: 140,
|
||||
flex: 1.4,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.unit'),
|
||||
field: 'unit',
|
||||
minWidth: 90,
|
||||
flex: 0.9,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: 'editable-cell-line',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.quantity'),
|
||||
field: 'quantity',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'editable-cell-line',
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: formatEditableQuantity,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.unitPrice'),
|
||||
field: 'unitPrice',
|
||||
minWidth: 120,
|
||||
flex: 1.1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'editable-cell-line',
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||
valueFormatter: formatEditableUnitPrice,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.budgetFee'),
|
||||
field: 'budgetFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
editable: false,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: formatReadonlyBudgetFee
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.remark'),
|
||||
field: 'remark',
|
||||
minWidth: 170,
|
||||
flex: 2,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: ' remark-wrap-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeDetail.columns.actions'),
|
||||
field: 'actions',
|
||||
minWidth: 92,
|
||||
maxWidth: 110,
|
||||
flex: 0.8,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: defineComponent({
|
||||
name: 'HtFeeGridActionCellRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<FeeRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(rendererProps) {
|
||||
return () => {
|
||||
const row = rendererProps.params.data
|
||||
if (!row || isSubtotalRow(row)) return null
|
||||
const onDelete = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestDeleteRow(row.id, row.feeItem)
|
||||
}
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
|
||||
onClick: onDelete
|
||||
},
|
||||
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', t('common.delete'))]
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const detailGridOptions: GridOptions<FeeRow> = {
|
||||
...gridOptions,
|
||||
treeData: false
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
syncComputedValuesToRows()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
if (isBulkClipboardMutation) return
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'quantity') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
if (field === 'unitPrice') {
|
||||
return parseNumberOrNull(params.value, { precision: 2 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (_error) {
|
||||
return params.value
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
void loadFromIndexedDB()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<Button type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeDetail.addRow') }}</Button>
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue
|
||||
:style="agGridStyle"
|
||||
:rowData="detailRows"
|
||||
:columnDefs="gridColumnDefs"
|
||||
:gridOptions="detailGridOptions"
|
||||
:theme="myTheme"
|
||||
:animateRows="true"
|
||||
:treeData="false"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">{{ t('htFeeDetail.dialog.deleteTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
{{ t('htFeeDetail.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline">{{ t('common.cancel') }}</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,776 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
import { roundTo, sumNullableNumbers, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { Pencil, Eraser } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
|
||||
interface FeeMethodRow {
|
||||
id: string
|
||||
name: string
|
||||
rateFee: number | null
|
||||
hourlyFee: number | null
|
||||
quantityUnitPriceFee: number | null
|
||||
subtotal?: number | null
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
interface FeeMethodState {
|
||||
detailRows: FeeMethodRow[]
|
||||
}
|
||||
|
||||
interface MethodRateState {
|
||||
rate?: unknown
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
interface MethodHourlyRowLike {
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
serviceBudget?: unknown
|
||||
}
|
||||
|
||||
interface MethodHourlyState {
|
||||
detailRows?: MethodHourlyRowLike[]
|
||||
}
|
||||
|
||||
interface MethodQuantityRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
|
||||
interface MethodQuantityState {
|
||||
detailRows?: MethodQuantityRowLike[]
|
||||
}
|
||||
|
||||
interface LegacyFeeRow {
|
||||
id?: string
|
||||
feeItem?: string
|
||||
budgetFee?: number | null
|
||||
quantity?: number | null
|
||||
unitPrice?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
contractId?: string
|
||||
contractName?: string
|
||||
fixedNames?: any[]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
||||
id: createRowId(),
|
||||
name,
|
||||
rateFee: null,
|
||||
hourlyFee: null,
|
||||
quantityUnitPriceFee: null
|
||||
})
|
||||
const SUMMARY_ROW_ID = 'fee-method-summary'
|
||||
|
||||
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
|
||||
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
|
||||
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
|
||||
const total = sumNullableNumbers(rows.map(row => pick(row)))
|
||||
return total == null ? null : roundTo(total, 3)
|
||||
}
|
||||
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
|
||||
if (!row) return null
|
||||
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
|
||||
const total = sumNullableNumbers(values)
|
||||
if (total == null) return null
|
||||
return roundTo(total, 3)
|
||||
}
|
||||
|
||||
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null
|
||||
return sumNullableField(rows, row => getRowSubtotal(row))
|
||||
}
|
||||
|
||||
const loadAdditionalWorkFeeTotal = async (contractId: string): Promise<number | null> => {
|
||||
try {
|
||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||
const additionalState = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(additionalStorageKey)
|
||||
return sumMainStateSubtotal(additionalState?.detailRows)
|
||||
} catch (error) {
|
||||
console.error('loadAdditionalWorkFeeTotal failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const loadContractServiceFeeBase = async (): Promise<number | null> => {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return null
|
||||
try {
|
||||
await zxFwPricingStore.loadContract(contractId)
|
||||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||||
if (!isReserveStorageKey(props.storageKey)) {
|
||||
return serviceBase == null ? null : roundTo(serviceBase, 3)
|
||||
}
|
||||
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
|
||||
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
|
||||
if (!hasAnyBase) return null
|
||||
return roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalFeeTotal), 3)
|
||||
} catch (error) {
|
||||
console.error('loadContractServiceFeeBase failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
|
||||
|
||||
|
||||
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.serviceBudget!== null):[]
|
||||
|
||||
if (rows.length === 0) return null
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
|
||||
const rowBudget = toFiniteNumber(row?.serviceBudget)
|
||||
if (rowBudget != null) {
|
||||
total += rowBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteNumber(row?.personnelCount)
|
||||
const workday = toFiniteNumber(row?.workdayCount)
|
||||
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
||||
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.budgetFee!== null):[]
|
||||
if (rows.length === 0) return null
|
||||
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteNumber(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteNumber(row?.quantity)
|
||||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return rows
|
||||
const contractBase = await loadContractServiceFeeBase()
|
||||
const hydratedRows = await Promise.all(
|
||||
rows.map(async row => {
|
||||
if (!row?.id) return row
|
||||
const [rateData, hourlyData, quantityData] = await Promise.all([
|
||||
zxFwPricingStore.loadHtFeeMethodState<MethodRateState>(props.storageKey, row.id, 'rate-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<MethodHourlyState>(props.storageKey, row.id, 'hourly-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
|
||||
])
|
||||
|
||||
const storedRateFee = toFiniteNumber(rateData?.budgetFee)
|
||||
const rateValue = toFiniteNumber(rateData?.rate)
|
||||
const rateFee =
|
||||
contractBase != null && rateValue != null
|
||||
? roundTo(contractBase * rateValue / 100, 2)
|
||||
: storedRateFee != null
|
||||
? roundTo(storedRateFee, 2)
|
||||
: null
|
||||
|
||||
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
||||
const quantityUnitPriceFee = sumQuantityMethodFee(quantityData)
|
||||
|
||||
return {
|
||||
...row,
|
||||
rateFee,
|
||||
hourlyFee,
|
||||
quantityUnitPriceFee
|
||||
}
|
||||
})
|
||||
)
|
||||
return hydratedRows
|
||||
}
|
||||
|
||||
const fixedNames = computed(() =>
|
||||
Array.isArray(props.fixedNames)
|
||||
? props.fixedNames.map(item => ({name:item.name,id:item.id}))
|
||||
: []
|
||||
)
|
||||
const hasFixedNames = computed(() => fixedNames.value.length > 0)
|
||||
const fixedNamesSignature = computed(() =>
|
||||
JSON.stringify(
|
||||
fixedNames.value.map(item => ({
|
||||
id: String(item?.id || ''),
|
||||
name: String(item?.name || '')
|
||||
}))
|
||||
)
|
||||
)
|
||||
const detailRows = computed<FeeMethodRow[]>({
|
||||
get: () => {
|
||||
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
},
|
||||
set: rows => {
|
||||
zxFwPricingStore.setHtFeeMainState(props.storageKey, {
|
||||
detailRows: rows
|
||||
})
|
||||
}
|
||||
})
|
||||
const summaryRow = computed<FeeMethodRow>(() => {
|
||||
const rateFee = sumNullableField(detailRows.value, row => row.rateFee)
|
||||
const hourlyFee = sumNullableField(detailRows.value, row => row.hourlyFee)
|
||||
const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee)
|
||||
const result: FeeMethodRow = {
|
||||
id: SUMMARY_ROW_ID,
|
||||
name: t('htFeeGrid.subtotal'),
|
||||
rateFee,
|
||||
hourlyFee,
|
||||
quantityUnitPriceFee
|
||||
}
|
||||
result.subtotal = getRowSubtotal(result)
|
||||
return result
|
||||
})
|
||||
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
|
||||
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
|
||||
const clearConfirmOpen = ref(false)
|
||||
const pendingClearRowId = ref<string | null>(null)
|
||||
const pendingClearRowName = ref('')
|
||||
const lastSavedSnapshot = ref('')
|
||||
|
||||
const requestClearRow = (id: string, name?: string) => {
|
||||
pendingClearRowId.value = id
|
||||
pendingClearRowName.value = String(name || '').trim() || t('htFeeGrid.currentRow')
|
||||
clearConfirmOpen.value = true
|
||||
}
|
||||
|
||||
const handleClearConfirmOpenChange = (open: boolean) => {
|
||||
clearConfirmOpen.value = open
|
||||
}
|
||||
|
||||
const confirmClearRow = async () => {
|
||||
const id = pendingClearRowId.value
|
||||
if (!id) return
|
||||
await clearRow(id)
|
||||
clearConfirmOpen.value = false
|
||||
pendingClearRowId.value = null
|
||||
pendingClearRowName.value = ''
|
||||
}
|
||||
|
||||
const formatEditableText = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (isSummaryRow(params.data)) return ''
|
||||
return ''
|
||||
}
|
||||
return String(params.value)
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (isSummaryRow(params.data)) return ''
|
||||
return ''
|
||||
}
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const numericParser = (newValue: any): number | null =>
|
||||
parseNumberOrNull(newValue, { precision: 3 })
|
||||
|
||||
const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
|
||||
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) return row.budgetFee
|
||||
if (
|
||||
typeof row.quantity === 'number' &&
|
||||
Number.isFinite(row.quantity) &&
|
||||
typeof row.unitPrice === 'number' &&
|
||||
Number.isFinite(row.unitPrice)
|
||||
) {
|
||||
return roundTo(row.quantity * row.unitPrice, 2)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
|
||||
|
||||
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
|
||||
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
|
||||
)
|
||||
const rows = sourceRows.map(item => {
|
||||
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
|
||||
return {
|
||||
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
||||
name:
|
||||
typeof row.name === 'string'
|
||||
? row.name
|
||||
: (typeof row.feeItem === 'string' ? row.feeItem : ''),
|
||||
|
||||
rateFee: typeof row.rateFee === 'number' ? row.rateFee : null,
|
||||
hourlyFee: typeof row.hourlyFee === 'number' ? row.hourlyFee : null,
|
||||
quantityUnitPriceFee:
|
||||
typeof row.quantityUnitPriceFee === 'number'
|
||||
? row.quantityUnitPriceFee
|
||||
: toLegacyQuantityUnitPriceFee(row)
|
||||
} as FeeMethodRow
|
||||
})
|
||||
|
||||
if (hasFixedNames.value) {
|
||||
const byId = new Map(rows.map(row => [String(row.id || ''), row]))
|
||||
const byName = new Map(rows.map(row => [row.name, row]))
|
||||
return fixedNames.value.map((item, index) => {
|
||||
const rowId = String(item?.id || `fee-method-fixed-${index}`)
|
||||
const fromDb = byId.get(rowId) || byName.get(item.name)
|
||||
return {
|
||||
id: rowId,
|
||||
name: item.name,
|
||||
rateFee: fromDb?.rateFee ?? null,
|
||||
hourlyFee: fromDb?.hourlyFee ?? null,
|
||||
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
return rows.length > 0 ? rows : [createDefaultRow()]
|
||||
}
|
||||
|
||||
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
|
||||
|
||||
const saveToIndexedDB = async (force = false) => {
|
||||
try {
|
||||
const payload: FeeMethodState = {
|
||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||
}
|
||||
const snapshot = JSON.stringify(payload.detailRows)
|
||||
if (!force && snapshot === lastSavedSnapshot.value) return
|
||||
zxFwPricingStore.setHtFeeMainState(props.storageKey, payload, { force })
|
||||
if (String(props.storageKey || '').includes('-additional-work')) {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (contractId) {
|
||||
await zxFwPricingStore.syncHtExtraFeeByContractBase(contractId)
|
||||
}
|
||||
}
|
||||
lastSavedSnapshot.value = snapshot
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
|
||||
const mergedRows = mergeWithStoredRows(data?.detailRows)
|
||||
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||
await saveToIndexedDB(true)
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
const mergedRows = mergeWithStoredRows([])
|
||||
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||
await saveToIndexedDB(true)
|
||||
}
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
detailRows.value = [...detailRows.value, createDefaultRow()]
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const clearRow = async (id: string) => {
|
||||
tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
|
||||
await nextTick()
|
||||
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'rate-fee')
|
||||
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'hourly-fee')
|
||||
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'quantity-unit-price-fee')
|
||||
detailRows.value = detailRows.value.map(row =>
|
||||
row.id !== id
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
rateFee: null,
|
||||
hourlyFee: null,
|
||||
quantityUnitPriceFee: null
|
||||
}
|
||||
)
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
const editRow = (id: string) => {
|
||||
const row = detailRows.value.find(item => item.id === id)
|
||||
if (!row) return
|
||||
tabStore.openTab({
|
||||
id: `ht-fee-edit-${props.storageKey}-${id}`,
|
||||
title: t('htFeeGrid.editTabTitle', { name: row.name || t('htFeeGrid.unnamed') }),
|
||||
componentName: 'HtFeeMethodTypeLineView',
|
||||
props: {
|
||||
sourceTitle: props.title,
|
||||
storageKey: props.storageKey,
|
||||
rowId: id,
|
||||
rowName: row.name || '',
|
||||
contractId: props.contractId,
|
||||
contractName: props.contractName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ActionCellRenderer = defineComponent({
|
||||
name: 'HtFeeMethodActionCellRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<FeeMethodRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
|
||||
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const rowId = String(props.params.data?.id || '').trim()
|
||||
if (!rowId) return
|
||||
if (action === 'edit') {
|
||||
props.params.context?.onActionEdit?.(rowId)
|
||||
return
|
||||
}
|
||||
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
|
||||
}
|
||||
return h('div', { class: 'zxfw-action-wrap' }, [
|
||||
h('div', { class: 'zxfw-action-group' }, [
|
||||
h('button', {
|
||||
class: 'zxfw-action-btn',
|
||||
'data-action': 'edit',
|
||||
type: 'button',
|
||||
onClick: onActionClick('edit')
|
||||
}, [
|
||||
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', t('htFeeGrid.edit'))
|
||||
]),
|
||||
h('button', {
|
||||
class: 'zxfw-action-btn zxfw-action-btn--danger',
|
||||
'data-action': 'clear',
|
||||
type: 'button',
|
||||
onClick: onActionClick('clear')
|
||||
}, [
|
||||
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', t('htFeeGrid.clear'))
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||
{
|
||||
headerName: t('htFeeGrid.columns.name'),
|
||||
field: 'name',
|
||||
minWidth: 180,
|
||||
flex: 1.8,
|
||||
editable: false,
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: params =>
|
||||
params.context?.fixedNames === true || isSummaryRow(params.data)
|
||||
? ''
|
||||
: 'editable-cell-line',
|
||||
cellClassRules: {
|
||||
'ag-summary-label-cell': params => isSummaryRow(params.data),
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeGrid.columns.rateFee'),
|
||||
field: 'rateFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeGrid.columns.hourlyFee'),
|
||||
field: 'hourlyFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeGrid.columns.quantityUnitPriceFee'),
|
||||
field: 'quantityUnitPriceFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeGrid.columns.subtotal'),
|
||||
field: 'subtotal',
|
||||
minWidth: 140,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => getRowSubtotal(params.data),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: t('htFeeGrid.columns.actions'),
|
||||
field: 'actions',
|
||||
minWidth: 220,
|
||||
flex: 1.6,
|
||||
maxWidth: 260,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: ActionCellRenderer
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const detailGridOptions: GridOptions<FeeMethodRow> = {
|
||||
...gridOptions,
|
||||
treeData: false,
|
||||
getDataPath: undefined,
|
||||
context: {
|
||||
fixedNames: hasFixedNames.value,
|
||||
onActionEdit: editRow,
|
||||
onActionClear: clearRow,
|
||||
onActionRequestClear: requestClearRow
|
||||
},
|
||||
onCellClicked: params => {
|
||||
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
|
||||
const target = params.event?.target as HTMLElement | null
|
||||
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
||||
const action = btn?.dataset.action
|
||||
if (action === 'edit') {
|
||||
editRow(params.data.id)
|
||||
return
|
||||
}
|
||||
if (action === 'clear') {
|
||||
requestClearRow(params.data.id, params.data.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scheduleReloadFromStorage = () => {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
reloadTimer = setTimeout(() => {
|
||||
void loadFromIndexedDB()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
if (isSummaryRow(event.data)) return
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
scheduleReloadFromStorage()
|
||||
})
|
||||
|
||||
const storageKeyRef = computed(() => props.storageKey)
|
||||
const reserveAdditionalWorkStateSignature = computed(() => {
|
||||
if (!isReserveStorageKey(props.storageKey)) return ''
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return ''
|
||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
|
||||
})
|
||||
watch(storageKeyRef, () => {
|
||||
scheduleReloadFromStorage()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => JSON.stringify(zxFwPricingStore.htFeeMethodStates[props.storageKey] || null),
|
||||
(nextSignature, prevSignature) => {
|
||||
if (!nextSignature && !prevSignature) return
|
||||
if (nextSignature === prevSignature) return
|
||||
scheduleReloadFromStorage()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return ''
|
||||
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
|
||||
},
|
||||
(nextSig, prevSig) => {
|
||||
if (nextSig === prevSig) return
|
||||
scheduleReloadFromStorage()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
reserveAdditionalWorkStateSignature,
|
||||
(nextSig, prevSig) => {
|
||||
if (nextSig === prevSig) return
|
||||
scheduleReloadFromStorage()
|
||||
}
|
||||
)
|
||||
|
||||
watch([hasFixedNames], () => {
|
||||
if (!detailGridOptions.context) return
|
||||
detailGridOptions.context.fixedNames = hasFixedNames.value
|
||||
detailGridOptions.context.onActionEdit = editRow
|
||||
detailGridOptions.context.onActionClear = clearRow
|
||||
detailGridOptions.context.onActionRequestClear = requestClearRow
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
})
|
||||
|
||||
watch(
|
||||
fixedNamesSignature,
|
||||
async (nextSig, prevSig) => {
|
||||
if (!hasFixedNames.value) return
|
||||
if (!nextSig || nextSig === prevSig) return
|
||||
const nextRows = mergeWithStoredRows(detailRows.value)
|
||||
const changed = JSON.stringify(nextRows) !== JSON.stringify(detailRows.value)
|
||||
if (!changed) return
|
||||
detailRows.value = nextRows
|
||||
await saveToIndexedDB(true)
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeGrid.add') }}</Button>
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue
|
||||
:style="agGridStyle"
|
||||
:rowData="displayRows"
|
||||
:columnDefs="gridColumnDefs"
|
||||
:gridOptions="detailGridOptions"
|
||||
:theme="myTheme"
|
||||
:animateRows="true"
|
||||
:treeData="false"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">{{ t('htFeeGrid.dialog.clearTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
{{ t('htFeeGrid.dialog.clearDesc', { name: pendingClearRowName }) }}
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline">{{ t('common.cancel') }}</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmClearRow">{{ t('htFeeGrid.dialog.confirmClear') }}</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
message: string
|
||||
}>(),
|
||||
{
|
||||
title: undefined
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
|
||||
>
|
||||
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
|
||||
<p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title || t('methodUnavailable.defaultTitle') }}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface ServiceItem {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
services: ServiceItem[]
|
||||
serviceRows?: string[][]
|
||||
modelValue: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedSet = computed(() => new Set(props.modelValue))
|
||||
const serviceById = computed(() => new Map(props.services.map(item => [item.id, item])))
|
||||
const firstRowIds = computed(() => {
|
||||
const rows = Array.isArray(props.serviceRows) ? props.serviceRows : []
|
||||
if (rows.length === 0) return [] as string[]
|
||||
const firstRow = Array.isArray(rows[0]) ? rows[0] : []
|
||||
return firstRow.filter(id => serviceById.value.has(id))
|
||||
})
|
||||
const firstRowIdSet = computed(() => new Set(firstRowIds.value))
|
||||
const groupedRows = computed(() => {
|
||||
const rows = Array.isArray(props.serviceRows) ? props.serviceRows : []
|
||||
if (rows.length === 0) return [] as ServiceItem[][]
|
||||
|
||||
const used = new Set<string>()
|
||||
const grouped = rows
|
||||
.map(row => row.map(id => serviceById.value.get(id)).filter((item): item is ServiceItem => Boolean(item)))
|
||||
.map(row => row.filter(item => {
|
||||
if (used.has(item.id)) return false
|
||||
used.add(item.id)
|
||||
return true
|
||||
}))
|
||||
.filter(row => row.length > 0)
|
||||
|
||||
const leftovers = props.services.filter(item => !used.has(item.id))
|
||||
if (leftovers.length > 0) grouped.push(leftovers)
|
||||
return grouped
|
||||
})
|
||||
|
||||
const toggleService = (id: string, checked: boolean) => {
|
||||
const next = new Set(props.modelValue)
|
||||
if (checked) {
|
||||
if (firstRowIdSet.value.has(id)) {
|
||||
firstRowIds.value.forEach(firstId => next.delete(firstId))
|
||||
}
|
||||
next.add(id)
|
||||
} else {
|
||||
next.delete(id)
|
||||
}
|
||||
emit('update:modelValue', props.services.map(item => item.id).filter(itemId => next.has(itemId)))
|
||||
}
|
||||
|
||||
const isFirstRowDisabled = (id: string) => {
|
||||
if (!firstRowIdSet.value.has(id)) return false
|
||||
for (const selectedId of props.modelValue) {
|
||||
if (selectedId !== id && firstRowIdSet.value.has(selectedId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, firstRowIds.value] as const,
|
||||
() => {
|
||||
const firstSelected = props.modelValue.filter(id => firstRowIdSet.value.has(id))
|
||||
if (firstSelected.length <= 1) return
|
||||
const keepId = firstRowIds.value.find(id => firstSelected.includes(id)) || firstSelected[0]
|
||||
const next = props.modelValue.filter(id => !firstRowIdSet.value.has(id) || id === keepId)
|
||||
emit('update:modelValue', next)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const clearAll = () => {
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<label class="block shrink-0 text-sm font-semibold leading-none text-slate-900">{{ t('serviceSelector.title') }}</label>
|
||||
<span class="min-w-0 text-xs leading-5 text-muted-foreground">{{ t('serviceSelector.titleHint') }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
|
||||
@click="clearAll"
|
||||
>
|
||||
{{ t('serviceSelector.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-md border p-1.5">
|
||||
<div v-if="groupedRows.length > 0" class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(row, rowIndex) in groupedRows"
|
||||
:key="`service-row-${rowIndex}`"
|
||||
class="flex flex-wrap items-start gap-1 border-b border-slate-200 pb-1.5 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<label
|
||||
v-for="item in row"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'inline-flex w-fit max-w-full items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4 transition',
|
||||
isFirstRowDisabled(item.id)
|
||||
? 'cursor-not-allowed border-slate-300 bg-slate-100/80 text-slate-400 opacity-80'
|
||||
: 'cursor-pointer hover:bg-muted/60'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:class="[
|
||||
'mt-0.5',
|
||||
isFirstRowDisabled(item.id) ? 'cursor-not-allowed accent-slate-300' : 'cursor-pointer'
|
||||
]"
|
||||
:checked="selectedSet.has(item.id)"
|
||||
:disabled="isFirstRowDisabled(item.id)"
|
||||
@change="toggleService(item.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
|
||||
<span class="text-foreground break-words">{{ item.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{{ t('serviceSelector.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,857 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, PropType, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type {
|
||||
CellValueChangedEvent,
|
||||
ColDef,
|
||||
FirstDataRenderedEvent,
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
ICellRendererParams,
|
||||
IGroupCellRendererParams,
|
||||
IRowNode,
|
||||
ValueFormatterParams
|
||||
} from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { agGridDefaultColDef, myTheme, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
import { getServiceDictItemById, getWorkListEntries, wholeProcessTasks } from '@/sql'
|
||||
import { WorkType } from '@/sql'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
|
||||
interface WorkContentRow {
|
||||
id: string
|
||||
content: string
|
||||
type: WorkType
|
||||
dictOrder?: number
|
||||
serviceGroup?: string
|
||||
serviceid?: number | null
|
||||
remark: string
|
||||
checked: boolean
|
||||
custom: boolean
|
||||
isAddTrigger?: boolean
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface WorkContentState {
|
||||
detailRows: WorkContentRow[]
|
||||
}
|
||||
|
||||
// dictMode: 'service' 按serviceId筛选workList;'additional' 取serviceid=-1的数据;'none' 不加载词典
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
storageKey: string
|
||||
serviceId?: number | string
|
||||
contractId?: string
|
||||
projectInfoKey?: string
|
||||
dictMode?: 'service' | 'additional' | 'none'
|
||||
}>(), {
|
||||
title: '',
|
||||
projectInfoKey: 'xm-base-info-v1',
|
||||
dictMode: 'none'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
checkedChange: [value: string[]]
|
||||
}>()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
const gridApi = ref<GridApi<WorkContentRow> | null>(null)
|
||||
const rowData = ref<WorkContentRow[]>([])
|
||||
const isWholeProcessGroupedMode = ref(false)
|
||||
const groupedServiceGroups = ref<string[]>([])
|
||||
const defaultColDef = {
|
||||
...(agGridDefaultColDef as ColDef<WorkContentRow>),
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
|
||||
const syncGroupedRowsRender = async () => {
|
||||
await nextTick()
|
||||
const api = gridApi.value
|
||||
if (!api || api.isDestroyed?.()) return
|
||||
if (isWholeProcessGroupedMode.value) {
|
||||
api.expandAll()
|
||||
}
|
||||
api.refreshClientSideRowModel('group')
|
||||
api.refreshCells({ force: true })
|
||||
api.redrawRows()
|
||||
setTimeout(() => {
|
||||
const liveApi = gridApi.value
|
||||
if (!liveApi || liveApi.isDestroyed?.()) return
|
||||
if (isWholeProcessGroupedMode.value) {
|
||||
liveApi.expandAll()
|
||||
}
|
||||
liveApi.refreshCells({ force: true })
|
||||
liveApi.redrawRows()
|
||||
}, 16)
|
||||
}
|
||||
|
||||
const toServiceId = (value: unknown): number | null => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed)) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
const getStableDictRowKeyFromId = (idRaw: unknown) => {
|
||||
const id = String(idRaw || '').trim()
|
||||
if (!id.startsWith('dict-')) return ''
|
||||
const matched = /^dict-(-?\d+)-(\d+)(?:-|$)/.exec(id)
|
||||
if (!matched) return ''
|
||||
return `sid:${matched[1]}|order:${matched[2]}`
|
||||
}
|
||||
|
||||
const getDictRowMergeKey = (row: Pick<WorkContentRow, 'id' | 'dictOrder' | 'content' | 'serviceid' | 'serviceGroup'>) => {
|
||||
const fromId = getStableDictRowKeyFromId(row.id)
|
||||
if (fromId) return fromId
|
||||
const content = String(row.content || '').trim()
|
||||
const serviceid = toServiceId(row.serviceid)
|
||||
const dictOrder = Number(row.dictOrder)
|
||||
if (serviceid != null && Number.isFinite(dictOrder)) return `sid:${serviceid}|order:${dictOrder}`
|
||||
if (serviceid != null) return `sid:${serviceid}|content:${content}`
|
||||
const groupName = String(row.serviceGroup || '').trim()
|
||||
if (groupName) return `group:${groupName}|content:${content}`
|
||||
return `content:${content}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
const loadProjectIndustryId = async () => {
|
||||
try {
|
||||
const baseInfo = await kvStore.getItem<{ projectIndustry?: unknown }>(props.projectInfoKey)
|
||||
const raw = typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
if (raw.toUpperCase() === 'E2') return 0
|
||||
if (raw.toUpperCase() === 'E3') return 1
|
||||
if (raw.toUpperCase() === 'E4') return 2
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRowsFromDict = async (): Promise<WorkContentRow[]> => {
|
||||
const rows: WorkContentRow[] = []
|
||||
const entries = getWorkListEntries(locale.value) as Array<{ text: string; serviceid: number; order: number; type: number }>
|
||||
|
||||
let filtered: typeof entries = []
|
||||
let groupedServiceIds: number[] = []
|
||||
let groupedBy: 'fid' | 'sid' | null = null
|
||||
let matchedWholeProcessGroup: { fid: number; industry: number; sid: number[] } | null = null
|
||||
isWholeProcessGroupedMode.value = false
|
||||
groupedServiceGroups.value = []
|
||||
if (props.dictMode === 'service') {
|
||||
const sid = Number(props.serviceId)
|
||||
const industryId = await loadProjectIndustryId()
|
||||
const wholeProcessGroupByFid = wholeProcessTasks.find(
|
||||
item => Number(item.fid) === sid && Number(item.industry) === industryId
|
||||
)
|
||||
|
||||
const wholeProcessGroup = wholeProcessGroupByFid
|
||||
groupedBy = wholeProcessGroupByFid ? 'fid' : null
|
||||
if (wholeProcessGroup) {
|
||||
groupedServiceIds = Array.isArray(wholeProcessGroup.sid)
|
||||
? wholeProcessGroup.sid.map(id => Number(id)).filter(Number.isFinite)
|
||||
: []
|
||||
matchedWholeProcessGroup = {
|
||||
fid: Number(wholeProcessGroup.fid),
|
||||
industry: Number(wholeProcessGroup.industry),
|
||||
sid: [...groupedServiceIds]
|
||||
}
|
||||
const groupedSet = new Set(groupedServiceIds)
|
||||
filtered = entries.filter(e => groupedSet.has(Number(e.serviceid)))
|
||||
isWholeProcessGroupedMode.value = groupedServiceIds.length > 0
|
||||
groupedServiceGroups.value = groupedServiceIds.map(sid => {
|
||||
const serviceItem = getServiceDictItemById(sid) as { code?: string; name?: string } | undefined
|
||||
return serviceItem
|
||||
? `${String(serviceItem.code || '').trim()} ${String(serviceItem.name || '').trim()}`.trim()
|
||||
: String(sid)
|
||||
})
|
||||
} else {
|
||||
filtered = entries.filter(e => e.serviceid === sid)
|
||||
}
|
||||
|
||||
} else if (props.dictMode === 'additional') {
|
||||
filtered = entries.filter(e => e.serviceid === -1 && props.storageKey.split('-').at(-1) =='2')
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
if (isWholeProcessGroupedMode.value) {
|
||||
const sidIndex = new Map(groupedServiceIds.map((sid, index) => [sid, index]))
|
||||
filtered.sort((a, b) => {
|
||||
const indexA = sidIndex.get(Number(a.serviceid)) ?? Number.MAX_SAFE_INTEGER
|
||||
const indexB = sidIndex.get(Number(b.serviceid)) ?? Number.MAX_SAFE_INTEGER
|
||||
if (indexA !== indexB) return indexA - indexB
|
||||
return a.order - b.order
|
||||
})
|
||||
} else {
|
||||
filtered.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
for (const entry of filtered) {
|
||||
const content = String(entry.text || '').trim()
|
||||
if (!content) continue
|
||||
const typeLabel = ((): WorkType => {
|
||||
if (entry.type === 1) return t('workContent.type.optional') as WorkType
|
||||
if (entry.type === 2) return t('workContent.type.daily') as WorkType
|
||||
if (entry.type === 3) return t('workContent.type.special') as WorkType
|
||||
if (entry.type === 4) return t('workContent.type.additional') as WorkType
|
||||
return t('workContent.type.basic') as WorkType
|
||||
})()
|
||||
const serviceItem = getServiceDictItemById(entry.serviceid) as { code?: string; name?: string } | undefined
|
||||
const serviceGroup = serviceItem
|
||||
? `${String(serviceItem.code || '').trim()} ${String(serviceItem.name || '').trim()}`.trim()
|
||||
: ''
|
||||
rows.push({
|
||||
id: `dict-${entry.serviceid}-${entry.order}`,
|
||||
content,
|
||||
type: typeLabel,
|
||||
dictOrder: entry.order,
|
||||
serviceGroup,
|
||||
serviceid: toServiceId(entry.serviceid),
|
||||
remark: '',
|
||||
checked: true,
|
||||
custom: false,
|
||||
path: isWholeProcessGroupedMode.value && serviceGroup
|
||||
? [serviceGroup, content]
|
||||
: [typeLabel, content]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const pendingDeleteRowId = ref<string | null>(null)
|
||||
const pendingDeleteRowName = ref('')
|
||||
const requestDeleteRow = (id: string, name?: string) => {
|
||||
pendingDeleteRowId.value = id
|
||||
pendingDeleteRowName.value = String(name || '').trim() || t('workContent.currentRow')
|
||||
deleteConfirmOpen.value = true
|
||||
}
|
||||
const checkedIds = computed(() =>
|
||||
rowData.value.filter(item => !isAddTriggerRow(item) && item.checked).map(item => item.id)
|
||||
)
|
||||
|
||||
// 导出用:自定义内容全部包含,默认词典内容只含勾选的
|
||||
const selectedTexts = computed(() =>
|
||||
rowData.value
|
||||
.filter(item => !isAddTriggerRow(item) && (item.custom || item.checked))
|
||||
.map(item => item.content)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
defineExpose({ selectedTexts })
|
||||
|
||||
const emitCheckedChange = () => {
|
||||
emit('checkedChange', [...checkedIds.value])
|
||||
}
|
||||
|
||||
const saveToStore = () => {
|
||||
const payload: WorkContentState = {
|
||||
detailRows: getPersistableRows(rowData.value).map(item => ({ ...item }))
|
||||
}
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||||
emitCheckedChange()
|
||||
}
|
||||
|
||||
const loadFromStore = async () => {
|
||||
const defaultRows =
|
||||
props.dictMode === 'none'
|
||||
? []
|
||||
: await buildDefaultRowsFromDict()
|
||||
const state = await zxFwPricingStore.loadKeyState<WorkContentState>(props.storageKey)
|
||||
if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) {
|
||||
const persistedRows = state.detailRows.map(item => ({
|
||||
...item,
|
||||
type: item.custom ? t('workContent.type.custom') : (item.type || t('workContent.type.basic')),
|
||||
checked: item.custom ? false : item.checked !== false,
|
||||
serviceid: toServiceId(item.serviceid),
|
||||
path: Array.isArray(item.path) && item.path.length ? item.path : [t('workContent.type.custom'), item.content || t('workContent.unnamed')]
|
||||
})) as WorkContentRow[]
|
||||
|
||||
const defaultGroupServiceIdMap = new Map<string, number>()
|
||||
for (const row of defaultRows) {
|
||||
const groupName = String(row.serviceGroup || '').trim()
|
||||
const serviceid = toServiceId(row.serviceid)
|
||||
if (!groupName || serviceid == null) continue
|
||||
defaultGroupServiceIdMap.set(groupName, serviceid)
|
||||
}
|
||||
for (const row of persistedRows) {
|
||||
if (row.serviceid != null) continue
|
||||
const groupName = String(row.serviceGroup || '').trim()
|
||||
if (!groupName) continue
|
||||
const fallbackServiceId = defaultGroupServiceIdMap.get(groupName)
|
||||
if (fallbackServiceId != null) {
|
||||
row.serviceid = fallbackServiceId
|
||||
}
|
||||
}
|
||||
|
||||
// 按最新词典规则重建默认行,再合并历史勾选/备注,保证分组规则变更后立即生效。
|
||||
if (defaultRows.length > 0) {
|
||||
const persistedCustomRows = persistedRows.filter(item => item.custom)
|
||||
const persistedDictRows = persistedRows.filter(item => !item.custom)
|
||||
const persistedByKey = new Map(
|
||||
persistedDictRows.map(item => [getDictRowMergeKey(item), item])
|
||||
)
|
||||
const mergedDictRows = defaultRows.map(item => {
|
||||
const key = getDictRowMergeKey(item)
|
||||
const old = persistedByKey.get(key)
|
||||
if (!old) return item
|
||||
return {
|
||||
...item,
|
||||
checked: old.checked !== false,
|
||||
remark: String(old.remark || '')
|
||||
}
|
||||
})
|
||||
rowData.value = withAddTriggerRows([...mergedDictRows, ...persistedCustomRows])
|
||||
saveToStore()
|
||||
} else {
|
||||
rowData.value = withAddTriggerRows(persistedRows)
|
||||
isWholeProcessGroupedMode.value = rowData.value.some(
|
||||
item => !item.custom && Boolean(String(item.serviceGroup || '').trim())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
rowData.value = withAddTriggerRows(defaultRows)
|
||||
saveToStore()
|
||||
}
|
||||
emitCheckedChange()
|
||||
await syncGroupedRowsRender()
|
||||
}
|
||||
|
||||
const handleCheckedToggle = (id: string, checked: boolean) => {
|
||||
const target = rowData.value.find(item => item.id === id)
|
||||
if (!target || isAddTriggerRow(target)) return
|
||||
target.checked = checked
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
gridApi.value?.redrawRows()
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
const getGroupCheckableRows = (node?: IRowNode<WorkContentRow> | null) => {
|
||||
if (!node) return []
|
||||
return (node.allLeafChildren || [])
|
||||
.map(item => item.data)
|
||||
.filter((item): item is WorkContentRow => Boolean(item && !isAddTriggerRow(item) && !item.custom))
|
||||
}
|
||||
|
||||
const handleGroupCheckedToggle = (node: IRowNode<WorkContentRow>, checked: boolean) => {
|
||||
const groupRows = getGroupCheckableRows(node)
|
||||
if (groupRows.length === 0) return
|
||||
const targetIds = new Set(groupRows.map(item => item.id))
|
||||
let changed = false
|
||||
for (const row of rowData.value) {
|
||||
if (!targetIds.has(row.id)) continue
|
||||
if (row.checked === checked) continue
|
||||
row.checked = checked
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
gridApi.value?.redrawRows()
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
const groupRowRendererParams = computed<IGroupCellRendererParams<WorkContentRow> | undefined>(() => {
|
||||
if (!isWholeProcessGroupedMode.value) return undefined
|
||||
return {
|
||||
suppressCount: true,
|
||||
innerRenderer: (params: ICellRendererParams<WorkContentRow>) => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'work-content-group-row'
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.className = 'work-content-group-check'
|
||||
const rows = getGroupCheckableRows(params.node)
|
||||
const checkedCount = rows.filter(item => item.checked).length
|
||||
checkbox.checked = rows.length > 0 && checkedCount === rows.length
|
||||
checkbox.indeterminate = checkedCount > 0 && checkedCount < rows.length
|
||||
checkbox.disabled = rows.length === 0
|
||||
checkbox.addEventListener('mousedown', event => event.stopPropagation())
|
||||
checkbox.addEventListener('click', event => event.stopPropagation())
|
||||
checkbox.addEventListener('change', event => {
|
||||
event.stopPropagation()
|
||||
handleGroupCheckedToggle(params.node, checkbox.checked)
|
||||
})
|
||||
const label = document.createElement('span')
|
||||
label.className = 'work-content-group-label'
|
||||
label.textContent = String(params.valueFormatted || params.value || params.node.key || '')
|
||||
wrapper.append(checkbox, label)
|
||||
return wrapper
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
|
||||
const data = params.data
|
||||
if (!data) return ''
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.display = 'flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
wrapper.style.justifyContent = 'space-between'
|
||||
wrapper.style.gap = '6px'
|
||||
wrapper.style.width = '100%'
|
||||
wrapper.className = 'work-content-cell'
|
||||
if (isAddTriggerRow(data)) {
|
||||
const label = document.createElement('span')
|
||||
label.className = 'work-content-placeholder'
|
||||
label.textContent = String(data.content || t('workContent.addCustom'))
|
||||
wrapper.appendChild(label)
|
||||
return wrapper
|
||||
}
|
||||
// 自定义行不显示 checkbox,直接显示文本;空时显示 placeholder
|
||||
if (data.custom) {
|
||||
const label = document.createElement('span')
|
||||
if (!data.content) {
|
||||
label.className = 'work-content-placeholder'
|
||||
label.textContent = t('workContent.clickToInputContent')
|
||||
} else {
|
||||
label.className = 'work-content-text'
|
||||
label.textContent = data.content
|
||||
}
|
||||
wrapper.appendChild(label)
|
||||
return wrapper
|
||||
}
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.className = 'work-content-check'
|
||||
checkbox.checked = Boolean(data.checked)
|
||||
checkbox.addEventListener('change', () => {
|
||||
handleCheckedToggle(data.id, checkbox.checked)
|
||||
})
|
||||
const label = document.createElement('span')
|
||||
label.className = 'work-content-text'
|
||||
label.textContent = String(data.content || '')
|
||||
wrapper.appendChild(checkbox)
|
||||
wrapper.appendChild(label)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<WorkContentRow>[] = [
|
||||
{
|
||||
headerName: t('workContent.columns.no'),
|
||||
minWidth: 60,
|
||||
width: 70,
|
||||
suppressMovable: true,
|
||||
editable: false,
|
||||
colSpan: params => (isAddTriggerRow(params.data) ? 5 : 1),
|
||||
valueGetter: params => {
|
||||
if (!params.node || params.node.group || isAddTriggerRow(params.data)) return ''
|
||||
if (!isWholeProcessGroupedMode.value) return (params.node.rowIndex ?? 0) + 1
|
||||
const siblings = params.node.parent?.childrenAfterSort || []
|
||||
const visibleLeafSiblings = siblings.filter(node => !node.group && !isAddTriggerRow(node.data as WorkContentRow))
|
||||
const index = visibleLeafSiblings.findIndex(node => node.id === params.node?.id)
|
||||
return index >= 0 ? index + 1 : ''
|
||||
},
|
||||
cellRenderer: (params: ICellRendererParams<WorkContentRow>) => {
|
||||
const row = params.data
|
||||
if (!isAddTriggerRow(row)) return params.value
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className =
|
||||
'inline-flex h-full w-full cursor-pointer items-center justify-center rounded-none border-0 bg-transparent px-3 py-3 text-sm font-medium text-blue-700 hover:bg-transparent focus:outline-none'
|
||||
button.textContent = `+ ${t('workContent.addCustom')}`
|
||||
button.addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
addCustomRow(String(row?.serviceGroup || '').trim())
|
||||
})
|
||||
return button
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('workContent.columns.content'),
|
||||
field: 'content',
|
||||
minWidth: 320,
|
||||
flex: 2,
|
||||
cellClass: 'work-content-main-cell',
|
||||
editable: params => Boolean(params.data?.custom && !isAddTriggerRow(params.data)),
|
||||
|
||||
valueParser: params => String(params.newValue || '').trim(),
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.5' },
|
||||
cellRenderer: contentCellRenderer
|
||||
},
|
||||
{
|
||||
headerName: t('workContent.columns.type'),
|
||||
field: 'type',
|
||||
minWidth: 100,
|
||||
width: 120,
|
||||
editable: false,
|
||||
valueFormatter: (params: ValueFormatterParams<WorkContentRow>) =>
|
||||
isAddTriggerRow(params.data) ? '' : String(params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: t('workContent.columns.remark'),
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
editable: params => !isAddTriggerRow(params.data),
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
cellClass: 'remark-wrap-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
},
|
||||
valueFormatter: params => (isAddTriggerRow(params.data) ? '' : (params.value || t('workContent.clickToInput')))
|
||||
},
|
||||
{
|
||||
headerName: t('workContent.columns.actions'),
|
||||
colId: 'actions',
|
||||
minWidth: 92,
|
||||
maxWidth: 110,
|
||||
flex: 0.8,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: defineComponent({
|
||||
name: 'HtFeeGridActionCellRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<WorkContentRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(rendererProps) {
|
||||
return () => {
|
||||
const row = rendererProps.params.data
|
||||
if (!row?.custom) return null
|
||||
const onDelete = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestDeleteRow(row.id, row.content)
|
||||
}
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
|
||||
onClick: onDelete
|
||||
},
|
||||
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', t('common.delete'))]
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const isAddTriggerRow = (row?: WorkContentRow | null) => Boolean(row?.isAddTrigger)
|
||||
const getPersistableRows = (rows: WorkContentRow[]) => rows.filter(item => !isAddTriggerRow(item))
|
||||
|
||||
const createAddTriggerRow = (groupName?: string): WorkContentRow => {
|
||||
const suffix = groupName ? String(groupName).trim() : 'root'
|
||||
return {
|
||||
id: `add-trigger-${suffix}`,
|
||||
content: t('workContent.addCustom'),
|
||||
type: t('workContent.type.custom') as WorkType,
|
||||
serviceGroup: groupName || '',
|
||||
serviceid: null,
|
||||
remark: '',
|
||||
checked: false,
|
||||
custom: false,
|
||||
isAddTrigger: true,
|
||||
path: groupName ? [groupName, '__add__'] : ['__add__']
|
||||
}
|
||||
}
|
||||
|
||||
const withAddTriggerRows = (rows: WorkContentRow[]) => {
|
||||
const pureRows = getPersistableRows(rows)
|
||||
if (isWholeProcessGroupedMode.value) {
|
||||
const groupedMap = new Map<string, WorkContentRow[]>()
|
||||
for (const row of pureRows) {
|
||||
const groupName = String(row.serviceGroup || '').trim() || t('workContent.ungrouped')
|
||||
if (!groupedMap.has(groupName)) groupedMap.set(groupName, [])
|
||||
groupedMap.get(groupName)?.push(row)
|
||||
}
|
||||
const groupOrder = groupedServiceGroups.value.length
|
||||
? [...groupedServiceGroups.value]
|
||||
: [...groupedMap.keys()]
|
||||
const result: WorkContentRow[] = []
|
||||
const used = new Set<string>()
|
||||
for (const groupName of groupOrder) {
|
||||
used.add(groupName)
|
||||
result.push(...(groupedMap.get(groupName) || []))
|
||||
result.push(createAddTriggerRow(groupName))
|
||||
}
|
||||
for (const [groupName, groupRows] of groupedMap.entries()) {
|
||||
if (used.has(groupName)) continue
|
||||
result.push(...groupRows)
|
||||
result.push(createAddTriggerRow(groupName))
|
||||
}
|
||||
return result
|
||||
}
|
||||
return [...pureRows, createAddTriggerRow()]
|
||||
}
|
||||
|
||||
const getDataPath = (data: WorkContentRow) => {
|
||||
const path = Array.isArray(data?.path)
|
||||
? data.path.map(segment => String(segment || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (path.length > 0) return path
|
||||
const fallback = String(data?.id || '').trim()
|
||||
return [fallback || '__row__']
|
||||
}
|
||||
|
||||
const addCustomRow = (groupName?: string) => {
|
||||
const ts = Date.now()
|
||||
const finalGroupName = isWholeProcessGroupedMode.value
|
||||
? String(groupName || groupedServiceGroups.value[0] || '').trim()
|
||||
: ''
|
||||
const finalServiceId = isWholeProcessGroupedMode.value
|
||||
? (() => {
|
||||
const pureRows = getPersistableRows(rowData.value)
|
||||
const hit = pureRows.find(item => String(item.serviceGroup || '').trim() === finalGroupName && item.serviceid != null)
|
||||
return hit?.serviceid ?? null
|
||||
})()
|
||||
: null
|
||||
const nextRow: WorkContentRow = {
|
||||
id: `custom-${ts}`,
|
||||
content: '',
|
||||
type: t('workContent.type.custom') as WorkType,
|
||||
serviceGroup: finalGroupName,
|
||||
serviceid: finalServiceId,
|
||||
remark: '',
|
||||
checked: false,
|
||||
custom: true,
|
||||
path: isWholeProcessGroupedMode.value && finalGroupName
|
||||
? [finalGroupName, `${t('workContent.type.custom')}-${ts}`]
|
||||
: [t('workContent.type.custom'), `${t('workContent.type.custom')}-${ts}`]
|
||||
}
|
||||
const pureRows = getPersistableRows(rowData.value)
|
||||
pureRows.push(nextRow)
|
||||
rowData.value = withAddTriggerRows(pureRows)
|
||||
saveToStore()
|
||||
setTimeout(() => {
|
||||
const rowIndex = rowData.value.findIndex(item => item.id === nextRow.id)
|
||||
if (rowIndex >= 0) gridApi.value?.startEditingCell({ rowIndex, colKey: 'content' })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const onGridReady = (event: GridReadyEvent<WorkContentRow>) => {
|
||||
gridApi.value = event.api
|
||||
void syncGroupedRowsRender()
|
||||
}
|
||||
|
||||
const onFirstDataRendered = (_event: FirstDataRenderedEvent<WorkContentRow>) => {
|
||||
void syncGroupedRowsRender()
|
||||
}
|
||||
|
||||
const onCellValueChanged = (event: CellValueChangedEvent<WorkContentRow>) => {
|
||||
const row = event.data
|
||||
if (!row || isAddTriggerRow(row)) return
|
||||
if (event.colDef.field === 'content' && row.custom) {
|
||||
const groupName = String(row.serviceGroup || '').trim()
|
||||
row.path = isWholeProcessGroupedMode.value && groupName
|
||||
? [groupName, row.content || `${t('workContent.type.custom')}-${row.id}`]
|
||||
: [t('workContent.type.custom'), row.content || `${t('workContent.type.custom')}-${row.id}`]
|
||||
}
|
||||
if (event.colDef.field === 'type' && row.custom) {
|
||||
row.type = t('workContent.type.custom') as WorkType
|
||||
}
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFromStore()
|
||||
})
|
||||
|
||||
watch(isWholeProcessGroupedMode, () => {
|
||||
void syncGroupedRowsRender()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => rowData.value.length,
|
||||
() => {
|
||||
void syncGroupedRowsRender()
|
||||
}
|
||||
)
|
||||
|
||||
watch(locale, () => {
|
||||
void loadFromStore()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
saveToStore()
|
||||
})
|
||||
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
||||
deleteConfirmOpen.value = open
|
||||
}
|
||||
|
||||
|
||||
const deleteRow = (id: string) => {
|
||||
rowData.value = withAddTriggerRows(rowData.value.filter(item => item.id !== id))
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
const confirmDeleteRow = () => {
|
||||
const id = pendingDeleteRowId.value
|
||||
if (!id) return
|
||||
deleteRow(id)
|
||||
deleteConfirmOpen.value = false
|
||||
pendingDeleteRowId.value = null
|
||||
pendingDeleteRowName.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 xmMx">
|
||||
<div class="h-full min-h-0 rounded-2xl border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ props.title || t('workContent.title') }}</h3>
|
||||
</div>
|
||||
<div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full">
|
||||
<AgGridVue
|
||||
:style="agGridStyle"
|
||||
:rowData="rowData"
|
||||
:columnDefs="gridColumnDefs"
|
||||
:theme="myTheme"
|
||||
:getRowId="(params: { data: WorkContentRow }) => params.data.id"
|
||||
:treeData="isWholeProcessGroupedMode"
|
||||
:getDataPath="getDataPath"
|
||||
:groupDefaultExpanded="isWholeProcessGroupedMode ? -1 : 0"
|
||||
:groupDisplayType="isWholeProcessGroupedMode ? 'groupRows' : undefined"
|
||||
:groupRowRendererParams="groupRowRendererParams"
|
||||
:animateRows="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:singleClickEdit="true"
|
||||
:stopEditingWhenCellsLoseFocus="true"
|
||||
:enterNavigatesVertically="true"
|
||||
:enterNavigatesVerticallyAfterEdit="true"
|
||||
:defaultColDef="defaultColDef"
|
||||
:suppressColumnVirtualisation="false"
|
||||
:suppressRowVirtualisation="false"
|
||||
@grid-ready="onGridReady"
|
||||
@first-data-rendered="onFirstDataRendered"
|
||||
@cell-value-changed="onCellValueChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">{{ t('workContent.dialog.deleteTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
{{ t('workContent.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline">{{ t('common.cancel') }}</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ag-cell) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.ag-cell .ag-cell-wrapper) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ag-cell .ag-cell-value) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
:deep(.work-content-placeholder) {
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.work-content-cell) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.work-content-text) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:deep(.work-content-check) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.work-content-group-row) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.work-content-group-check) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.work-content-group-check:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:deep(.work-content-group-label) {
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.work-content-main-cell.ag-cell-auto-height),
|
||||
:deep(.remark-wrap-cell.ag-cell-auto-height) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.work-content-main-cell.ag-cell-auto-height .ag-cell-wrapper),
|
||||
:deep(.work-content-main-cell.ag-cell-auto-height .ag-cell-value),
|
||||
:deep(.remark-wrap-cell.ag-cell-auto-height .ag-cell-wrapper),
|
||||
:deep(.remark-wrap-cell.ag-cell-auto-height .ag-cell-value) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,508 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
|
||||
interface DictItem {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
order?: number | null
|
||||
}
|
||||
|
||||
interface FactorRow {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
standardFactor: number | null
|
||||
budgetValue: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface GridState {
|
||||
detailRows: FactorRow[]
|
||||
}
|
||||
|
||||
interface FactorChangeState {
|
||||
changedRowIds: string[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type DictSource = Record<string, DictItem>
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
parentStorageKey?: string
|
||||
dict: DictSource
|
||||
disableBudgetEditWhenStandardNull?: boolean
|
||||
excludeNotshowByZxflxs?: boolean
|
||||
initBudgetValueFromStandard?: boolean
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
const detailRows = ref<FactorRow[]>([])
|
||||
const gridApi = ref<GridApi<FactorRow> | null>(null)
|
||||
const CHANGE_STORAGE_KEY = computed(() => `${props.storageKey}-change`)
|
||||
|
||||
const formatReadonlyFactor = (value: unknown) => {
|
||||
if (value == null || value === '') return ''
|
||||
const parsed = parseNumberOrNull(value, { precision: 3 })
|
||||
if (parsed == null) return ''
|
||||
return String(Number(parsed))
|
||||
}
|
||||
|
||||
const formatEditableFactor = (params: any) => {
|
||||
if (params.value == null || params.value === '') return t('xmFactorGrid.clickToInput')
|
||||
const parsed = parseNumberOrNull(params.value, { precision: 3 })
|
||||
if (parsed == null) return ''
|
||||
return String(Number(parsed))
|
||||
}
|
||||
|
||||
const sortedDictEntries = () =>
|
||||
Object.entries(props.dict)
|
||||
.filter((entry): entry is [string, DictItem] => {
|
||||
const item = entry[1]
|
||||
if (!item?.code || !item?.name) return false
|
||||
if (props.excludeNotshowByZxflxs && item.notshowByzxflxs === true) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aOrder = Number(a[1]?.order)
|
||||
const bOrder = Number(b[1]?.order)
|
||||
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
|
||||
if (Number.isFinite(aOrder) && !Number.isFinite(bOrder)) return -1
|
||||
if (!Number.isFinite(aOrder) && Number.isFinite(bOrder)) return 1
|
||||
return String(a[1]?.code || a[0]).localeCompare(String(b[1]?.code || b[0]))
|
||||
})
|
||||
|
||||
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
|
||||
const parts = code.split('-').filter(Boolean)
|
||||
if (!parts.length) return [selfId]
|
||||
|
||||
const path: string[] = []
|
||||
let currentCode = parts[0]
|
||||
const firstId = codeIdMap.get(currentCode)
|
||||
if (firstId) path.push(firstId)
|
||||
|
||||
for (let i = 1; i < parts.length; i += 1) {
|
||||
currentCode = `${currentCode}-${parts[i]}`
|
||||
const id = codeIdMap.get(currentCode)
|
||||
if (id) path.push(id)
|
||||
}
|
||||
|
||||
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
|
||||
return path
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): FactorRow[] => {
|
||||
const entries = sortedDictEntries()
|
||||
const codeIdMap = new Map<string, string>()
|
||||
for (const [id, item] of entries) {
|
||||
codeIdMap.set(item.code, id)
|
||||
}
|
||||
|
||||
return entries.map(([id, item]) => {
|
||||
const standardFactor = typeof item.defCoe === 'number' && Number.isFinite(item.defCoe) ? item.defCoe : null
|
||||
return {
|
||||
id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
standardFactor,
|
||||
budgetValue: props.initBudgetValueFromStandard ? standardFactor : null,
|
||||
remark: '',
|
||||
path: buildCodePath(item.code, id, codeIdMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type SourceRow = Pick<FactorRow, 'id'> & Partial<Pick<FactorRow, 'budgetValue' | 'remark'>>
|
||||
const hasMeaningfulFactorValue = (rows: SourceRow[] | undefined) =>
|
||||
Array.isArray(rows) &&
|
||||
rows.some(row => {
|
||||
const hasBudgetValue = typeof row?.budgetValue === 'number' && Number.isFinite(row.budgetValue)
|
||||
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
|
||||
return hasBudgetValue || hasRemark
|
||||
})
|
||||
|
||||
const hasUsablePersistedRows = (state: GridState | null | undefined) =>
|
||||
Array.isArray(state?.detailRows) &&
|
||||
state.detailRows.some(row => {
|
||||
const hasFactor =
|
||||
typeof row?.budgetValue === 'number' ||
|
||||
typeof row?.standardFactor === 'number'
|
||||
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
|
||||
return hasFactor || hasRemark || String(row?.id || '').trim() !== ''
|
||||
})
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
|
||||
const dbValueMap = new Map<string, SourceRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
const hasBudgetValue = Object.prototype.hasOwnProperty.call(fromDb, 'budgetValue')
|
||||
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
|
||||
return {
|
||||
...row,
|
||||
budgetValue:
|
||||
typeof fromDb.budgetValue === 'number'
|
||||
? fromDb.budgetValue
|
||||
: hasBudgetValue
|
||||
? null
|
||||
: row.budgetValue,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<FactorRow>[] = [
|
||||
{
|
||||
headerName: t('xmFactorGrid.columns.standardFactor'),
|
||||
field: 'standardFactor',
|
||||
type: 'numericColumn',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
minWidth: 86,
|
||||
maxWidth: 100,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
flex: 0.9,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueFormatter: params => formatReadonlyFactor(params.value)
|
||||
},
|
||||
{
|
||||
headerName: t('xmFactorGrid.columns.budgetValue'),
|
||||
field: 'budgetValue',
|
||||
minWidth: 86,
|
||||
maxWidth: 100,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params => {
|
||||
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||
return disabled ? '' : 'editable-cell-line'
|
||||
},
|
||||
flex: 0.9,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params => {
|
||||
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||
return !disabled && (params.value == null || params.value === '')
|
||||
}
|
||||
},
|
||||
editable: params => {
|
||||
if (!props.disableBudgetEditWhenStandardNull) return true
|
||||
return params.data?.standardFactor != null
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||
if (disabled && (params.value == null || params.value === '')) return ''
|
||||
return formatEditableFactor(params)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('xmFactorGrid.columns.remark'),
|
||||
field: 'remark',
|
||||
minWidth: 170,
|
||||
flex: 2.4,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
editable: true,
|
||||
valueFormatter: params => params.value || t('xmFactorGrid.clickToInput'),
|
||||
cellClass: ' remark-wrap-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const autoGroupColumnDef: ColDef<FactorRow> = {
|
||||
headerName: t('xmFactorGrid.columns.groupName'),
|
||||
minWidth: 220,
|
||||
flex: 2.2,
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.data?.code && params.data?.name) return `${params.data.code} ${params.data.name}`
|
||||
const key = String(params.node?.key || '')
|
||||
const dictItem = (props.dict as DictSource)[key]
|
||||
return dictItem ? `${dictItem.code} ${dictItem.name}` : ''
|
||||
}
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: GridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeFactorBudgetValue = (value: unknown) => {
|
||||
const parsed = parseNumberOrNull(value, { precision: 6 })
|
||||
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const isSameNullableFactorNumber = (left: unknown, right: unknown) => {
|
||||
const normalizedLeft = normalizeFactorBudgetValue(left)
|
||||
const normalizedRight = normalizeFactorBudgetValue(right)
|
||||
if (normalizedLeft == null && normalizedRight == null) return true
|
||||
if (normalizedLeft == null || normalizedRight == null) return false
|
||||
return normalizedLeft === normalizedRight
|
||||
}
|
||||
|
||||
const parseContractFactorMeta = (storageKey: string) => {
|
||||
const consultMatch = /^ht-consult-category-factor-v1-(.+)$/.exec(storageKey)
|
||||
if (consultMatch) {
|
||||
return {
|
||||
factorType: 'consult' as const,
|
||||
contractId: String(consultMatch[1] || '').trim()
|
||||
}
|
||||
}
|
||||
const majorMatch = /^ht-major-factor-v1-(.+)$/.exec(storageKey)
|
||||
if (majorMatch) {
|
||||
return {
|
||||
factorType: 'major' as const,
|
||||
contractId: String(majorMatch[1] || '').trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const saveFactorChangeState = async (changedRowIds: string[]) => {
|
||||
if (changedRowIds.length === 0) return
|
||||
const payload: FactorChangeState = {
|
||||
changedRowIds: Array.from(new Set(changedRowIds.map(id => String(id || '').trim()).filter(Boolean))),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
if (payload.changedRowIds.length === 0) return
|
||||
zxFwPricingStore.setKeyState(CHANGE_STORAGE_KEY.value, payload, { force: true })
|
||||
|
||||
const contractMeta = parseContractFactorMeta(props.storageKey)
|
||||
if (!contractMeta?.contractId) return
|
||||
if (contractMeta.factorType === 'consult') {
|
||||
await syncContractFactorsToPricing(contractMeta.contractId, {
|
||||
consultChangedServiceIds: payload.changedRowIds
|
||||
})
|
||||
return
|
||||
}
|
||||
await syncContractFactorsToPricing(contractMeta.contractId, {
|
||||
majorChangedRowIds: payload.changedRowIds
|
||||
})
|
||||
}
|
||||
|
||||
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
|
||||
if (!storageKey) return null
|
||||
const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
|
||||
if (hasUsablePersistedRows(piniaData)) return piniaData
|
||||
|
||||
// 兼容历史 kvStore 数据:命中后迁移到 pinia keyed state。
|
||||
const legacyData = await kvStore.getItem<GridState>(storageKey)
|
||||
if (!legacyData?.detailRows || !Array.isArray(legacyData.detailRows)) return null
|
||||
zxFwPricingStore.setKeyState(storageKey, legacyData, { force: true })
|
||||
return legacyData
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<FactorRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await loadGridState(props.storageKey)
|
||||
if (data && hasMeaningfulFactorValue(data.detailRows)) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
const parentStorageKey = props.parentStorageKey?.trim()
|
||||
if (parentStorageKey) {
|
||||
const parentData = await loadGridState(parentStorageKey)
|
||||
if (parentData && hasMeaningfulFactorValue(parentData.detailRows)) {
|
||||
detailRows.value = mergeWithDictRows(parentData.detailRows)
|
||||
await saveToIndexedDB()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
await saveToIndexedDB()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isBulkClipboardMutation = false
|
||||
let bulkBudgetSnapshot: Map<string, number | null> | null = null
|
||||
const pendingChangedRowIds = new Set<string>()
|
||||
|
||||
const queueChangedRowId = (rowId: unknown) => {
|
||||
const normalizedId = String(rowId || '').trim()
|
||||
if (!normalizedId) return
|
||||
pendingChangedRowIds.add(normalizedId)
|
||||
}
|
||||
|
||||
const flushGridPersist = async () => {
|
||||
await saveToIndexedDB()
|
||||
if (pendingChangedRowIds.size === 0) return
|
||||
const changedRowIds = Array.from(pendingChangedRowIds)
|
||||
pendingChangedRowIds.clear()
|
||||
await saveFactorChangeState(changedRowIds)
|
||||
}
|
||||
|
||||
const scheduleGridPersist = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void flushGridPersist()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event?: any) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
const field = String(event?.colDef?.field || '')
|
||||
if (field === 'budgetValue' && !isSameNullableFactorNumber(event?.oldValue, event?.newValue)) {
|
||||
queueChangedRowId(event?.data?.id)
|
||||
}
|
||||
scheduleGridPersist()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
bulkBudgetSnapshot = new Map(
|
||||
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
|
||||
)
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
if (bulkBudgetSnapshot) {
|
||||
const nextBudgetMap = new Map(
|
||||
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
|
||||
)
|
||||
const allRowIds = new Set<string>([...bulkBudgetSnapshot.keys(), ...nextBudgetMap.keys()])
|
||||
for (const rowId of allRowIds) {
|
||||
if (!isSameNullableFactorNumber(bulkBudgetSnapshot.get(rowId), nextBudgetMap.get(rowId))) {
|
||||
queueChangedRowId(rowId)
|
||||
}
|
||||
}
|
||||
bulkBudgetSnapshot = null
|
||||
}
|
||||
scheduleGridPersist()
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'budgetValue') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (_error) {
|
||||
return params.value
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.dict,
|
||||
() => {
|
||||
void loadFromIndexedDB()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridApi.value?.stopEditing()
|
||||
gridApi.value = null
|
||||
void flushGridPersist()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue
|
||||
:style="agGridStyle"
|
||||
:rowData="detailRows"
|
||||
:columnDefs="gridColumnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
:animateRows="true"
|
||||
:treeData="true"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { getIndustryDisplayName, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import { syncContractScaleToPricing, type ContractScaleSyncResult } from '@/lib/zxFwPricingSync'
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import {
|
||||
ToastAction,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastRoot,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from 'reka-ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CircleHelp } from 'lucide-vue-next'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface XmScaleState {
|
||||
detailRows?: DetailRow[]
|
||||
totalAmount: number
|
||||
roughCalcEnabled: boolean
|
||||
|
||||
}
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
|
||||
const CONTRACT_SCALE_KEY_PREFIX = 'ht-info-v3-'
|
||||
const CONTRACT_SCALE_CHANGE_KEY_PREFIX = 'ht-info-scale-change-v1-'
|
||||
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||
const kvStore = useKvStore()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
const detailDict = ref<DictGroup[]>([])
|
||||
|
||||
const majorEntries = ref<Array<[string, MajorLite]>>([])
|
||||
const majorIdAliasMap = ref<Map<string, string>>(new Map())
|
||||
const refreshMajorDictCaches = () => {
|
||||
const entries = getMajorDictEntries()
|
||||
majorEntries.value = entries.map(({ id, item }) => [id, item] as [string, MajorLite])
|
||||
majorIdAliasMap.value = new Map(entries.map(({ rawId, id }) => [rawId, id]))
|
||||
}
|
||||
refreshMajorDictCaches()
|
||||
|
||||
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, ...item }]))
|
||||
|
||||
for (const [key, item] of entries) {
|
||||
const isGroup = !item.code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(item.code)) groupOrder.push(item.code)
|
||||
groupMap.set(item.code, {
|
||||
id: key,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = item.code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
|
||||
hasCost: item.hasCost !== false,
|
||||
hasArea: item.hasArea !== false
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
}
|
||||
|
||||
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict.value) {
|
||||
for (const child of group.children) {
|
||||
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
hasCost: child.hasCost,
|
||||
hasArea: child.hasArea,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
if (row?.isGroupRow === true) continue
|
||||
const rowId = String(row.id)
|
||||
dbValueMap.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.value.get(rowId)
|
||||
if (aliasId && !dbValueMap.has(aliasId)) {
|
||||
dbValueMap.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
hide: fromDb.hide,
|
||||
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const buildGroupRows = (rows: DetailRow[]): DetailRow[] => {
|
||||
const rowById = new Map(rows.map(row => [String(row.id || ''), row] as const))
|
||||
const groupRows: DetailRow[] = []
|
||||
for (const group of detailDict.value) {
|
||||
let amountTotal = 0
|
||||
let hasAmount = false
|
||||
let landAreaTotal = 0
|
||||
let hasLandArea = false
|
||||
for (const child of group.children) {
|
||||
const leaf = rowById.get(String(child.id || ''))
|
||||
const amount = leaf?.amount
|
||||
if (typeof amount === 'number' && Number.isFinite(amount)) {
|
||||
amountTotal += amount
|
||||
hasAmount = true
|
||||
}
|
||||
const landArea = leaf?.landArea
|
||||
if (typeof landArea === 'number' && Number.isFinite(landArea)) {
|
||||
landAreaTotal += landArea
|
||||
hasLandArea = true
|
||||
}
|
||||
}
|
||||
groupRows.push({
|
||||
id: group.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: group.code,
|
||||
majorName: group.name,
|
||||
hasCost: true,
|
||||
hasArea: true,
|
||||
amount: hasAmount ? roundTo(amountTotal, 3) : null,
|
||||
landArea: hasLandArea ? roundTo(landAreaTotal, 3) : null,
|
||||
path: [`${group.code} ${group.name}`],
|
||||
hide: false,
|
||||
isGroupRow: true
|
||||
})
|
||||
}
|
||||
return groupRows
|
||||
}
|
||||
|
||||
|
||||
const applyPinnedTotalAmount = (
|
||||
api: GridApi<DetailRow> | null | undefined,
|
||||
totalAmount: number | null | undefined
|
||||
) => {
|
||||
const normalized = typeof totalAmount === 'number' && Number.isFinite(totalAmount)
|
||||
? roundTo(totalAmount, 2)
|
||||
: null
|
||||
pinnedTopRowData.value[0].amount = normalized
|
||||
const pinnedTopNode = api?.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
|
||||
try {
|
||||
const [baseInfo, contractData] = await Promise.all([
|
||||
kvStore.getItem<XmBaseInfoState>(props.baseInfoKey || 'xm-base-info-v1'),
|
||||
kvStore.getItem<XmScaleState>(props.dbKey)
|
||||
])
|
||||
|
||||
activeIndustryId.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
|
||||
if (!activeIndustryId.value) {
|
||||
detailDict.value = []
|
||||
detailRows.value = []
|
||||
lastPersistedLeafRows = []
|
||||
roughCalcEnabled.value = false
|
||||
applyPinnedTotalAmount(api, null)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredEntries = majorEntries.value.filter(([id]) =>
|
||||
isMajorIdInIndustryScope(id, activeIndustryId.value)
|
||||
)
|
||||
detailDict.value = buildDetailDict(filteredEntries)
|
||||
|
||||
roughCalcEnabled.value = Boolean(contractData?.roughCalcEnabled)
|
||||
applyPinnedTotalAmount(api, contractData?.totalAmount)
|
||||
const contractRows = Array.isArray(contractData?.detailRows) ? contractData.detailRows : []
|
||||
const hasContractRows = contractRows.length > 0
|
||||
const hasContractScaleValue = hasContractRows
|
||||
? contractRows.some(row => {
|
||||
const amount = row?.amount
|
||||
const landArea = row?.landArea
|
||||
return (
|
||||
(typeof amount === 'number' && Number.isFinite(amount)) ||
|
||||
(typeof landArea === 'number' && Number.isFinite(landArea))
|
||||
)
|
||||
})
|
||||
: false
|
||||
const isLegacyEmptyScaleRows =
|
||||
hasContractRows &&
|
||||
!hasContractScaleValue &&
|
||||
!roughCalcEnabled.value &&
|
||||
typeof contractData?.totalAmount === 'number' &&
|
||||
Number.isFinite(contractData.totalAmount)
|
||||
if (hasContractRows && !isLegacyEmptyScaleRows) {
|
||||
detailRows.value = mergeWithDictRows(contractRows)
|
||||
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.xmInfoKey) {
|
||||
// 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐)
|
||||
const xmData = await kvStore.getItem<XmScaleState>(props.xmInfoKey)
|
||||
roughCalcEnabled.value = Boolean(xmData?.roughCalcEnabled)
|
||||
applyPinnedTotalAmount(api, xmData?.totalAmount)
|
||||
|
||||
if (Array.isArray(xmData?.detailRows) && xmData.detailRows.length > 0) {
|
||||
detailRows.value = mergeWithDictRows(xmData.detailRows)
|
||||
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
|
||||
return
|
||||
}
|
||||
}
|
||||
detailRows.value = buildDefaultRows()
|
||||
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
|
||||
|
||||
|
||||
void saveToIndexedDB()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
activeIndustryId.value = ''
|
||||
detailRows.value = []
|
||||
lastPersistedLeafRows = []
|
||||
roughCalcEnabled.value = false
|
||||
applyPinnedTotalAmount(api, null)
|
||||
}
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
path: string[]
|
||||
hide?: boolean
|
||||
isGroupRow?: boolean
|
||||
}
|
||||
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
interface GridPersistState {
|
||||
detailRows?: DetailRow[]
|
||||
roughCalcEnabled?: boolean
|
||||
totalAmount?: number | null
|
||||
}
|
||||
|
||||
interface ContractScaleChangeState {
|
||||
changedRowIds: string[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
dbKey: string
|
||||
xmInfoKey?: string | null
|
||||
baseInfoKey?: string
|
||||
titleHint?: string
|
||||
titleHintAria?: string
|
||||
}>()
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
const activeIndustryId = ref('')
|
||||
const totalLabel = computed(() => {
|
||||
const industryName = getIndustryDisplayName(activeIndustryId.value.trim(), locale.value)
|
||||
return industryName ? t('pricingScale.totalInvestmentByIndustry', { industryName }) : t('pricingScale.totalInvestment')
|
||||
})
|
||||
const roughCalcEnabled = ref(false)
|
||||
const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) })
|
||||
|
||||
const refreshPinnedTotalLabelCell = () => {
|
||||
if (!gridApi.value) return
|
||||
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
|
||||
if (!pinnedTopNode) return
|
||||
gridApi.value.refreshCells({
|
||||
rowNodes: [pinnedTopNode],
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: t('pricingScale.columns.investAmount'),
|
||||
field: 'amount',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
editable: params => {
|
||||
if (roughCalcEnabled.value) return Boolean(params.node?.rowPinned)
|
||||
return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost)
|
||||
},
|
||||
cellClass: params =>
|
||||
roughCalcEnabled.value && params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
roughCalcEnabled.value && params.node?.rowPinned
|
||||
? params.value == null || params.value === ''
|
||||
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (roughCalcEnabled.value) {
|
||||
if (!params.node?.rowPinned) return ''
|
||||
if (params.value == null || params.value === '') return t('pricingScale.clickToInput')
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return t('pricingScale.clickToInput')
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('pricingScale.columns.landArea'),
|
||||
field: 'landArea',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
editable: params => !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (roughCalcEnabled.value) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return t('pricingScale.clickToInput')
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
}
|
||||
]
|
||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: t('pricingScale.columns.majorGroup'),
|
||||
minWidth: 200,
|
||||
flex: 2,
|
||||
cellClassRules: {
|
||||
'ag-summary-label-cell': params => Boolean(params.node?.rowPinned)
|
||||
},
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) return totalLabel.value
|
||||
return String(params.value || '')
|
||||
},
|
||||
tooltipValueGetter: params => {
|
||||
if (params.node?.rowPinned) return totalLabel.value
|
||||
return String(params.value || '')
|
||||
}
|
||||
}
|
||||
|
||||
const pinnedTopRowData = ref<DetailRow[]>([
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
hasCost: false,
|
||||
hasArea: false,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
const syncToastOpen = ref(false)
|
||||
const syncToastText = ref('')
|
||||
let lastPersistedLeafRows: DetailRow[] | null = null
|
||||
|
||||
const cloneLeafRows = (rows: DetailRow[]) =>
|
||||
rows.map(row => ({
|
||||
...JSON.parse(JSON.stringify(row)),
|
||||
hide: Boolean(row.hide),
|
||||
isGroupRow: false
|
||||
}))
|
||||
|
||||
const getChangedScaleRowIds = (previousLeafRows: DetailRow[], nextLeafRows: DetailRow[]) => {
|
||||
const previousRowMap = new Map(previousLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
|
||||
const nextRowMap = new Map(nextLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
|
||||
return Array.from(new Set([
|
||||
...previousRowMap.keys(),
|
||||
...nextRowMap.keys()
|
||||
])).filter(rowId => {
|
||||
const prevRow = previousRowMap.get(rowId)
|
||||
const nextRow = nextRowMap.get(rowId)
|
||||
const prevAmount = typeof prevRow?.amount === 'number' && Number.isFinite(prevRow.amount) ? roundTo(prevRow.amount, 6) : null
|
||||
const nextAmount = typeof nextRow?.amount === 'number' && Number.isFinite(nextRow.amount) ? roundTo(nextRow.amount, 6) : null
|
||||
const prevLandArea = typeof prevRow?.landArea === 'number' && Number.isFinite(prevRow.landArea) ? roundTo(prevRow.landArea, 6) : null
|
||||
const nextLandArea = typeof nextRow?.landArea === 'number' && Number.isFinite(nextRow.landArea) ? roundTo(nextRow.landArea, 6) : null
|
||||
return prevAmount !== nextAmount || prevLandArea !== nextLandArea
|
||||
})
|
||||
}
|
||||
|
||||
const showScaleSyncToast = (result: ContractScaleSyncResult) => {
|
||||
if (result.updatedMethodCount <= 0) return
|
||||
syncToastText.value = t('xmScaleGrid.syncToastDesc', {
|
||||
serviceCount: result.updatedServiceCount,
|
||||
methodCount: result.updatedMethodCount,
|
||||
rowCount: result.updatedRowCount
|
||||
})
|
||||
syncToastOpen.value = false
|
||||
requestAnimationFrame(() => {
|
||||
syncToastOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const leafRows = cloneLeafRows(detailRows.value)
|
||||
const totalAmountFromRows = (() => {
|
||||
let hasValue = false
|
||||
let total = 0
|
||||
for (const row of leafRows) {
|
||||
const amount = row?.amount
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) continue
|
||||
total += amount
|
||||
hasValue = true
|
||||
}
|
||||
return hasValue ? roundTo(total, 2) : null
|
||||
})()
|
||||
const pinnedAmount = pinnedTopRowData.value[0].amount
|
||||
const normalizedPinnedAmount =
|
||||
typeof pinnedAmount === 'number' && Number.isFinite(pinnedAmount)
|
||||
? roundTo(pinnedAmount, 2)
|
||||
: null
|
||||
const normalizedTotalAmount = roughCalcEnabled.value ? normalizedPinnedAmount : totalAmountFromRows
|
||||
pinnedTopRowData.value[0].amount = normalizedTotalAmount
|
||||
const payload: GridPersistState = {
|
||||
detailRows: [...leafRows, ...buildGroupRows(leafRows)]
|
||||
}
|
||||
payload.roughCalcEnabled = roughCalcEnabled.value
|
||||
payload.totalAmount = normalizedTotalAmount
|
||||
await kvStore.setItem(props.dbKey, payload)
|
||||
const previousLeafRows = lastPersistedLeafRows || leafRows
|
||||
const changedRowIds = getChangedScaleRowIds(previousLeafRows, leafRows)
|
||||
lastPersistedLeafRows = cloneLeafRows(leafRows)
|
||||
if (props.dbKey.startsWith(CONTRACT_SCALE_KEY_PREFIX)) {
|
||||
const contractId = props.dbKey.slice(CONTRACT_SCALE_KEY_PREFIX.length).trim()
|
||||
if (contractId && changedRowIds.length > 0) {
|
||||
await kvStore.setItem<ContractScaleChangeState>(`${CONTRACT_SCALE_CHANGE_KEY_PREFIX}${contractId}`, {
|
||||
changedRowIds,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
const syncResult = await syncContractScaleToPricing(contractId, { changedRowIds })
|
||||
showScaleSyncToast(syncResult)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePersist = () => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const getRowId = (params: { data?: DetailRow }) => String(params.data?.id || '')
|
||||
const detailGridOptions: GridOptions<DetailRow> = {
|
||||
...gridOptions,
|
||||
getRowId
|
||||
}
|
||||
|
||||
const handleFlushPersistRequest = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ done?: () => void }>
|
||||
const done = customEvent?.detail?.done
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
}
|
||||
void saveToIndexedDB().finally(() => {
|
||||
done?.()
|
||||
})
|
||||
}
|
||||
|
||||
const setDetailRowsHidden = (hidden: boolean) => {
|
||||
for (const row of detailRows.value) {
|
||||
row.hide = hidden
|
||||
}
|
||||
}
|
||||
|
||||
let oldValue:number|null
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
if (roughCalcEnabled.value) {
|
||||
const rawAmount = pinnedTopRowData.value[0]?.amount
|
||||
const parsed = typeof rawAmount === 'number' ? rawAmount : Number(rawAmount)
|
||||
const nextAmount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
|
||||
pinnedTopRowData.value[0].amount = nextAmount
|
||||
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', nextAmount)
|
||||
}
|
||||
} else {
|
||||
syncPinnedTotalForNormalMode()
|
||||
}
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
const onRoughCalcSwitch = (checked: boolean) => {
|
||||
gridApi.value?.stopEditing(true)
|
||||
roughCalcEnabled.value = checked
|
||||
setDetailRowsHidden(checked)
|
||||
if (!checked) {
|
||||
oldValue=pinnedTopRowData.value[0].amount
|
||||
syncPinnedTotalForNormalMode()
|
||||
} else {
|
||||
pinnedTopRowData.value[0].amount = oldValue
|
||||
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', oldValue)
|
||||
}
|
||||
}
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
|
||||
|
||||
const onCellValueChanged = (event: CellValueChangedEvent) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
|
||||
if (typeof event.newValue === 'number') {
|
||||
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
|
||||
} else {
|
||||
const parsed = Number(event.newValue)
|
||||
pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
|
||||
}
|
||||
|
||||
} else if (!roughCalcEnabled.value) {
|
||||
syncPinnedTotalForNormalMode()
|
||||
}
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
|
||||
void loadFromIndexedDB(event.api)
|
||||
|
||||
void refreshPinnedTotalLabelCell()
|
||||
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'amount' || field === 'landArea') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (error) {
|
||||
// no-op
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const relabelRowsFromMajorDict = async () => {
|
||||
refreshMajorDictCaches()
|
||||
if (!activeIndustryId.value) return
|
||||
const filteredEntries = majorEntries.value.filter(([id]) =>
|
||||
isMajorIdInIndustryScope(id, activeIndustryId.value)
|
||||
)
|
||||
detailDict.value = buildDetailDict(filteredEntries)
|
||||
if (detailRows.value.length === 0) {
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
return
|
||||
}
|
||||
const nextRows = mergeWithDictRows(detailRows.value)
|
||||
const changed = nextRows.some((row, index) => {
|
||||
const current = detailRows.value[index]
|
||||
return (
|
||||
current?.groupCode !== row.groupCode ||
|
||||
current?.groupName !== row.groupName ||
|
||||
current?.majorCode !== row.majorCode ||
|
||||
current?.majorName !== row.majorName
|
||||
)
|
||||
})
|
||||
detailRows.value = nextRows
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
void refreshPinnedTotalLabelCell()
|
||||
if (!changed) return
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
watch(totalLabel, () => {
|
||||
refreshPinnedTotalLabelCell()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => {
|
||||
void relabelRowsFromMajorDict()
|
||||
}
|
||||
)
|
||||
|
||||
const syncPinnedTotalForNormalMode = () => {
|
||||
if (roughCalcEnabled.value) return
|
||||
|
||||
if (!gridApi.value) {
|
||||
pinnedTopRowData.value[0].amount = sumByNumber(detailRows.value, row => row.amount)
|
||||
return
|
||||
}
|
||||
let total = 0
|
||||
let hasValue = false
|
||||
|
||||
detailRows.value.forEach(node => {
|
||||
|
||||
const amount = node.amount
|
||||
if (typeof amount === 'number' && Number.isFinite(amount)) {
|
||||
total += amount
|
||||
hasValue = true
|
||||
}
|
||||
})
|
||||
pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null
|
||||
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
window.removeEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<div class="h-full">
|
||||
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<h3
|
||||
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<TooltipRoot v-if="props.titleHint">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="props.titleHintAria || props.titleHint"
|
||||
class="inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground"
|
||||
>
|
||||
<CircleHelp class="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ props.titleHint }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<!-- <div class="flex items-center gap-2">
|
||||
<span class=" text-xs text-muted-foreground">简要计算</span>
|
||||
<SwitchRoot
|
||||
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
|
||||
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
|
||||
<SwitchThumb
|
||||
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
|
||||
</SwitchRoot>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
|
||||
:animateRows="true"
|
||||
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
|
||||
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
|
||||
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||
</div>
|
||||
</div>
|
||||
<ToastRoot
|
||||
v-model:open="syncToastOpen"
|
||||
class="group pointer-events-auto rounded-lg border bg-background px-4 py-3 text-sm shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=closed]:fade-out data-[state=open]:slide-in-from-bottom-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<ToastTitle class="text-sm font-semibold text-foreground">{{ t('xmScaleGrid.syncToastTitle') }}</ToastTitle>
|
||||
<ToastDescription class="text-xs text-muted-foreground">{{ syncToastText }}</ToastDescription>
|
||||
</div>
|
||||
<ToastAction as-child :alt-text="t('common.close')">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="syncToastOpen = false">
|
||||
{{ t('common.close') }}
|
||||
</Button>
|
||||
</ToastAction>
|
||||
</div>
|
||||
</ToastRoot>
|
||||
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[420px] max-w-[92vw] flex-col gap-2 outline-none" />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
import type localforage from 'localforage'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
export interface DataEntry {
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export interface ForageStoreSnapshot {
|
||||
storeName: string
|
||||
entries: DataEntry[]
|
||||
}
|
||||
|
||||
export interface DataPackage {
|
||||
version: number
|
||||
packageType?: 'project-snapshot'
|
||||
exportedAt: string
|
||||
projectId?: string
|
||||
localStorage: DataEntry[]
|
||||
sessionStorage: DataEntry[]
|
||||
localforageDefault: DataEntry[]
|
||||
localforageStores?: ForageStoreSnapshot[]
|
||||
}
|
||||
|
||||
export type ForageInstance = ReturnType<typeof localforage.createInstance>
|
||||
export type ForageStore = Pick<ForageInstance, 'keys' | 'getItem' | 'setItem' | 'clear'>
|
||||
|
||||
type XmInfoLike = {
|
||||
projectName?: unknown
|
||||
}
|
||||
|
||||
export const readWebStorage = (storageObj: Storage): DataEntry[] => {
|
||||
const entries: DataEntry[] = []
|
||||
for (let i = 0; i < storageObj.length; i++) {
|
||||
const key = storageObj.key(i)
|
||||
if (!key) continue
|
||||
const raw = storageObj.getItem(key)
|
||||
let value: any = raw
|
||||
if (raw != null) {
|
||||
try {
|
||||
value = JSON.parse(raw)
|
||||
} catch {
|
||||
value = raw
|
||||
}
|
||||
}
|
||||
entries.push({ key, value })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
|
||||
storageObj.clear()
|
||||
for (const entry of entries || []) {
|
||||
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
|
||||
storageObj.setItem(entry.key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export const toPersistableValue = (value: unknown) => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
} catch (error) {
|
||||
console.error('normalize persist value failed, fallback to null:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const readForage = async (store: ForageStore): Promise<DataEntry[]> => {
|
||||
const keys = await store.keys()
|
||||
const values = await Promise.all(keys.map(key => store.getItem(key)))
|
||||
return keys.map((key, index) => ({
|
||||
key,
|
||||
value: toPersistableValue(values[index])
|
||||
}))
|
||||
}
|
||||
|
||||
export const writeForage = async (store: ForageStore, entries: DataEntry[]) => {
|
||||
await store.clear()
|
||||
await Promise.all((entries || []).map(entry => store.setItem(entry.key, toPersistableValue(entry.value))))
|
||||
}
|
||||
|
||||
export const normalizeEntries = (value: unknown): DataEntry[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
|
||||
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
|
||||
}
|
||||
|
||||
export const normalizeForageStoreSnapshots = (value: unknown): ForageStoreSnapshot[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter(item =>
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
&& typeof (item as any).storeName === 'string'
|
||||
&& Array.isArray((item as any).entries)
|
||||
)
|
||||
.map(item => ({
|
||||
storeName: String((item as any).storeName),
|
||||
entries: normalizeEntries((item as any).entries)
|
||||
}))
|
||||
}
|
||||
|
||||
export const sanitizeFileNamePart = (value: string): string => {
|
||||
const cleaned = value
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return cleaned || i18n.global.t('tab.messages.defaultProjectName')
|
||||
}
|
||||
|
||||
export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: string, legacyProjectDbKey: string) => {
|
||||
const target =
|
||||
entries.find(item => item.key === projectInfoDbKey) ||
|
||||
entries.find(item => item.key === legacyProjectDbKey)
|
||||
const data = (target?.value || {}) as XmInfoLike
|
||||
return typeof data.projectName === 'string'
|
||||
? sanitizeFileNamePart(data.projectName)
|
||||
: i18n.global.t('tab.messages.defaultProjectName')
|
||||
}
|
||||
|
||||
export const isDataPackageLike = (value: unknown): value is DataPackage => {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const payload = value as Partial<DataPackage>
|
||||
const hasRequiredArrays =
|
||||
Array.isArray(payload.localStorage) &&
|
||||
Array.isArray(payload.sessionStorage) &&
|
||||
Array.isArray(payload.localforageDefault)
|
||||
if (!hasRequiredArrays) return false
|
||||
if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) return false
|
||||
if (payload.packageType != null && payload.packageType !== 'project-snapshot') return false
|
||||
if (payload.projectId != null && typeof payload.projectId !== 'string') return false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.tab-strip-sortable > .tab-item {
|
||||
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.tab-strip-sortable.is-dragging > .tab-item {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.tab-drag-ghost {
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.tab-drag-chosen {
|
||||
transform: scale(1.015);
|
||||
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.tab-drag-active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-viewport']) {
|
||||
scrollbar-width: none;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-viewport']::-webkit-scrollbar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-scrollbar'][data-orientation='vertical']),
|
||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-corner']) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.toolbar-dropdown-enter-active,
|
||||
.toolbar-dropdown-leave-active {
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.toolbar-dropdown-enter-from,
|
||||
.toolbar-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
export interface UserGuideStep {
|
||||
title: string
|
||||
description: string
|
||||
points: string[]
|
||||
}
|
||||
|
||||
export type XmInfoLike = {
|
||||
projectName?: unknown
|
||||
preparedBy?: unknown
|
||||
reviewedBy?: unknown
|
||||
preparedDate?: unknown
|
||||
projectIndustry?: unknown
|
||||
preparedCompany?: unknown
|
||||
overview?: unknown
|
||||
desc?: unknown
|
||||
}
|
||||
|
||||
export type HtBaseInfoLike = {
|
||||
quality?: unknown
|
||||
duration?: unknown
|
||||
}
|
||||
|
||||
export interface ScaleRowLike {
|
||||
id: string
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
}
|
||||
|
||||
export interface XmInfoStorageLike extends XmInfoLike {
|
||||
detailRows?: ScaleRowLike[]
|
||||
totalAmount?: number
|
||||
roughCalcEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface XmScaleStorageLike {
|
||||
detailRows?: ScaleRowLike[]
|
||||
}
|
||||
|
||||
export interface ContractCardItem {
|
||||
id: string
|
||||
name?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface ZxFwRowLike {
|
||||
id: string
|
||||
process?: unknown
|
||||
subtotal?: unknown
|
||||
finalFee?: unknown
|
||||
investScale?: unknown
|
||||
landScale?: unknown
|
||||
workload?: unknown
|
||||
hourly?: unknown
|
||||
}
|
||||
|
||||
export interface WorkContentRowLike {
|
||||
id?: unknown
|
||||
content?: unknown
|
||||
checked?: unknown
|
||||
custom?: unknown
|
||||
serviceGroup?: unknown
|
||||
serviceid?: unknown
|
||||
isAddTrigger?: unknown
|
||||
}
|
||||
|
||||
export interface WorkContentStateLike {
|
||||
detailRows?: WorkContentRowLike[]
|
||||
}
|
||||
|
||||
export interface ZxFwStorageLike {
|
||||
selectedIds?: string[]
|
||||
selectedCodes?: string[]
|
||||
detailRows?: ZxFwRowLike[]
|
||||
}
|
||||
|
||||
export interface ScaleMethodRowLike extends ScaleRowLike {
|
||||
benchmarkBudgetBasicChecked?: unknown
|
||||
benchmarkBudgetOptionalChecked?: unknown
|
||||
basicFormula?: unknown
|
||||
optionalFormula?: unknown
|
||||
budgetFee?: unknown
|
||||
budgetFeeBasic?: unknown
|
||||
budgetFeeOptional?: unknown
|
||||
consultCategoryFactor?: unknown
|
||||
majorFactor?: unknown
|
||||
workStageFactor?: unknown
|
||||
workRatio?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
export interface HtFeeMainRowLike {
|
||||
id?: unknown
|
||||
name?: unknown
|
||||
}
|
||||
|
||||
export interface RateMethodRowLike {
|
||||
rate?: unknown
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
export interface QuantityMethodRowLike {
|
||||
id?: unknown
|
||||
feeItem?: unknown
|
||||
unit?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
budgetFee?: number | null
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
export interface WorkloadMethodRowLike {
|
||||
id: string
|
||||
budgetAdoptedUnitPrice?: unknown
|
||||
workload?: unknown
|
||||
basicFee?: unknown
|
||||
consultCategoryFactor?: unknown
|
||||
serviceFee?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
export interface HourlyMethodRowLike {
|
||||
id: string
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
serviceBudget?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
export interface DetailRowsStorageLike<T> {
|
||||
detailRows?: T[]
|
||||
roughCalcEnabled?: boolean
|
||||
totalAmount?: number
|
||||
}
|
||||
|
||||
export interface FactorRowLike {
|
||||
id: string
|
||||
standardFactor?: unknown
|
||||
budgetValue?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
export interface ExportScaleRow {
|
||||
major: number
|
||||
cost: number | null
|
||||
area: number | null
|
||||
}
|
||||
|
||||
export interface ExportMethod1Detail {
|
||||
proNum: number
|
||||
major: number
|
||||
cost: number
|
||||
basicFee: number
|
||||
basicFormula: string
|
||||
basicFee_basic: number
|
||||
optionalFormula: string
|
||||
basicFee_optional: number
|
||||
serviceCoe: number
|
||||
majorCoe: number
|
||||
processCoe: number
|
||||
proportion: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface ExportMethod1 {
|
||||
proAmount: number
|
||||
cost: number
|
||||
basicFee: number
|
||||
basicFee_basic: number
|
||||
basicFee_optional: number
|
||||
fee: number
|
||||
det: ExportMethod1Detail[]
|
||||
}
|
||||
|
||||
export interface ExportMethod2Detail {
|
||||
proNum: number
|
||||
major: number
|
||||
area: number
|
||||
basicFee: number
|
||||
basicFormula: string
|
||||
basicFee_basic: number
|
||||
optionalFormula: string
|
||||
basicFee_optional: number
|
||||
serviceCoe: number
|
||||
majorCoe: number
|
||||
processCoe: number
|
||||
proportion: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface ExportMethod2 {
|
||||
proAmount: number
|
||||
area: number
|
||||
basicFee: number
|
||||
basicFee_basic: number
|
||||
basicFee_optional: number
|
||||
fee: number
|
||||
det: ExportMethod2Detail[]
|
||||
}
|
||||
|
||||
export interface ExportMethod3Detail {
|
||||
task: number
|
||||
price: number
|
||||
amount: number
|
||||
basicFee: number
|
||||
serviceCoe: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface ExportMethod3 {
|
||||
basicFee: number
|
||||
fee: number
|
||||
det: ExportMethod3Detail[]
|
||||
}
|
||||
|
||||
export interface ExportMethod4Detail {
|
||||
expert: number
|
||||
price: number
|
||||
person_num: number
|
||||
work_day: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface ExportMethod4 {
|
||||
person_num: number
|
||||
work_day: number
|
||||
fee: number
|
||||
det: ExportMethod4Detail[]
|
||||
}
|
||||
|
||||
export interface ExportService {
|
||||
id: number
|
||||
fee: number
|
||||
finalFee: number
|
||||
process: number
|
||||
tasks: ExportTaskGroup[]
|
||||
method1?: ExportMethod1
|
||||
method2?: ExportMethod2
|
||||
method3?: ExportMethod3
|
||||
method4?: ExportMethod4
|
||||
}
|
||||
|
||||
export interface ExportTaskGroup {
|
||||
serviceid?: number
|
||||
text: string[]
|
||||
}
|
||||
|
||||
export interface ExportServiceCoe {
|
||||
serviceid: number
|
||||
coe: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface ExportMajorCoe {
|
||||
majorid: number
|
||||
coe: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface ExportContract {
|
||||
name: string
|
||||
serviceFee: number
|
||||
addtionalFee: number
|
||||
reserveFee: number
|
||||
fee: number
|
||||
quality: string
|
||||
duration: string
|
||||
scale: ExportScaleRow[]
|
||||
serviceCoes: ExportServiceCoe[]
|
||||
majorCoes: ExportMajorCoe[]
|
||||
services: ExportService[]
|
||||
addtional: ExportAdditional | null
|
||||
reserve: ExportReserve | null
|
||||
}
|
||||
|
||||
export interface ExportMethod0 {
|
||||
coe: number
|
||||
fee: number
|
||||
}
|
||||
|
||||
export interface ExportMethod5Detail {
|
||||
name: string
|
||||
unit: string
|
||||
amount: number
|
||||
price: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface ExportMethod5 {
|
||||
fee: number
|
||||
det: ExportMethod5Detail[]
|
||||
}
|
||||
|
||||
export interface ExportAdditionalDetail {
|
||||
id: number | string
|
||||
code?: unknown
|
||||
name: string
|
||||
fee: number
|
||||
tasks: ExportTaskGroup[]
|
||||
m0?: ExportMethod0
|
||||
m4?: ExportMethod4
|
||||
m5?: ExportMethod5
|
||||
}
|
||||
|
||||
export interface ExportAdditional {
|
||||
code?: unknown
|
||||
name: string
|
||||
fee: number
|
||||
det: ExportAdditionalDetail[]
|
||||
}
|
||||
|
||||
export interface ExportReserve {
|
||||
code?: unknown
|
||||
name: string
|
||||
fee: number
|
||||
tasks: ExportTaskGroup[]
|
||||
m0?: ExportMethod0
|
||||
m4?: ExportMethod4
|
||||
m5?: ExportMethod5
|
||||
}
|
||||
|
||||
export interface ExportReportPayload {
|
||||
name: string
|
||||
writer: string
|
||||
reviewer: string
|
||||
company: string
|
||||
date: string
|
||||
industry: number
|
||||
fee: number
|
||||
scaleCost: number
|
||||
overview: string
|
||||
desc: string
|
||||
scale: ExportScaleRow[]
|
||||
serviceCoes: ExportServiceCoe[]
|
||||
majorCoes: ExportMajorCoe[]
|
||||
contracts: ExportContract[]
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { useUiPrefsStore } from '@/pinia/uiPrefs'
|
||||
import {
|
||||
Calculator,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Languages,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { getIndustryDisplayName, industryTypeList } from '@/sql'
|
||||
import { initializeProjectFactorStates, initializeProjectScaleState } from '@/lib/projectWorkspace'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectIcon,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import {
|
||||
buildDisclaimerUrl,
|
||||
buildProjectUrl,
|
||||
consumePendingDisclaimerAction,
|
||||
DEFAULT_PROJECT_ID,
|
||||
hasAcceptedRestrictedDisclaimer,
|
||||
FORCE_HOME_QUERY_KEY,
|
||||
isDisclaimerAcceptanceRequired,
|
||||
NEW_PROJECT_QUERY_KEY,
|
||||
OPEN_PROJECT_DIALOG_QUERY_KEY,
|
||||
setPendingDisclaimerAction,
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_PROJECT_ID,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_CONTRACT_FALLBACK_NAME,
|
||||
QUICK_CONTRACT_ID,
|
||||
QUICK_CONTRACT_META_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY,
|
||||
QUICK_PROJECT_INFO_KEY,
|
||||
readCurrentProjectId,
|
||||
writeProjectIdToUrl,
|
||||
setPendingHomeImportFile,
|
||||
writeWorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry'
|
||||
import { createProjectKvAdapter } from '@/lib/projectKvStore'
|
||||
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
|
||||
|
||||
interface QuickProjectInfoState {
|
||||
projectIndustry?: string
|
||||
projectName?: string
|
||||
preparedBy?: string
|
||||
reviewedBy?: string
|
||||
preparedCompany?: string
|
||||
preparedDate?: string
|
||||
}
|
||||
|
||||
interface QuickContractMetaState {
|
||||
id?: string
|
||||
name?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface ProjectInfoState {
|
||||
projectIndustry?: string
|
||||
projectName?: string
|
||||
preparedBy?: string
|
||||
reviewedBy?: string
|
||||
preparedCompany?: string
|
||||
preparedDate?: string
|
||||
}
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
||||
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
|
||||
const PROJECT_SCALE_KEY = 'xm-info-v3'
|
||||
const FILE_LEDGER_URL = 'https://www.lianzhong.com.cn/file?fileNo=24'
|
||||
const getActiveProjectId = () => readCurrentProjectId()
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const kvStore = useKvStore()
|
||||
const uiPrefsStore = useUiPrefsStore()
|
||||
const { t, tm, locale } = useI18n()
|
||||
const projectDialogOpen = ref(false)
|
||||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const projectSubmitting = ref(false)
|
||||
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||||
const quickSubmitting = ref(false)
|
||||
const homeImportInputRef = ref<HTMLInputElement | null>(null)
|
||||
const homeImportConfirmOpen = ref(false)
|
||||
const pendingHomeImportFile = ref<File | null>(null)
|
||||
const pendingHomeImportFileName = ref('')
|
||||
const existingProjectDialogOpen = ref(false)
|
||||
const disclaimerRequired = ref(false)
|
||||
const existingProjects = ref<Array<{ id: string; name: string; updatedAt: string }>>([])
|
||||
const existingProjectLoading = ref(false)
|
||||
const hasExistingProjects = ref(false)
|
||||
const openedProjectIds = ref<string[]>([])
|
||||
let existingProjectPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
const heroTitleIndex = ref(0)
|
||||
const heroDescIndex = ref(0)
|
||||
const projectIndustryLabel = computed(() => {
|
||||
const target = String(projectIndustry.value || '').trim()
|
||||
if (!target) return ''
|
||||
return getIndustryDisplayName(target, locale.value) || ''
|
||||
})
|
||||
const localeBadge = computed(() => (locale.value === 'en-US' ? 'EN' : '中'))
|
||||
const toggleLocale = () => {
|
||||
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
|
||||
uiPrefsStore.setLocale(next as 'zh-CN' | 'en-US')
|
||||
}
|
||||
const resolveProjectRegistryName = (projectIdRaw: string) => {
|
||||
const projectId = String(projectIdRaw || '').trim()
|
||||
if (projectId !== DEFAULT_PROJECT_ID) return undefined
|
||||
return t('xmInfo.defaultProjectName')
|
||||
}
|
||||
|
||||
const writeCleanProjectUrl = (projectIdRaw: string) => {
|
||||
try {
|
||||
const href = buildProjectUrl(projectIdRaw, { forceHome: false, newProject: false })
|
||||
window.history.replaceState({}, '', href)
|
||||
} catch {
|
||||
writeProjectIdToUrl(projectIdRaw)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToWorkspace = (projectIdRaw: string, mode: 'project' | 'quick') => {
|
||||
const projectId = String(projectIdRaw || '').trim()
|
||||
if (!projectId) return false
|
||||
writeWorkspaceMode(mode)
|
||||
const currentProjectId = getActiveProjectId()
|
||||
const shouldReloadApp = mode === 'project' && currentProjectId !== projectId
|
||||
if (shouldReloadApp) {
|
||||
window.location.href = buildProjectUrl(projectId, { forceHome: false, newProject: false })
|
||||
return false
|
||||
}
|
||||
writeCleanProjectUrl(projectId)
|
||||
return true
|
||||
}
|
||||
|
||||
const getTodayDateString = () => {
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const formatProjectEditedTime = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const pad = (num: number) => String(num).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
const enterProjectCalc = () => {
|
||||
const projectId = getActiveProjectId()
|
||||
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
||||
if (!navigateToWorkspace(projectId, 'project')) return
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
title: t('home.projectCalcTab'),
|
||||
componentName: 'ProjectCalcView'
|
||||
})
|
||||
tabStore.hasCompletedSetup = true
|
||||
}
|
||||
|
||||
const loadProjectDefaults = async () => {
|
||||
const savedInfo = await kvStore.getItem<ProjectInfoState>(PROJECT_INFO_KEY)
|
||||
projectIndustry.value =
|
||||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
||||
? savedInfo.projectIndustry.trim()
|
||||
: String(industryTypeList[0]?.id || '')
|
||||
}
|
||||
|
||||
const openProjectCalc = async () => {
|
||||
await runWithDisclaimerGuard({ type: 'project' }, async () => {
|
||||
await loadProjectDefaults()
|
||||
projectDialogOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const openRelatedFiles = () => {
|
||||
window.open(FILE_LEDGER_URL, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
const redirectToDisclaimerPage = () => {
|
||||
const returnUrl = window.location.href
|
||||
window.location.href = buildDisclaimerUrl(returnUrl)
|
||||
}
|
||||
|
||||
const runWithDisclaimerGuard = async (
|
||||
pendingAction: { type: 'project' | 'quick' | 'import' | 'existing-project'; projectId?: string },
|
||||
action: () => void | Promise<void>
|
||||
) => {
|
||||
if (!disclaimerRequired.value || hasAcceptedRestrictedDisclaimer()) {
|
||||
await action()
|
||||
return
|
||||
}
|
||||
setPendingDisclaimerAction(pendingAction)
|
||||
redirectToDisclaimerPage()
|
||||
}
|
||||
|
||||
const syncDisclaimerRequirement = () => {
|
||||
disclaimerRequired.value = isDisclaimerAcceptanceRequired()
|
||||
}
|
||||
|
||||
const syncExistingProjectOpenedState = (projectIds: string[]) => {
|
||||
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(projectIds))
|
||||
}
|
||||
|
||||
const isExistingProjectOpened = (projectIdRaw: string) => {
|
||||
const projectId = String(projectIdRaw || '').trim()
|
||||
return projectId ? openedProjectIds.value.includes(projectId) : false
|
||||
}
|
||||
|
||||
const refreshExistingProjects = async (options?: { showLoading?: boolean }) => {
|
||||
if (options?.showLoading !== false) {
|
||||
existingProjectLoading.value = true
|
||||
}
|
||||
try {
|
||||
const projects = listProjects()
|
||||
.filter(item => item.id !== QUICK_PROJECT_ID)
|
||||
.sort((a, b) => {
|
||||
const left = new Date(a.updatedAt).getTime()
|
||||
const right = new Date(b.updatedAt).getTime()
|
||||
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0)
|
||||
})
|
||||
existingProjects.value = projects.map(project => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
updatedAt: project.updatedAt
|
||||
}))
|
||||
hasExistingProjects.value = projects.length > 0
|
||||
syncExistingProjectOpenedState(projects.map(project => project.id))
|
||||
} finally {
|
||||
if (options?.showLoading !== false) {
|
||||
existingProjectLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopExistingProjectPolling = () => {
|
||||
if (existingProjectPollTimer == null) return
|
||||
clearInterval(existingProjectPollTimer)
|
||||
existingProjectPollTimer = null
|
||||
}
|
||||
|
||||
const startExistingProjectPolling = () => {
|
||||
stopExistingProjectPolling()
|
||||
existingProjectPollTimer = setInterval(() => {
|
||||
if (!existingProjectDialogOpen.value) return
|
||||
void refreshExistingProjects({ showLoading: false })
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const openExistingProjectDialog = async () => {
|
||||
await runWithDisclaimerGuard({ type: 'existing-project' }, async () => {
|
||||
existingProjectDialogOpen.value = true
|
||||
await refreshExistingProjects()
|
||||
startExistingProjectPolling()
|
||||
})
|
||||
}
|
||||
|
||||
const closeExistingProjectDialog = () => {
|
||||
existingProjectDialogOpen.value = false
|
||||
stopExistingProjectPolling()
|
||||
}
|
||||
|
||||
const enterExistingProject = (projectIdRaw: string) => {
|
||||
const projectId = String(projectIdRaw || '').trim()
|
||||
if (!projectId || isExistingProjectOpened(projectId)) return
|
||||
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
||||
if (!navigateToWorkspace(projectId, 'project')) return
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
title: t('home.projectCalcTab'),
|
||||
componentName: 'ProjectCalcView'
|
||||
})
|
||||
tabStore.hasCompletedSetup = true
|
||||
closeExistingProjectDialog()
|
||||
}
|
||||
|
||||
const closeProjectCalcDialog = () => {
|
||||
projectDialogOpen.value = false
|
||||
}
|
||||
|
||||
const confirmProjectCalc = async () => {
|
||||
const industry = projectIndustry.value.trim()
|
||||
if (!industry) return
|
||||
|
||||
projectSubmitting.value = true
|
||||
try {
|
||||
const project = createProject(t('xmInfo.defaultProjectName'))
|
||||
const kvAdapter = createProjectKvAdapter(project.id)
|
||||
await kvAdapter.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
|
||||
projectIndustry: industry,
|
||||
projectName: t('xmInfo.defaultProjectName'),
|
||||
preparedBy: '',
|
||||
reviewedBy: '',
|
||||
preparedCompany: '',
|
||||
preparedDate: getTodayDateString()
|
||||
})
|
||||
await initializeProjectFactorStates(
|
||||
kvAdapter,
|
||||
industry,
|
||||
PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
PROJECT_MAJOR_FACTOR_KEY
|
||||
)
|
||||
await initializeProjectScaleState(kvAdapter, industry, PROJECT_SCALE_KEY)
|
||||
writeWorkspaceMode('project')
|
||||
window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false })
|
||||
} finally {
|
||||
projectSubmitting.value = false
|
||||
projectDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadQuickDefaults = async () => {
|
||||
const [savedInfo, savedMeta] = await Promise.all([
|
||||
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
|
||||
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
|
||||
])
|
||||
|
||||
quickIndustry.value =
|
||||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
||||
? savedInfo.projectIndustry.trim()
|
||||
: String(industryTypeList[0]?.id || '')
|
||||
quickContractName.value =
|
||||
typeof savedMeta?.name === 'string' && savedMeta.name.trim()
|
||||
? savedMeta.name.trim()
|
||||
: QUICK_CONTRACT_FALLBACK_NAME
|
||||
}
|
||||
|
||||
const enterQuickCalc = (contractName: string) => {
|
||||
if (!navigateToWorkspace(QUICK_PROJECT_ID, 'quick')) return
|
||||
tabStore.enterWorkspace({
|
||||
id: `contract-${QUICK_CONTRACT_ID}`,
|
||||
title: t('home.quickCalcTab'),
|
||||
componentName: 'QuickCalcWorkbenchView',
|
||||
props: {
|
||||
contractId: QUICK_CONTRACT_ID,
|
||||
contractName,
|
||||
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||||
projectScaleKey: null,
|
||||
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
||||
}
|
||||
})
|
||||
tabStore.hasCompletedSetup = true
|
||||
}
|
||||
|
||||
const openQuickCalc = async () => {
|
||||
await runWithDisclaimerGuard({ type: 'quick' }, async () => {
|
||||
await loadQuickDefaults()
|
||||
const contractName = quickContractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME
|
||||
const industry = quickIndustry.value.trim()
|
||||
|
||||
quickSubmitting.value = true
|
||||
try {
|
||||
const currentInfo = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
|
||||
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||||
...currentInfo,
|
||||
projectIndustry: industry,
|
||||
projectName: t('quickCalc.projectName')
|
||||
})
|
||||
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||||
id: QUICK_CONTRACT_ID,
|
||||
name: contractName,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
if (industry) {
|
||||
await initializeProjectFactorStates(
|
||||
kvStore,
|
||||
industry,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY
|
||||
)
|
||||
}
|
||||
enterQuickCalc(contractName)
|
||||
} finally {
|
||||
quickSubmitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleHomeImportChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
pendingHomeImportFile.value = file
|
||||
pendingHomeImportFileName.value = file.name
|
||||
homeImportConfirmOpen.value = true
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const openHomeImport = () => {
|
||||
void runWithDisclaimerGuard({ type: 'import' }, () => {
|
||||
homeImportInputRef.value?.click()
|
||||
})
|
||||
}
|
||||
|
||||
const replayPendingDisclaimerAction = async () => {
|
||||
if (!disclaimerRequired.value || !hasAcceptedRestrictedDisclaimer()) return
|
||||
const pendingAction = consumePendingDisclaimerAction()
|
||||
if (!pendingAction) return
|
||||
if (pendingAction.type === 'project') {
|
||||
await loadProjectDefaults()
|
||||
projectDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
if (pendingAction.type === 'quick') {
|
||||
await openQuickCalc()
|
||||
return
|
||||
}
|
||||
if (pendingAction.type === 'import') {
|
||||
homeImportInputRef.value?.click()
|
||||
return
|
||||
}
|
||||
if (pendingAction.type === 'existing-project') {
|
||||
existingProjectDialogOpen.value = true
|
||||
await refreshExistingProjects()
|
||||
startExistingProjectPolling()
|
||||
}
|
||||
}
|
||||
|
||||
const cancelHomeImportConfirm = () => {
|
||||
homeImportConfirmOpen.value = false
|
||||
pendingHomeImportFile.value = null
|
||||
pendingHomeImportFileName.value = ''
|
||||
}
|
||||
|
||||
const confirmHomeImport = async () => {
|
||||
const file = pendingHomeImportFile.value
|
||||
if (!file) return
|
||||
await setPendingHomeImportFile(file, { skipWorkspaceConfirm: true })
|
||||
const targetProject = createProject(t('xmInfo.defaultProjectName'))
|
||||
writeWorkspaceMode('project')
|
||||
window.location.href = buildProjectUrl(targetProject.id, { forceHome: false, newProject: false })
|
||||
cancelHomeImportConfirm()
|
||||
}
|
||||
|
||||
const handleHomeWindowFocus = () => {
|
||||
if (!existingProjectDialogOpen.value) return
|
||||
void refreshExistingProjects({ showLoading: false })
|
||||
}
|
||||
|
||||
const handleHomeVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
handleHomeWindowFocus()
|
||||
}
|
||||
|
||||
const openDisclaimerPage = () => {
|
||||
const href = buildDisclaimerUrl(window.location.href)
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
const resolveLocalizedStringArray = (value: unknown) => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
}
|
||||
|
||||
const heroTitleOptions = computed(() =>
|
||||
resolveLocalizedStringArray(tm('home.cards.heroTitles'))
|
||||
)
|
||||
const heroDescOptions = computed(() =>
|
||||
resolveLocalizedStringArray(tm('home.cards.heroDescs'))
|
||||
)
|
||||
const heroTitleText = computed(() => heroTitleOptions.value[heroTitleIndex.value] || '')
|
||||
const heroDescText = computed(() => heroDescOptions.value[heroDescIndex.value] || '')
|
||||
|
||||
const pickRandomIndex = (length: number) => {
|
||||
if (length <= 1) return 0
|
||||
return Math.floor(Math.random() * length)
|
||||
}
|
||||
|
||||
const refreshHeroCopy = () => {
|
||||
heroTitleIndex.value = pickRandomIndex(heroTitleOptions.value.length)
|
||||
heroDescIndex.value = pickRandomIndex(heroDescOptions.value.length)
|
||||
}
|
||||
|
||||
const homeActionCards = [
|
||||
{
|
||||
key: 'projectBudget',
|
||||
desc: 'projectBudgetDesc',
|
||||
action: 'enter',
|
||||
icon: 'project',
|
||||
iconWrapClass: 'border-blue-100 bg-blue-50/80 text-blue-600',
|
||||
iconClass: 'h-5 w-5',
|
||||
clickFunc: openProjectCalc,
|
||||
showExistingAction: true
|
||||
},
|
||||
{
|
||||
key: 'quickCalc',
|
||||
desc: 'quickCalcDesc',
|
||||
action: 'enter',
|
||||
icon: 'quick',
|
||||
iconWrapClass: 'border-amber-100 bg-amber-50/80 text-amber-600',
|
||||
iconClass: 'h-10 w-10',
|
||||
clickFunc: openQuickCalc,
|
||||
showExistingAction: false
|
||||
},
|
||||
{
|
||||
key: 'importData',
|
||||
desc: 'importDataDesc',
|
||||
action: 'pickFile',
|
||||
icon: 'import',
|
||||
iconWrapClass: 'border-emerald-100 bg-emerald-50/80 text-emerald-600',
|
||||
iconClass: 'h-5 w-5',
|
||||
clickFunc: openHomeImport,
|
||||
showExistingAction: false
|
||||
},
|
||||
{
|
||||
key: 'file',
|
||||
desc: 'fileDataDesc',
|
||||
action: 'openFileSystem',
|
||||
icon: 'files',
|
||||
iconWrapClass: 'border-emerald-100 bg-emerald-50/80 text-emerald-600',
|
||||
iconClass: 'h-5 w-5',
|
||||
clickFunc: openRelatedFiles,
|
||||
showExistingAction: false
|
||||
}
|
||||
] as const
|
||||
|
||||
onMounted(() => {
|
||||
syncDisclaimerRequirement()
|
||||
void refreshExistingProjects()
|
||||
void loadProjectDefaults()
|
||||
void loadQuickDefaults()
|
||||
void replayPendingDisclaimerAction()
|
||||
refreshHeroCopy()
|
||||
window.addEventListener('focus', handleHomeWindowFocus)
|
||||
document.addEventListener('visibilitychange', handleHomeVisibilityChange)
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const isNewProject = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
||||
if (isNewProject) {
|
||||
const projectId = getActiveProjectId()
|
||||
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
||||
const openProjectDialog = url.searchParams.get(OPEN_PROJECT_DIALOG_QUERY_KEY) !== '0'
|
||||
if (openProjectDialog) {
|
||||
void openProjectCalc()
|
||||
}
|
||||
url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
|
||||
url.searchParams.delete(OPEN_PROJECT_DIALOG_QUERY_KEY)
|
||||
url.searchParams.delete(FORCE_HOME_QUERY_KEY)
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
} catch {
|
||||
// ignore url parsing errors
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopExistingProjectPolling()
|
||||
window.removeEventListener('focus', handleHomeWindowFocus)
|
||||
document.removeEventListener('visibilitychange', handleHomeVisibilityChange)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => {
|
||||
refreshHeroCopy()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input ref="homeImportInputRef" type="file" accept=".zw" class="sr-only" @change="handleHomeImportChange" />
|
||||
<div class="home-entry relative flex min-h-screen items-center justify-center px-4 py-8 lg:py-10">
|
||||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(59,130,246,0.06),transparent_70%)]" />
|
||||
<div class="relative w-full max-w-[1240px]">
|
||||
<div class="absolute right-0 top-0 z-10">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 cursor-pointer gap-1.5 rounded-full border-slate-200/80 bg-white/85 px-3 text-xs text-slate-600 shadow-sm backdrop-blur transition hover:bg-white"
|
||||
@click="toggleLocale"
|
||||
>
|
||||
<Languages class="h-3.5 w-3.5" />
|
||||
<span>{{ localeBadge }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="home-title text-center" :style="{ maxWidth: '800px' }">
|
||||
<h1
|
||||
class="text-2xl tracking-tight text-slate-900 lg:text-3xl"
|
||||
:style="{ whiteSpace: 'pre-line', fontWeight: '200', fontFamily: 'HarmonyOS_Sans_SC' }"
|
||||
>
|
||||
{{ t('home.title') }}
|
||||
</h1>
|
||||
<p class="mt-1.5 text-sm text-slate-500">{{ t('home.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="mt-5 grid items-stretch gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div
|
||||
class="home-hero home-card-base home-entry-item home-entry-item--1 relative overflow-hidden rounded-2xl bg-[#dc2626] p-7 text-white shadow-[0_24px_60px_rgba(153,27,27,0.35)]"
|
||||
>
|
||||
<div class="pointer-events-none absolute -right-20 -top-16 h-56 w-56 rounded-full bg-white/12 blur-2xl" />
|
||||
<div class="pointer-events-none absolute -left-10 -bottom-10 h-40 w-40 rounded-full bg-white/8 blur-3xl" />
|
||||
<div v-for="index in 10" :key="index" :class="`home-hero-meteor home-hero-meteor--${index}`" />
|
||||
<div class="relative inline-flex h-11 w-11 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/35">
|
||||
<Calculator class="h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="relative mt-8 text-2xl font-semibold leading-tight tracking-tight lg:text-3xl" :style="{ whiteSpace: 'pre-line', fontSize: '20px' }">{{ heroTitleText }}</h2>
|
||||
<p class="relative mt-2 text-sm text-red-200/90">{{ t('home.cards.heroSubTitle') }}</p>
|
||||
<div class="relative mt-6 h-px bg-white/20" />
|
||||
<p class="relative mt-4 whitespace-pre-line text-xs leading-5 text-red-200/60">{{ heroDescText }}</p>
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-for="(card, index) in homeActionCards"
|
||||
:key="card.key"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
'home-card home-card-base home-entry-item group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200',
|
||||
`home-entry-item--${index + 2}`
|
||||
]"
|
||||
@click="card.clickFunc"
|
||||
@keydown.enter.prevent="card.clickFunc"
|
||||
@keydown.space.prevent="card.clickFunc"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex h-11 w-11 items-center justify-center rounded-xl border shadow-sm transition-transform duration-200 group-hover:scale-105',
|
||||
card.iconWrapClass
|
||||
]"
|
||||
>
|
||||
<svg v-if="card.icon === 'project'" viewBox="0 0 1024 1024" :class="card.iconClass" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M938.666667 874.666667c0 11.733333-9.6 21.333333-21.333334 21.333333H106.666667c-11.733333 0-21.333333-9.6-21.333334-21.333333s9.6-21.333333 21.333334-21.333334h42.666666V490.666667c0-11.733333 9.6-21.333333 21.333334-21.333334h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333334v362.666666h42.666666V320c0-11.733333 9.6-21.333333 21.333334-21.333333h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333333v533.333333h42.666666V149.333333c0-11.733333 9.6-21.333333 21.333334-21.333333h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333333v704h42.666666c11.733333 0 21.333333 9.6 21.333334 21.333334z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else-if="card.icon === 'quick'" viewBox="0 0 800 800" :class="card.iconClass" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5245 5891c-11-5-47-38-80-73-33-36-269-281-525-544-256-263-514-530-575-592-90-93-113-123-130-170-20-54-79-170-200-392-70-129-81-164-61-194 22-35 59-40 114-15 26 11 119 47 207 79 88 33 185 69 215 81 30 12 83 31 118 44 58 20 82 40 300 246 130 123 291 276 357 339 66 63 165 158 219 210 55 52 105 100 111 106 57 58 205 194 212 194 4 0 7-520 5-1155l-2-1155-553 0c-343 0-576-4-613-11-86-15-175-46-227-79-25-15-48-26-51-23-3 4-6 154-6 335l0 328-80 0-80 0 0-336 0-336-32 22c-44 29-106 56-176 77-50 15-130 17-647 22l-590 6 0 1165 0 1165 440 0c467 0 528-4 702-50 105-28 200-69 261-114 41-31 42-32 42-91l0-60 80 0 80 0 1 33c3 69 8 82 40 110 19 16 63 43 99 59 36 16 71 33 79 37 13 7 56 169 47 178-6 6-170-49-221-74-27-14-66-38-86-54l-35-28-32 25c-51 40-181 101-274 128-188 54-249 59-840 63l-548 4 0-1330 0-1331 619 0c669 0 715-3 826-54 63-29 138-94 155-136 12-30 13-30 91-30l78 0 17 35c20 44 70 87 137 122 114 58 98 56 807 62l655 6 3 1313c2 1296 2 1314 22 1339 34 43 21 153-29 252-35 67-147 173-224 211-70 34-179 50-222 31z m171-190c72-42 154-148 154-200 0-12-47-64-125-138-69-65-129-119-133-121-4-1-60 51-125 116l-117 118 121 127c136 144 141 146 225 98z m-341-468l110-114-65-62c-36-33-110-104-165-157-385-367-609-575-618-575-7 0-54 44-106 97l-94 97 335 343c184 189 366 376 403 416 38 39 73 71 79 71 6-1 61-53 121-116z m-905-994c0-7-193-79-211-79-5 0 14 46 42 101l51 102 59-59c32-32 59-61 59-65z"
|
||||
transform="translate(0,800) scale(0.1,-0.1)"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M1897 4983c-9-8-9-2599-1-2630l6-23 787 0c432 0 791-4 797-8 6-4 18-21 27-38 24-46 102-113 170-144 169-79 482-78 640 1 96 49 141 90 193 177 6 9 97 11 822 11l782 1 0 1330 0 1330-85 0-85 0 0-1250 0-1250-773 0-772 0-18-51c-24-66-84-131-146-158-131-56-369-51-491 11-69 35-121 96-138 162l-8 31-775 5-774 5-2 1150c-2 633-3 1194-3 1248l0 97-73 0c-41 0-77-3-80-7z"
|
||||
transform="translate(0,800) scale(0.1,-0.1)"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3391 3872c-261-2-347-5-353-15-4-6-8-38-8-69 0-47 4-59 19-68 13-6 214-10 584-10 474 0 566 2 576 14 7 9 11 40 9 78l-3 63-240 5c-132 3-395 4-584 2z"
|
||||
transform="translate(0,800) scale(0.1,-0.1)"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else-if="card.icon === 'import'" viewBox="0 0 1024 1024" :class="card.iconClass" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M154.579478 1001.73913v-332.844521h89.043479V912.695652H912.695652V369.530435h-234.896695V111.304348H243.890087v349.184h-89.043478V22.26087h585.683478l261.431652 263.924869V1001.73913z m612.173913-721.252173h104.314435l-104.314435-105.293914z m-416.857043 411.469913l79.026087-79.026087H22.26087v-89.043479h406.661565L349.94087 444.861217l41.138087-41.22713 123.592347 123.592348 41.227131 41.182608-41.227131 41.138087-123.592347 123.592348z m123.013565-123.013566l0.489739-0.534261-0.489739-0.489739z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 1024 1024"
|
||||
:class="card.iconClass"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M271.146667 128h-85.333334a64 64 0 0 0-64 64v640a64 64 0 0 0 64 64h85.333334a64 64 0 0 0 64-64V192a64 64 0 0 0-64-64z m21.333333 704a21.333333 21.333333 0 0 1-21.333333 21.333333h-85.333334a21.333333 21.333333 0 0 1-21.333333-21.333333V320h128z m0-554.666667h-128V192a21.333333 21.333333 0 0 1 21.333333-21.333333h85.333334a21.333333 21.333333 0 0 1 21.333333 21.333333zM901.12 808.533333L789.333333 178.346667a64 64 0 0 0-74.026666-51.84l-84.053334 14.72a64 64 0 0 0-44.16 31.146666A64 64 0 0 0 527.146667 128h-85.333334a64 64 0 0 0-64 64v640a64 64 0 0 0 64 64h85.333334a64 64 0 0 0 64-64V279.253333l99.84 566.4a64 64 0 0 0 74.24 51.84l84.053333-14.72a64 64 0 0 0 51.84-74.24zM548.48 832a21.333333 21.333333 0 0 1-21.333333 21.333333h-85.333334a21.333333 21.333333 0 0 1-21.333333-21.333333V320h128z m0-554.666667h-128V192a21.333333 21.333333 0 0 1 21.333333-21.333333h85.333334a21.333333 21.333333 0 0 1 21.333333 21.333333z m73.386667-69.333333A21.333333 21.333333 0 0 1 640 183.253333l83.2-14.72a21.333333 21.333333 0 0 1 24.746667 17.28l14.933333 84.053334-126.08 21.333333z m219.946666 632.746667l-84.053333 14.72a21.333333 21.333333 0 0 1-24.746667-17.28L644.266667 334.08l125.866666-21.333333 88.96 504.106666a21.333333 21.333333 0 0 1-17.28 23.893334z"
|
||||
fill="#e0db84"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ t(`home.cards.${card.key}`) }}</h3>
|
||||
<p class="mt-1.5 text-xs leading-5 text-slate-500">{{ t(`home.cards.${card.desc}`) }}</p>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
v-if="card.showExistingAction && hasExistingProjects"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700"
|
||||
@click.stop="openExistingProjectDialog"
|
||||
>
|
||||
{{ t('home.cards.pickExisting') }}
|
||||
</button>
|
||||
<div class="flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
||||
<span>{{ t(`home.cards.${card.action}`) }}</span>
|
||||
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="home-disclaimer mt-5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center justify-center rounded-2xl border border-slate-300/60 bg-white/55 px-5 py-2 text-base font-semibold text-slate-700 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-sm underline decoration-slate-300 underline-offset-4 transition hover:border-slate-400/70 hover:bg-white/70 hover:text-slate-900 hover:decoration-slate-500"
|
||||
@click="openDisclaimerPage"
|
||||
>
|
||||
{{ t('home.disclaimer.link') }}
|
||||
</button>
|
||||
<p class="mt-2 text-xs leading-5 text-slate-500">
|
||||
{{ t('home.disclaimer.supportText') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="existingProjectDialogOpen"
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="closeExistingProjectDialog"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-xl border bg-background shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.chooseExistingProject') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ t('home.dialog.chooseExistingProjectDesc') }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeExistingProjectDialog">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 space-y-2 overflow-auto px-5 py-4">
|
||||
<div
|
||||
v-if="!existingProjectLoading && existingProjects.length === 0"
|
||||
class="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-5 text-center text-sm text-slate-500"
|
||||
>
|
||||
{{ t('home.dialog.noProjectYet') }}
|
||||
</div>
|
||||
<button
|
||||
v-for="project in existingProjects"
|
||||
:key="project.id"
|
||||
type="button"
|
||||
:disabled="isExistingProjectOpened(project.id)"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left transition"
|
||||
:class="isExistingProjectOpened(project.id)
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-100/80 opacity-70'
|
||||
: 'cursor-pointer hover:border-slate-300 hover:bg-slate-50'"
|
||||
@click="enterExistingProject(project.id)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium text-slate-800">
|
||||
{{ project.name }}
|
||||
<span v-if="isExistingProjectOpened(project.id)" class="ml-1 text-xs text-slate-500">
|
||||
{{ t('tab.toolbar.opened') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-slate-500">{{ project.id }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 pl-2 text-xs text-slate-500">
|
||||
{{ t('tab.toolbar.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
|
||||
<Button variant="outline" @click="closeExistingProjectDialog">{{ t('common.cancel') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectDialogOpen"
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="closeProjectCalcDialog"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.newProject') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ t('home.dialog.chooseIndustryDesc') }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeProjectCalcDialog">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-medium text-foreground">{{ t('home.dialog.industry') }}</span>
|
||||
<SelectRoot v-model="projectIndustry">
|
||||
<SelectTrigger
|
||||
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
|
||||
>
|
||||
<span :class="projectIndustryLabel ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ projectIndustryLabel || t('home.dialog.selectIndustry') }}
|
||||
</span>
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
:side-offset="6"
|
||||
position="popper"
|
||||
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
|
||||
>
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem
|
||||
v-for="item in industryTypeList"
|
||||
:key="`project-${item.id}`"
|
||||
:value="String(item.id)"
|
||||
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
||||
>
|
||||
<SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
|
||||
<Button variant="outline" @click="closeProjectCalcDialog">{{ t('common.cancel') }}</Button>
|
||||
<Button :disabled="projectSubmitting || !projectIndustry" @click="confirmProjectCalc">
|
||||
{{ projectSubmitting ? t('home.dialog.entering') : t('home.dialog.enterProjectCalc') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="homeImportConfirmOpen"
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="cancelHomeImportConfirm"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
||||
<div class=" px-5 py-4">
|
||||
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.confirmImport') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ t('home.dialog.confirmImportDesc', { file: pendingHomeImportFileName || t('home.cards.pickFile') }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-4">
|
||||
<Button variant="outline" @click="cancelHomeImportConfirm">{{ t('common.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="confirmHomeImport">{{ t('home.dialog.confirmImportAction') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-hero {
|
||||
animation: none;
|
||||
}
|
||||
.home-card-base {
|
||||
min-height: 248px;
|
||||
}
|
||||
.home-title {
|
||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||
border-radius: 20px;
|
||||
background-image: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(59, 130, 246, 0.06), transparent 70%);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.home-entry-item {
|
||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.home-slogan-row {
|
||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.6s both;
|
||||
}
|
||||
.home-entry-item--1 { animation-delay: 0.2s; }
|
||||
.home-entry-item--2 { animation-delay: 0.3s; }
|
||||
.home-entry-item--3 { animation-delay: 0.4s; }
|
||||
.home-entry-item--4 { animation-delay: 0.5s; }
|
||||
.home-entry-item--5 { animation-delay: 0.6s; }
|
||||
.home-disclaimer {
|
||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.6s both;
|
||||
}
|
||||
|
||||
@keyframes hero-in {
|
||||
from { opacity: 0; transform: translateX(-20px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
.home-hero-meteor {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0));
|
||||
transform: rotate(-28deg);
|
||||
opacity: 0;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
|
||||
animation: hero-meteor 3.8s linear infinite;
|
||||
}
|
||||
.home-hero-meteor--1 {
|
||||
top: 16%;
|
||||
right: -30%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.home-hero-meteor--2 {
|
||||
top: 38%;
|
||||
right: -40%;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
.home-hero-meteor--3 {
|
||||
top: 62%;
|
||||
right: -35%;
|
||||
animation-delay: 2.2s;
|
||||
}
|
||||
.home-hero-meteor--4 {
|
||||
top: 24%;
|
||||
right: -45%;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
.home-hero-meteor--5 {
|
||||
top: 50%;
|
||||
right: -28%;
|
||||
animation-delay: 1.7s;
|
||||
}
|
||||
.home-hero-meteor--6 {
|
||||
top: 74%;
|
||||
right: -42%;
|
||||
animation-delay: 2.8s;
|
||||
}
|
||||
.home-hero-meteor--7 {
|
||||
top: 10%;
|
||||
right: -48%;
|
||||
animation-delay: 0.35s;
|
||||
}
|
||||
.home-hero-meteor--8 {
|
||||
top: 31%;
|
||||
right: -26%;
|
||||
animation-delay: 1.05s;
|
||||
}
|
||||
.home-hero-meteor--9 {
|
||||
top: 56%;
|
||||
right: -50%;
|
||||
animation-delay: 2.45s;
|
||||
}
|
||||
.home-hero-meteor--10 {
|
||||
top: 82%;
|
||||
right: -30%;
|
||||
animation-delay: 3.15s;
|
||||
}
|
||||
@keyframes hero-meteor {
|
||||
0% { transform: translate3d(0, 0, 0) rotate(-28deg); opacity: 0; }
|
||||
8% { opacity: 0.9; }
|
||||
34% { opacity: 0.9; }
|
||||
42% { opacity: 0; }
|
||||
100% { transform: translate3d(-340px, 220px, 0) rotate(-28deg); opacity: 0; }
|
||||
}
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<TypeLine
|
||||
scene="ht-fee-method-type-line"
|
||||
:title="titleText"
|
||||
:subtitle="t('htFeeMethodTypeLine.contractId', { id: contractIdText })"
|
||||
:copy-text="contractIdText"
|
||||
:storage-key="activeTypeStorageKey"
|
||||
default-category="rate-fee"
|
||||
:categories="categories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
import HtFeeGrid from '@/features/shared/components/HtFeeGrid.vue'
|
||||
import HtFeeRateMethodForm from '@/features/ht/components/HtFeeRateMethodForm.vue'
|
||||
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
|
||||
|
||||
interface TypeLineCategoryItem {
|
||||
key: string
|
||||
label: string
|
||||
component: Component
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sourceTitle?: string
|
||||
storageKey: string
|
||||
rowId: string | number
|
||||
rowName?: string
|
||||
contractId?: string
|
||||
contractName?: string
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const sourceTitleText = computed(() => props.sourceTitle || t('htFeeMethodTypeLine.feeDetail'))
|
||||
const rowNameText = computed(() => props.rowName || t('htFeeMethodTypeLine.unnamed'))
|
||||
const rowIdText = computed(() => String(props.rowId || '').trim())
|
||||
const contractIdText = computed(() => String(props.contractId || '').trim())
|
||||
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
|
||||
const titleText = computed(() => t('htFeeMethodTypeLine.title', {
|
||||
contractName: contractNameText.value,
|
||||
rowName: rowNameText.value || sourceTitleText.value
|
||||
}))
|
||||
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${rowIdText.value}`)
|
||||
const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
|
||||
`${props.storageKey}-${rowIdText.value}-${method}`
|
||||
|
||||
const quantityUnitPricePane = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtFeeGrid',
|
||||
setup() {
|
||||
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
|
||||
return () =>
|
||||
h(HtFeeGrid, {
|
||||
title: t('htFeeMethodTypeLine.quantityUnitPrice'),
|
||||
storageKey: quantityStorageKey.value,
|
||||
htMainStorageKey: props.storageKey,
|
||||
htRowId: rowIdText.value,
|
||||
htMethodType: 'quantity-unit-price-fee'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const rateFeePane = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtFeeMethodTypePane-rate-fee',
|
||||
setup() {
|
||||
const rateStorageKey = computed(() => buildMethodStorageKey('rate-fee'))
|
||||
return () =>
|
||||
h(HtFeeRateMethodForm, {
|
||||
storageKey: rateStorageKey.value,
|
||||
contractId: props.contractId,
|
||||
htMainStorageKey: props.storageKey,
|
||||
htRowId: rowIdText.value,
|
||||
htMethodType: 'rate-fee'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const hourlyFeePane = markRaw(
|
||||
defineComponent({
|
||||
name: 'HtFeeMethodTypePane-hourly-fee',
|
||||
setup() {
|
||||
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
|
||||
return () =>
|
||||
h(HourlyFeeGrid, {
|
||||
title: t('hourlyFeeGrid.title'),
|
||||
storageKey: hourlyStorageKey.value,
|
||||
htMainStorageKey: props.storageKey,
|
||||
htRowId: rowIdText.value,
|
||||
htMethodType: 'hourly-fee'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const isReserveFee = computed(() => props.sourceTitle === t('htSummary.reservePrefix'))
|
||||
const showWorkContent = computed(() => {
|
||||
if (isReserveFee.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const workContentPane = markRaw(
|
||||
defineComponent({
|
||||
name: 'WorkContentPane',
|
||||
setup() {
|
||||
const AsyncWorkContentGrid = defineAsyncComponent({
|
||||
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
|
||||
onError: err => {
|
||||
console.error('加载 WorkContentGrid 组件失败:', err)
|
||||
}
|
||||
})
|
||||
return () => h(AsyncWorkContentGrid, {
|
||||
title: t('workContent.title'),
|
||||
storageKey: `work-content-${props.storageKey}-${rowIdText.value}`,
|
||||
dictMode: 'additional'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const categories = computed<TypeLineCategoryItem[]>(() => {
|
||||
const base: TypeLineCategoryItem[] = [
|
||||
{ key: 'rate-fee', label: t('htFeeGrid.columns.rateFee'), component: rateFeePane },
|
||||
{ key: 'hourly-fee', label: t('htFeeGrid.columns.hourlyFee'), component: hourlyFeePane },
|
||||
{ key: 'quantity-unit-price-fee', label: t('htFeeGrid.columns.quantityUnitPriceFee'), component: quantityUnitPricePane },
|
||||
]
|
||||
if (showWorkContent.value) {
|
||||
base.push({ key: 'work-content', label: t('workContent.title'), component: workContentPane })
|
||||
}
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<TypeLine
|
||||
scene="zxfw-pricing-tab"
|
||||
:title="`${contractName ? `${t('zxFwView.contractPrefix', { name: contractName })} · ` : ''}${fwName}${t('zxFwView.calcSuffix')}`"
|
||||
:subtitle="t('zxFwView.contractId', { id: contractId })"
|
||||
:copy-text="contractId"
|
||||
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
|
||||
:default-category="defaultCategory"
|
||||
:categories="pricingCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
||||
import ScaleFormulaReadonlyPane from '@/features/pricing/components/ScaleFormulaReadonlyPane.vue'
|
||||
import { resolveServicePricingCapabilities } from '@/lib/servicePricing'
|
||||
|
||||
interface ServiceMethodType {
|
||||
enableInvestScale?: boolean | null
|
||||
enableLandScale?: boolean | null
|
||||
investScaleSingleTotal?: boolean | null
|
||||
amount?: boolean | null
|
||||
workDay?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
contractName?: string
|
||||
serviceId: string|number
|
||||
fwName:string
|
||||
type?: ServiceMethodType
|
||||
projectInfoKey?: string
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface PricingCategoryItem {
|
||||
key: string
|
||||
label: string
|
||||
component: Component
|
||||
}
|
||||
|
||||
const methodAvailability = computed(() => {
|
||||
const capability = resolveServicePricingCapabilities(props.type, {
|
||||
investScaleEnabled: true,
|
||||
landScaleEnabled: true,
|
||||
investScaleSingleTotal: false,
|
||||
workloadEnabled: true,
|
||||
hourlyEnabled: true
|
||||
})
|
||||
return {
|
||||
investmentScale: capability.investScaleEnabled,
|
||||
landScale: capability.landScaleEnabled,
|
||||
workload: capability.workloadEnabled,
|
||||
hourly: capability.hourlyEnabled
|
||||
}
|
||||
})
|
||||
|
||||
const createPricingPane = (name: string) =>
|
||||
markRaw(
|
||||
defineComponent({
|
||||
name,
|
||||
setup() {
|
||||
const AsyncPricingView = defineAsyncComponent({
|
||||
loader: () => import(`@/features/pricing/components/${name}.vue`),
|
||||
onError: err => {
|
||||
console.error('load PricingMethodView failed:', err)
|
||||
}
|
||||
})
|
||||
|
||||
return () => h(AsyncPricingView, {
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
projectInfoKey: props.projectInfoKey
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const createMethodUnavailablePane = (title: string, message: string) =>
|
||||
markRaw(
|
||||
defineComponent({
|
||||
name: 'MethodUnavailablePane',
|
||||
setup() {
|
||||
return () => h(MethodUnavailableNotice, { title, message })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
|
||||
const landScaleView = createPricingPane('LandScalePricingPane')
|
||||
const workloadView = createPricingPane('WorkloadPricingPane')
|
||||
const hourlyView = createPricingPane('HourlyPricingPane')
|
||||
|
||||
const createScaleFormulaPane = (
|
||||
method: 'investScale' | 'landScale',
|
||||
name: 'InvestmentScaleFormulaPane' | 'LandScaleFormulaPane'
|
||||
) =>
|
||||
markRaw(
|
||||
defineComponent({
|
||||
name,
|
||||
setup() {
|
||||
return () => h(ScaleFormulaReadonlyPane, {
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
method
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const investmentScaleFormulaView = createScaleFormulaPane('investScale', 'InvestmentScaleFormulaPane')
|
||||
const landScaleFormulaView = createScaleFormulaPane('landScale', 'LandScaleFormulaPane')
|
||||
|
||||
const workContentPane = markRaw(
|
||||
defineComponent({
|
||||
name: 'WorkContentPane',
|
||||
setup() {
|
||||
const AsyncWorkContentGrid = defineAsyncComponent({
|
||||
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
|
||||
onError: err => {
|
||||
console.error('load WorkContentGrid failed:', err)
|
||||
}
|
||||
})
|
||||
|
||||
return () => h(AsyncWorkContentGrid, {
|
||||
title: t('zxFwView.workContentTitle'),
|
||||
storageKey: `work-content-${props.contractId}-${props.serviceId}`,
|
||||
contractId: props.contractId,
|
||||
projectInfoKey: props.projectInfoKey,
|
||||
serviceId: props.serviceId,
|
||||
dictMode: 'service'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const investmentScaleUnavailableView = createMethodUnavailablePane(
|
||||
t('zxFwView.unavailable.investmentScaleTitle'),
|
||||
t('zxFwView.unavailable.investmentScaleMessage')
|
||||
)
|
||||
const landScaleUnavailableView = createMethodUnavailablePane(
|
||||
t('zxFwView.unavailable.landScaleTitle'),
|
||||
t('zxFwView.unavailable.landScaleMessage')
|
||||
)
|
||||
const workloadUnavailableView = createMethodUnavailablePane(
|
||||
t('zxFwView.unavailable.workloadTitle'),
|
||||
t('zxFwView.unavailable.workloadMessage')
|
||||
)
|
||||
const hourlyUnavailableView = createMethodUnavailablePane(
|
||||
t('zxFwView.unavailable.hourlyTitle'),
|
||||
t('zxFwView.unavailable.hourlyMessage')
|
||||
)
|
||||
|
||||
const pricingCategories = computed<PricingCategoryItem[]>(() => [
|
||||
|
||||
{
|
||||
key: 'investment-scale-method',
|
||||
label: t('zxFwView.categories.investmentScale'),
|
||||
component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView
|
||||
},
|
||||
{
|
||||
key: 'investment-scale-formula',
|
||||
label: t('zxFwView.categories.investmentScaleFormula'),
|
||||
component: methodAvailability.value.investmentScale ? investmentScaleFormulaView : investmentScaleUnavailableView
|
||||
},
|
||||
{
|
||||
key: 'land-scale-method',
|
||||
label: t('zxFwView.categories.landScale'),
|
||||
component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView
|
||||
},
|
||||
{
|
||||
key: 'land-scale-formula',
|
||||
label: t('zxFwView.categories.landScaleFormula'),
|
||||
component: methodAvailability.value.landScale ? landScaleFormulaView : landScaleUnavailableView
|
||||
},
|
||||
{
|
||||
key: 'workload-method',
|
||||
label: t('zxFwView.categories.workload'),
|
||||
component: methodAvailability.value.workload ? workloadView : workloadUnavailableView
|
||||
},
|
||||
{
|
||||
key: 'hourly-method',
|
||||
label: t('zxFwView.categories.hourly'),
|
||||
component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView
|
||||
},
|
||||
{
|
||||
key: 'work-content',
|
||||
label: t('zxFwView.categories.workContent'),
|
||||
component: workContentPane
|
||||
},
|
||||
])
|
||||
|
||||
const defaultCategory = computed(() => {
|
||||
const m = methodAvailability.value
|
||||
if (m.investmentScale) return 'investment-scale-method'
|
||||
if (m.landScale) return 'land-scale-method'
|
||||
if (m.workload) return 'workload-method'
|
||||
if (m.hourly) return 'hourly-method'
|
||||
return 'work-content'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
|
||||
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
type ServiceItem = {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const projectIndustry = ref('')
|
||||
const kvStore = useKvStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
console.error('loadProjectIndustry failed:', error)
|
||||
projectIndustry.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const filteredServiceDict = computed<Record<string, ServiceItem>>(() => {
|
||||
const industry = projectIndustry.value
|
||||
if (!industry) return {}
|
||||
const entries = getServiceDictEntries()
|
||||
.filter(({ item }) => isIndustryEnabledByType(item, getIndustryTypeValue(industry))
|
||||
)
|
||||
.map(({ id, item }) => [id, item as ServiceItem] as const)
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<XmFactorGrid :title="t('htFactors.consultCategoryTitle')" storage-key="xm-consult-category-factor-v1" :dict="filteredServiceDict"
|
||||
:disable-budget-edit-when-standard-null="true" :exclude-notshow-by-zxflxs="true"
|
||||
:init-budget-value-from-standard="true" />
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
|
||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
type MajorItem = {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const projectIndustry = ref('')
|
||||
const hasProjectBaseInfo = ref(false)
|
||||
const kvStore = useKvStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
|
||||
hasProjectBaseInfo.value = Boolean(data)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
console.error('loadProjectIndustry failed:', error)
|
||||
hasProjectBaseInfo.value = false
|
||||
projectIndustry.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
|
||||
const industry = projectIndustry.value
|
||||
if (!industry) return {}
|
||||
const entries = getMajorDictEntries()
|
||||
.filter(({ id }) => isMajorIdInIndustryScope(id, industry))
|
||||
.map(({ id, item }) => [id, item as MajorItem] as const)
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<XmFactorGrid
|
||||
:title="t('htFactors.majorTitle')"
|
||||
storage-key="xm-major-factor-v1"
|
||||
:dict="filteredMajorDict"
|
||||
:disable-budget-edit-when-standard-null="true"
|
||||
:exclude-notshow-by-zxflxs="true"
|
||||
:init-budget-value-from-standard="true"
|
||||
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,474 @@
|
||||
<script setup lang="ts">
|
||||
import { parseDate } from '@internationalized/date'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
getIndustryDisplayName,
|
||||
industryTypeList,
|
||||
} from '@/sql'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { upsertProject } from '@/lib/projectRegistry'
|
||||
import { readCurrentProjectId } from '@/lib/workspace'
|
||||
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
||||
import { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DatePickerAnchor,
|
||||
DatePickerArrow,
|
||||
DatePickerCalendar,
|
||||
DatePickerCell,
|
||||
DatePickerCellTrigger,
|
||||
DatePickerClose,
|
||||
DatePickerContent,
|
||||
DatePickerField,
|
||||
DatePickerGrid,
|
||||
DatePickerGridBody,
|
||||
DatePickerGridHead,
|
||||
DatePickerGridRow,
|
||||
DatePickerHeadCell,
|
||||
DatePickerHeader,
|
||||
DatePickerHeading,
|
||||
DatePickerInput,
|
||||
DatePickerNext,
|
||||
DatePickerPrev,
|
||||
DatePickerRoot,
|
||||
DatePickerTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
interface XmInfoState {
|
||||
projectIndustry?: string
|
||||
projectName?: string
|
||||
preparedBy?: string
|
||||
reviewedBy?: string
|
||||
preparedCompany?: string
|
||||
preparedDate?: string
|
||||
overview?: string
|
||||
desc?: string
|
||||
}
|
||||
|
||||
type MajorParentNode = { id: string; name: string }
|
||||
|
||||
const DB_KEY = 'xm-base-info-v1'
|
||||
const { t, locale } = useI18n()
|
||||
const DEFAULT_PROJECT_NAME = t('xmInfo.defaultProjectName')
|
||||
const DEFAULT_DESC = t('xmInfo.defaultDesc')
|
||||
const INDUSTRY_HINT_TEXT = computed(() => t('xmInfo.industryHint'))
|
||||
const REPORT_CONTENT_HINT_TEXT = computed(() => t('xmInfo.reportContentHint'))
|
||||
const OTHER_DESC_HINT_TEXT = computed(() => t('xmInfo.otherDescHint'))
|
||||
const FIELD_HINT_BUTTON_CLASS = 'inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground'
|
||||
const FIELD_HINT_ICON_CLASS = 'h-4 w-4'
|
||||
const getTodayDateString = () => {
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const isProjectInitialized = ref(false)
|
||||
const isBootstrapping = ref(true)
|
||||
|
||||
const projectName = ref('')
|
||||
const projectIndustry = ref('')
|
||||
const preparedBy = ref('')
|
||||
const reviewedBy = ref('')
|
||||
const preparedCompany = ref('')
|
||||
const preparedDate = ref(getTodayDateString())
|
||||
const preparedDatePickerValue = ref<any>(undefined)
|
||||
const overview = ref('')
|
||||
const desc = ref(DEFAULT_DESC)
|
||||
|
||||
const normalizeDateString = (value: unknown): string => {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
try {
|
||||
const parsed = parseDate(trimmed)
|
||||
return parsed.toString() === trimmed ? trimmed : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const syncPreparedDatePickerFromString = () => {
|
||||
if (!preparedDate.value) {
|
||||
preparedDatePickerValue.value = undefined
|
||||
return
|
||||
}
|
||||
try {
|
||||
preparedDatePickerValue.value = parseDate(preparedDate.value)
|
||||
} catch {
|
||||
preparedDatePickerValue.value = undefined
|
||||
preparedDate.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreparedDateSelect = (date: any) => {
|
||||
preparedDatePickerValue.value = date
|
||||
preparedDate.value = date?.toString() ?? ''
|
||||
}
|
||||
|
||||
const majorParentNodes = computed<MajorParentNode[]>(() =>
|
||||
industryTypeList.map(item => ({
|
||||
id: item.id,
|
||||
name: getIndustryDisplayName(item.id, locale.value) || String(item.name || '')
|
||||
}))
|
||||
)
|
||||
const majorParentCodeSet = new Set<string>(industryTypeList.map(item => String(item.id)))
|
||||
const DEFAULT_PROJECT_INDUSTRY = String(industryTypeList[0]?.id || '')
|
||||
const kvStore = useKvStore()
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const normalizedProjectName = projectName.value.trim() || DEFAULT_PROJECT_NAME
|
||||
projectName.value = normalizedProjectName
|
||||
const payload: XmInfoState = {
|
||||
projectIndustry: projectIndustry.value,
|
||||
projectName: normalizedProjectName,
|
||||
preparedBy: preparedBy.value,
|
||||
reviewedBy: reviewedBy.value,
|
||||
preparedCompany: preparedCompany.value,
|
||||
preparedDate: preparedDate.value,
|
||||
overview: overview.value,
|
||||
desc: desc.value
|
||||
}
|
||||
await kvStore.setItem(DB_KEY, payload)
|
||||
upsertProject(readCurrentProjectId(), normalizedProjectName)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmInfoState>(DB_KEY)
|
||||
if (data) {
|
||||
isProjectInitialized.value = true
|
||||
projectIndustry.value =
|
||||
typeof data.projectIndustry === 'string' && majorParentCodeSet.has(data.projectIndustry)
|
||||
? data.projectIndustry
|
||||
: DEFAULT_PROJECT_INDUSTRY
|
||||
projectName.value =
|
||||
typeof data.projectName === 'string' && data.projectName.trim() ? data.projectName : DEFAULT_PROJECT_NAME
|
||||
preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : ''
|
||||
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
|
||||
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
|
||||
preparedDate.value = normalizeDateString(data.preparedDate) || getTodayDateString()
|
||||
overview.value = typeof data.overview === 'string' ? data.overview : ''
|
||||
desc.value = typeof data.desc === 'string' && data.desc.trim() ? data.desc : DEFAULT_DESC
|
||||
syncPreparedDatePickerFromString()
|
||||
return
|
||||
}
|
||||
|
||||
isProjectInitialized.value = false
|
||||
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
|
||||
projectName.value = DEFAULT_PROJECT_NAME
|
||||
preparedBy.value = ''
|
||||
reviewedBy.value = ''
|
||||
preparedCompany.value = ''
|
||||
preparedDate.value = getTodayDateString()
|
||||
overview.value = ''
|
||||
desc.value = DEFAULT_DESC
|
||||
syncPreparedDatePickerFromString()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
isProjectInitialized.value = false
|
||||
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
|
||||
projectName.value = DEFAULT_PROJECT_NAME
|
||||
preparedBy.value = ''
|
||||
reviewedBy.value = ''
|
||||
preparedCompany.value = ''
|
||||
preparedDate.value = getTodayDateString()
|
||||
overview.value = ''
|
||||
desc.value = DEFAULT_DESC
|
||||
syncPreparedDatePickerFromString()
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const schedulePersist = () => {
|
||||
if (!isProjectInitialized.value) return
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
const handleProjectNameBlur = () => {
|
||||
if (!projectName.value.trim()) {
|
||||
projectName.value = DEFAULT_PROJECT_NAME
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[projectIndustry, projectName, preparedBy, reviewedBy, preparedCompany, preparedDate, overview, desc],
|
||||
schedulePersist
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await waitForHydration('kv')
|
||||
await loadFromIndexedDB()
|
||||
} finally {
|
||||
isBootstrapping.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="space-y-6 h-full">
|
||||
<div
|
||||
v-if="isBootstrapping"
|
||||
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isProjectInitialized"
|
||||
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('xmInfo.createFromHomeFirst') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="md:col-span-2 xl:col-span-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectName') }}</label>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('xmInfo.reportContentHintAria')"
|
||||
:class="FIELD_HINT_BUTTON_CLASS"
|
||||
>
|
||||
<CircleHelp :class="FIELD_HINT_ICON_CLASS" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ REPORT_CONTENT_HINT_TEXT }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<input
|
||||
v-model="projectName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t('xmInfo.defaultProjectName')"
|
||||
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@blur="handleProjectNameBlur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 xl:col-span-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectIndustry') }}</label>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('xmInfo.industryHintAria')"
|
||||
:class="FIELD_HINT_BUTTON_CLASS"
|
||||
>
|
||||
<CircleHelp :class="FIELD_HINT_ICON_CLASS" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ INDUSTRY_HINT_TEXT }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-3 rounded-lg border bg-background px-3 py-2">
|
||||
<label
|
||||
v-for="item in majorParentNodes"
|
||||
:key="item.id"
|
||||
class="inline-flex items-center gap-2 text-sm text-foreground/80"
|
||||
>
|
||||
<input
|
||||
v-model="projectIndustry"
|
||||
type="radio"
|
||||
:value="item.id"
|
||||
disabled
|
||||
class="h-4 w-4 cursor-not-allowed accent-primary"
|
||||
/>
|
||||
<span>{{ item.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 xl:col-span-4">
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.overview') }}</label>
|
||||
<textarea
|
||||
v-model="overview"
|
||||
rows="3"
|
||||
:placeholder="t('xmInfo.placeholders.overview')"
|
||||
class="mt-2 w-full rounded-lg border bg-background px-4 py-2 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedBy') }}</label>
|
||||
<input
|
||||
v-model="preparedBy"
|
||||
type="text"
|
||||
:placeholder="t('xmInfo.placeholders.preparedBy')"
|
||||
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.reviewedBy') }}</label>
|
||||
<input
|
||||
v-model="reviewedBy"
|
||||
type="text"
|
||||
:placeholder="t('xmInfo.placeholders.reviewedBy')"
|
||||
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedCompany') }}</label>
|
||||
<input
|
||||
v-model="preparedCompany"
|
||||
type="text"
|
||||
:placeholder="t('xmInfo.placeholders.preparedCompany')"
|
||||
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedDate') }}</label>
|
||||
<DatePickerRoot
|
||||
locale="en-CA"
|
||||
:model-value="preparedDatePickerValue"
|
||||
@update:model-value="handlePreparedDateSelect"
|
||||
>
|
||||
<DatePickerAnchor class="mt-2 block w-full">
|
||||
<DatePickerField
|
||||
v-slot="{ segments }"
|
||||
class="flex h-10 w-full items-center justify-between rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<template
|
||||
v-for="(segment, index) in segments"
|
||||
:key="`${segment.part}-${index}`"
|
||||
>
|
||||
<DatePickerInput
|
||||
:part="segment.part"
|
||||
:class="segment.part === 'literal' ? 'text-muted-foreground/70' : 'text-foreground'"
|
||||
>
|
||||
{{ segment.part === 'literal' ? '-' : segment.value }}
|
||||
</DatePickerInput>
|
||||
</template>
|
||||
</div>
|
||||
<DatePickerTrigger as-child>
|
||||
<button type="button" class="cursor-pointer inline-flex h-6 w-6 items-center justify-center text-muted-foreground">
|
||||
<CalendarIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</DatePickerTrigger>
|
||||
</DatePickerField>
|
||||
</DatePickerAnchor>
|
||||
<DatePickerContent class="z-50 w-[22rem] rounded-lg border bg-card p-4 shadow-lg">
|
||||
<DatePickerArrow class="fill-border" />
|
||||
<DatePickerCalendar v-slot="{ weekDays, grid }" locale="zh-CN">
|
||||
<DatePickerHeader class="mb-2 flex items-center justify-between">
|
||||
<DatePickerPrev as-child>
|
||||
<button
|
||||
type="button"
|
||||
class=" cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
</DatePickerPrev>
|
||||
<DatePickerHeading class="text-base font-medium text-foreground" />
|
||||
<DatePickerNext as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</DatePickerNext>
|
||||
</DatePickerHeader>
|
||||
<div class="space-y-2">
|
||||
<DatePickerGrid
|
||||
v-for="month in grid"
|
||||
:key="month.value.toString()"
|
||||
class="w-full border-collapse select-none"
|
||||
>
|
||||
<DatePickerGridHead>
|
||||
<DatePickerGridRow class="grid grid-cols-7">
|
||||
<DatePickerHeadCell
|
||||
v-for="day in weekDays"
|
||||
:key="day"
|
||||
class="flex h-9 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ day }}
|
||||
</DatePickerHeadCell>
|
||||
</DatePickerGridRow>
|
||||
</DatePickerGridHead>
|
||||
<DatePickerGridBody>
|
||||
<DatePickerGridRow
|
||||
v-for="(weekDates, index) in month.rows"
|
||||
:key="`${month.value.toString()}-${index}`"
|
||||
class="grid grid-cols-7"
|
||||
>
|
||||
<DatePickerCell
|
||||
v-for="dateValue in weekDates"
|
||||
:key="dateValue.toString()"
|
||||
:date="dateValue"
|
||||
class="h-10 w-full p-0.5"
|
||||
>
|
||||
<DatePickerCellTrigger
|
||||
:day="dateValue"
|
||||
:month="month.value"
|
||||
class="cursor-pointer h-full w-full rounded-md border border-transparent bg-transparent text-base outline-none transition hover:bg-muted data-[outside-view]:text-muted-foreground/40 data-[selected]:border-primary data-[selected]:bg-transparent data-[selected]:text-foreground data-[disabled]:opacity-40 data-[unavailable]:text-muted-foreground/40"
|
||||
>
|
||||
{{ dateValue.day }}
|
||||
</DatePickerCellTrigger>
|
||||
</DatePickerCell>
|
||||
</DatePickerGridRow>
|
||||
</DatePickerGridBody>
|
||||
</DatePickerGrid>
|
||||
</div>
|
||||
</DatePickerCalendar>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<DatePickerClose as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</button>
|
||||
|
||||
</DatePickerClose>
|
||||
</div>
|
||||
</DatePickerContent>
|
||||
</DatePickerRoot>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="md:col-span-2 xl:col-span-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.desc') }}</label>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('xmInfo.otherDescHintAria')"
|
||||
:class="FIELD_HINT_BUTTON_CLASS"
|
||||
>
|
||||
<CircleHelp :class="FIELD_HINT_ICON_CLASS" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ OTHER_DESC_HINT_TEXT }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="desc"
|
||||
rows="4"
|
||||
:placeholder="t('xmInfo.placeholders.desc')"
|
||||
class="mt-2 w-full rounded-lg border bg-background px-4 py-2 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<TypeLine
|
||||
scene="xm-tab"
|
||||
title=""
|
||||
storage-key="project-active-cat"
|
||||
default-category="info"
|
||||
:categories="xmCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
const { t } = useI18n()
|
||||
const infoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/info.vue')))
|
||||
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmInfo.vue')))
|
||||
const htView = markRaw(defineAsyncComponent(() => import('@/features/ht/components/Ht.vue')))
|
||||
const consultCategoryFactorView = markRaw(
|
||||
defineAsyncComponent(() => import('@/features/xm/components/XmConsultCategoryFactor.vue'))
|
||||
)
|
||||
const majorFactorView = markRaw(
|
||||
defineAsyncComponent(() => import('@/features/xm/components/XmMajorFactor.vue'))
|
||||
)
|
||||
|
||||
const xmCategories = computed(() => [
|
||||
{ key: 'info', label: t('xmCard.categories.info'), component: infoView },
|
||||
{ key: 'scale-info', label: t('xmCard.categories.scaleInfo'), component: scaleInfoView },
|
||||
{ key: 'consult-category-factor', label: t('xmCard.categories.consultCategoryFactor'), component: consultCategoryFactorView },
|
||||
{ key: 'major-factor', label: t('xmCard.categories.majorFactor'), component: majorFactorView },
|
||||
{ key: 'contract', label: t('xmCard.categories.contract'), component: htView }
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
|
||||
const DB_KEY = 'xm-info-v3'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonAgGrid title="项目明细" :dbKey="DB_KEY" />
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
export const MAJOR_NAME_EN_BY_CODE: Record<string, string> = {
|
||||
E1: 'General Transportation Engineering',
|
||||
'E1-1': 'Land (Sea) Acquisition Compensation',
|
||||
'E1-2': 'Relocation Compensation',
|
||||
'E1-3': 'Relocation/Utility Diversion Works',
|
||||
'E1-4': 'Other Construction Expenses',
|
||||
'E1-5': 'Contingency',
|
||||
'E1-6': 'Construction Loan Interest',
|
||||
E2: 'Highway Engineering',
|
||||
'E2-1': 'Temporary Works',
|
||||
'E2-2': 'Subgrade Works',
|
||||
'E2-3': 'Pavement Works',
|
||||
'E2-4': 'Bridge and Culvert Works',
|
||||
'E2-5': 'Tunnel Works',
|
||||
'E2-6': 'Interchange Works',
|
||||
'E2-7': 'MEP Works',
|
||||
'E2-8': 'Traffic Safety Facilities',
|
||||
'E2-9': 'Landscaping and Environmental Works',
|
||||
'E2-10': 'Building Works',
|
||||
E3: 'Railway Engineering',
|
||||
'E3-1': 'Large Temporary Facilities and Transitional Works',
|
||||
'E3-2': 'Subgrade Works',
|
||||
'E3-3': 'Bridge and Culvert Works',
|
||||
'E3-4': 'Tunnel and Cut-and-Cover Works',
|
||||
'E3-5': 'Track Works',
|
||||
'E3-6': 'Communication, Signaling, Information and Disaster Monitoring',
|
||||
'E3-7': 'Power and Traction Power Supply Works',
|
||||
'E3-8': 'Building Works (Buildings and Ancillary Works)',
|
||||
'E3-9': 'Interior Decoration Works',
|
||||
E4: 'Waterway Engineering',
|
||||
'E4-1': 'Temporary Works',
|
||||
'E4-2': 'Civil Works',
|
||||
'E4-3': 'Mechanical, Electrical and Steel Structure Works',
|
||||
'E4-4': 'Equipment Works',
|
||||
'E4-5': 'Ancillary Building Works (Buildings and Ancillary Works)'
|
||||
}
|
||||
|
||||
export const SERVICE_NAME_EN_BY_CODE: Record<string, string> = {
|
||||
D1: 'Whole-Process Cost Consulting',
|
||||
D2: 'Stage-Based Cost Consulting',
|
||||
'D2-1': 'Early-Stage Cost Consulting',
|
||||
'D2-2-1': 'Implementation-Stage Cost Consulting (Highway/Waterway)',
|
||||
'D2-2-2': 'Implementation-Stage Cost Consulting (Railway)',
|
||||
D3: 'Basic Cost Consulting',
|
||||
'D3-1': 'Investment Estimate',
|
||||
'D3-2': 'Design Estimate',
|
||||
'D3-3': 'Construction Drawing Budget',
|
||||
'D3-4': 'BOQ and BOQ Budget (or Max Bid Price)',
|
||||
'D3-5': 'Estimate Review/Reconciliation (Railway Only)',
|
||||
'D3-6-1': 'Contract (Project) Settlement',
|
||||
'D3-6-2': 'Contract (Project) Settlement',
|
||||
'D3-7': 'Final Account',
|
||||
D4: 'Specialized Cost Consulting',
|
||||
'D4-1': 'Cost Advisory Service',
|
||||
'D4-2': 'Cost Policy Formulation/Revision',
|
||||
'D4-3': 'Cost Science and Technology Research',
|
||||
'D4-4': 'Quota Determination',
|
||||
'D4-5': 'Cost Information Consulting',
|
||||
'D4-6': 'Cost Appraisal',
|
||||
'D4-7': 'Cost Estimation',
|
||||
'D4-8': 'Cost Accounting',
|
||||
'D4-9': 'Quantity Takeoff',
|
||||
'D4-10': 'Variation Cost Consulting',
|
||||
'D4-11': 'Adjusted Estimate',
|
||||
'D4-12': 'Adjusted Budget Estimate',
|
||||
'D4-13': 'Cost Inspection',
|
||||
'D4-14': 'Other Specialized Consulting',
|
||||
'D4-15-1': 'Cost Data Validation (Estimate)',
|
||||
'D4-15-2': 'Cost Data Validation (Budget Estimate)',
|
||||
'D4-15-3': 'Cost Data Validation (Construction Drawing Budget)',
|
||||
'D4-15-4': 'Cost Data Validation (BOQ and BOQ Budget)',
|
||||
'D4-15-5': 'Cost Data Validation (Estimate Review, Railway Only)',
|
||||
'D4-15-6': 'Cost Data Validation (Contract Settlement)',
|
||||
'D4-15-7': 'Cost Data Validation (Contract Settlement)',
|
||||
'D4-15-8': 'Cost Data Validation (Final Account)'
|
||||
}
|
||||
|
||||
export const TASK_NAME_EN_BY_CODE: Record<string, string> = {
|
||||
'C4-1': 'Daily Cost Advisory',
|
||||
'C4-2': 'Special Cost Advisory',
|
||||
'C5-1': 'Organization and Research',
|
||||
'C5-2-1': 'Document Drafting',
|
||||
'C5-2-2': 'Document Drafting',
|
||||
'C5-3-1': 'Review',
|
||||
'C5-3-2': 'Review',
|
||||
'C5-3-3': 'Review',
|
||||
'C6-1': 'Organization and Research',
|
||||
'C6-2-1': 'Research and Report Writing',
|
||||
'C6-2-2': 'Research and Report Writing',
|
||||
'C6-2-3': 'Research and Report Writing',
|
||||
'C6-3-1': 'Standards/Guideline Drafting',
|
||||
'C6-3-2': 'Standards/Guideline Drafting',
|
||||
'C6-3-3': 'Standards/Guideline Drafting',
|
||||
'C6-3-4': 'Standards/Guideline Drafting',
|
||||
'C6-4-1': 'Review and Acceptance',
|
||||
'C6-4-2': 'Review and Acceptance',
|
||||
'C6-4-3': 'Review and Acceptance',
|
||||
'C6-5-1': 'Training and Communication',
|
||||
'C6-5-2': 'Training and Communication',
|
||||
'C7-1': 'Organization and Research',
|
||||
'C7-2': 'Outline Preparation',
|
||||
'C7-3': 'Data Collection and Measurement',
|
||||
'C7-4-1': 'Data Processing and Analysis',
|
||||
'C7-4-2': 'Data Processing and Analysis',
|
||||
'C7-5': 'Quota Determination Report',
|
||||
'C7-6-1': 'Quota Text and Notes Drafting',
|
||||
'C7-6-2': 'Quota Text and Notes Drafting',
|
||||
'C7-7-1': 'Review and Acceptance',
|
||||
'C7-7-2': 'Review and Acceptance',
|
||||
'C7-7-3': 'Review and Acceptance',
|
||||
'C7-8-1': 'Training and Communication',
|
||||
'C7-8-2': 'Training and Communication',
|
||||
'C8-1': 'Q ≤ 10',
|
||||
'C8-2': '10 < Q ≤ 30',
|
||||
'C8-3': '30 < Q ≤ 50',
|
||||
'C8-4': '50 < Q ≤ 100',
|
||||
'C8-5': 'Q > 100'
|
||||
}
|
||||
|
||||
export const EXPERT_NAME_EN_BY_CODE: Record<string, string> = {
|
||||
'C9-1-1': 'Technician and Others',
|
||||
'C9-1-2': 'Assistant Engineer',
|
||||
'C9-1-3': 'Intermediate Engineer or Level-2 Cost Engineer',
|
||||
'C9-1-4': 'Senior Engineer or Level-1 Cost Engineer',
|
||||
'C9-1-5': 'Professor-Level Senior Engineer or Senior Expert',
|
||||
'C9-2-1': 'Level-2 Cost Engineer + Intermediate Engineer',
|
||||
'C9-3-1': 'Level-1 Cost Engineer + Intermediate Engineer',
|
||||
'C9-3-2': 'Level-1 Cost Engineer + Senior Engineer'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { enUS } from './locales/en-US'
|
||||
import { zhCN } from './locales/zh-CN'
|
||||
|
||||
export const I18N_LOCALE_KEY = 'jgjs-locale-v1'
|
||||
export const DEFAULT_LOCALE = 'zh-CN'
|
||||
|
||||
const messages = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS
|
||||
} as const
|
||||
|
||||
export type AppLocale = keyof typeof messages
|
||||
|
||||
const getInitialLocale = (): AppLocale => {
|
||||
if (typeof window === 'undefined') return DEFAULT_LOCALE
|
||||
const saved = String(localStorage.getItem(I18N_LOCALE_KEY) || '').trim() as AppLocale
|
||||
if (saved in messages) return saved
|
||||
const language = String(navigator.language || '').toLowerCase()
|
||||
return language.startsWith('en') ? 'en-US' : DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getInitialLocale(),
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages
|
||||
})
|
||||
|
||||
export const setAppLocale = (locale: AppLocale) => {
|
||||
i18n.global.locale.value = locale
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(I18N_LOCALE_KEY, locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
export const enUS = {
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
delete: 'Delete',
|
||||
close: 'Close',
|
||||
clear: 'Clear',
|
||||
loading: 'Loading...'
|
||||
},
|
||||
app: {
|
||||
projectConflict: {
|
||||
title: 'Project Already Open',
|
||||
desc: 'Project "{name}" is already active in another tab. Editing is blocked here to avoid IndexedDB conflicts.',
|
||||
countdown: 'This page will try to close automatically in {seconds} seconds. You can open another project in a new tab first.',
|
||||
opened: '(Opened)',
|
||||
lastEdited: 'Last edited: {time}',
|
||||
openDefault: 'Back to Home',
|
||||
createAndOpen: 'Create and Open'
|
||||
}
|
||||
},
|
||||
home: {
|
||||
title: 'Yue Gong Xue Biao Zi [2026] No. 5\nSpecification for Budget Preparation of Cost Consulting Services for Transportation Engineering',
|
||||
subtitle: 'Project Budget · Quick Calc · Import Data · Related Files',
|
||||
projectCalcTab: 'Project Calculation',
|
||||
quickCalcTab: 'Quick Calculation',
|
||||
cards: {
|
||||
heroTitle: 'Zonghuiyi|Billing Made Simple',
|
||||
heroTitles: [
|
||||
'Zonghuiyi | Billing Made Simple',
|
||||
'Zonghuiyi | No Late Nights for Billing',
|
||||
'Zonghuiyi | Effortless Billing'
|
||||
],
|
||||
heroSubTitle: '',
|
||||
heroDesc: 'Instant Pricing, Instant Results, Leave Time for Creation',
|
||||
heroDescs: [
|
||||
'Instant Pricing, Instant Results, Leave Time for Creation.',
|
||||
'Smart Pricing, One Click to Results, Leave Time for Creation.',
|
||||
'Smart Pricing, One Click to Generate, Leave Time for Creation.',
|
||||
'Zonghuiyi. Simple, No Late Nights, Effortless.'
|
||||
],
|
||||
projectBudget: 'Project Budget',
|
||||
projectBudgetDesc: 'For full project-level calculation across multiple contracts with import/export support',
|
||||
quickCalc: 'Quick Calc',
|
||||
quickCalcDesc: 'Suitable for single service trial, select industry, consultation type, engineering specialty, input base number and get results in seconds',
|
||||
importData: 'Import Data',
|
||||
importDataDesc: 'Import a ".zw" package and create a new project automatically to restore the data without overriding existing projects',
|
||||
relatedFiles: 'Related Files',
|
||||
relatedFilesDesc: 'View, print, and download the fee documents, related bidding documents, and contract templates used by this calculator',
|
||||
viewFiles: 'View Files',
|
||||
enter: 'Enter',
|
||||
pickFile: 'Choose File',
|
||||
pickExisting: 'Choose Existing',
|
||||
openFileSystem: 'Open File System',
|
||||
file: 'File System',
|
||||
fileDataDesc: 'The file system provides related fee documents, bidding documents, contract documents, service contents, and work requirements'
|
||||
},
|
||||
disclaimer: {
|
||||
link: 'View Disclaimer',
|
||||
supportText: 'This calculator is provided with free technical support by Zhongwei Engineering Consulting Co., Ltd.'
|
||||
},
|
||||
dialog: {
|
||||
newProject: 'New Project',
|
||||
chooseIndustryDesc: 'Choose an industry and enter project calculation directly.',
|
||||
industry: 'Industry',
|
||||
selectIndustry: 'Select industry',
|
||||
entering: 'Entering...',
|
||||
enterProjectCalc: 'Enter Project Calculation',
|
||||
confirmImport: 'Confirm Import',
|
||||
confirmImportDesc: 'Import "{file}"',
|
||||
confirmImportAction: 'Import and Create Project',
|
||||
chooseExistingProject: 'Choose Existing Project',
|
||||
chooseExistingProjectDesc: 'Select a project from the list and enter workspace directly.',
|
||||
noProjectYet: 'No project available. Create a new project first.'
|
||||
}
|
||||
},
|
||||
disclaimerPage: {
|
||||
documentTitle: 'Budget Tool Disclaimer',
|
||||
eyebrow: 'DISCLAIMER',
|
||||
pageTitle: 'Disclaimer for Budget Preparation Tool under (T/GDHS017-2026) Cost Consulting Services for Transportation Engineering as Specifications for Budget Compilation',
|
||||
lastUpdatedLabel: 'Last updated: ',
|
||||
lastUpdatedValue: 'April 16, 2026',
|
||||
leadText: 'Thank you for using the cost consulting budget preparation tool for the Specification for Budget Preparation of Cost Consulting Services for Transportation Engineering (T/GDHS 017-2026) provided on this website. Before using this tool, please read the following disclaimer carefully. By continuing to use this tool, you acknowledge that you have read, understood, and agreed to all terms of this disclaimer.',
|
||||
sections: {
|
||||
standardBasisTitle: '1. Standard Basis',
|
||||
standardBasisP1: '1.1 This tool is based on the methodology set out in the group standard Specification for Budget Preparation of Cost Consulting Services for Transportation Engineering (T/GDHS 017-2026) issued by the Guangdong Province Highway Society "GDHS". Users must independently determine whether the standard applies to their specific projects and the regulatory requirements of their local authorities.',
|
||||
standardBasisP2: '1.2 The standard version used by this tool is indicated on the interface as T/GDHS 017-2026. Should the standard be subsequently revised, supplemented, or replaced, this tool may not be updated in a timely manner. Users must independently confirm that the referenced version remains the latest valid version before use.',
|
||||
standardBasisP3: '1.3 The calculation results generated by this tool are based on the budgeting methods, cost structure, and preparation rules in specified the standard. Requirements and calculation approaches may still vary across regions and project owners. This tool does not guarantee that its results will meet the review requirements of any specific project or authority.',
|
||||
referenceOnlyTitle: '2. Results Are for Reference Only',
|
||||
referenceOnlyP1: 'All results generated by this tool, including but not limited to values, breakdowns, summaries and preparation instructions, are produced automatically based on the parameters you provide (such as engineering industry, project scale, consulting category, engineering discipline, job responsibilities, adjustment factor, etc.) and the mathematical models and formulas in the standard. They are for reference purposes only and do not constitute professional advice or any official or mandatory basis for budget approval.',
|
||||
accuracyTitle: '3. No Warranties of Accuracy or Completeness',
|
||||
accuracyP1: 'Although we make reasonable efforts to ensure the tool\'s availability, it is provided on an as-is basis without any express or implied warranty. We do not guarantee that the results will always be accurate, error-free, or complete. Differences may arise due to input errors, formula selection, rounding, or system delays.',
|
||||
riskTitle: '4. Users Bear Their Own Risks',
|
||||
riskP1: 'You should independently assess the reliability of the calculation results and bear all risks and responsibilities arising from any decisions made based thereon. The output of this tool should not replace professional calculation or review. Before making important decisions, you should consult professionals holding a registered certificate in transportation engineering cost or perform manual verification based on the specific project context.',
|
||||
liabilityTitle: '5. Limitation of Liability',
|
||||
liabilityP1: 'To the fullest extent permitted by applicable law, the developers, administrators, publishers, and their affiliates shall not be liable for any direct, indirect, accidental, special, or consequential losses arising from the use of or inability to use this tool, even if advised of the possibility of such losses.',
|
||||
liabilityP2: 'In particular, if any cost consulting enterprise or individual issues deliverables from the results generated by this tool, the issuing party bears sole responsibility for their quality. This tool assumes no responsibility for the accuracy, compliance, or disputes arising from any third-party deliverables.',
|
||||
interruptionTitle: '6. Service Interruption and Changes',
|
||||
interruptionP1: 'We reserve the right to modify, suspend, or terminate part or all of this tool at any time, with or without notice. We are not responsible for unavailability, data loss, or changes in calculation results caused by maintenance, network failures, third-party service interruptions, or updates to the standard.',
|
||||
externalTitle: '7. External Links and Third-Party Content',
|
||||
externalP1: 'If this tool references or links to the National Group Standards Information Platform, the Guangdong Highway Society website, or other third-party websites, such links are provided only for convenience and do not imply endorsement of their accuracy, timeliness, or completeness. We assume no responsibility for any information, services, or content provided by third parties.',
|
||||
lawTitle: '8. Governing Law',
|
||||
lawP1: 'This disclaimer shall be governed by the laws of the People\'s Republic of China. If any provision of this disclaimer is held invalid or unenforceable, the remaining provisions shall remain in effect.'
|
||||
},
|
||||
confirm: {
|
||||
title: 'User Confirmation',
|
||||
desc1: 'I have read, understood, and agree to the full contents of this disclaimer.',
|
||||
desc2: 'You must check the box before continuing to use this tool.',
|
||||
checkbox: 'I have read, understood, and agree to the full contents of this disclaimer.',
|
||||
continue: 'Agree and Continue',
|
||||
hint: 'Once checked, your acceptance will be recorded in this browser so the same restricted entry will not prompt you again.'
|
||||
},
|
||||
actions: {
|
||||
back: 'Back to Home',
|
||||
switchLocale: 'Switch Language'
|
||||
}
|
||||
},
|
||||
tab: {
|
||||
toolbar: {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
language: 'Lang',
|
||||
importExport: 'Import/Export',
|
||||
importData: 'Import',
|
||||
exportData: 'Export',
|
||||
exportReport: 'Export Report',
|
||||
userGuide: 'Guide',
|
||||
reset: 'Reset',
|
||||
resetting: 'Resetting...',
|
||||
projectList: 'Projects',
|
||||
projectCount: 'Projects: {count}',
|
||||
createProject: 'New Project',
|
||||
backHome: 'Back Home',
|
||||
resetAll: 'Reset All',
|
||||
opened: '(Opened)',
|
||||
lastEdited: 'Last edited: {time}'
|
||||
},
|
||||
menu: {
|
||||
closeAll: 'Close All',
|
||||
closeLeft: 'Close Left',
|
||||
closeRight: 'Close Right',
|
||||
closeOther: 'Close Others'
|
||||
},
|
||||
dialog: {
|
||||
resetTitle: 'Confirm Reset',
|
||||
resetDesc: 'All project data will be cleared and the default page will be restored. Continue?',
|
||||
confirmReset: 'Confirm Reset',
|
||||
importOverrideTitle: 'Confirm Override Import',
|
||||
importOverrideDesc: 'Use "{file}" to override all local data for current project. Continue?',
|
||||
confirmOverride: 'Confirm Override',
|
||||
newProjectTitle: 'New Project',
|
||||
newProjectDesc: 'Choose an industry, then open the new project calculation page in a new tab.',
|
||||
createAndOpen: 'Create & Open',
|
||||
creating: 'Creating...',
|
||||
projectLimitTitle: 'Project Limit Reached',
|
||||
projectLimitDesc: 'Project count has reached {max}. Delete one project before adding a new one.',
|
||||
iKnow: 'OK',
|
||||
deleteProjectTitle: 'Confirm Delete Project',
|
||||
deleteCurrentProjectDesc: 'Delete current project "{name}"? Data will be cleared and you will return to home.',
|
||||
deleteProjectDesc: 'Delete project "{name}"? This will remove local data for that project.'
|
||||
},
|
||||
guide: {
|
||||
title: 'User Guide',
|
||||
later: 'Later',
|
||||
prev: 'Prev',
|
||||
next: 'Next',
|
||||
finish: 'Finish and Disable Auto Popup',
|
||||
jumpToStep: 'Jump to step {index}',
|
||||
steps: {
|
||||
step1: {
|
||||
title: 'Project Calculation Overview',
|
||||
description: 'This guide only explains the main project-calculation flow: project setup -> contract segments -> service pricing -> report export.',
|
||||
point1: 'The entry is the "Project Card", and the left flow line guides you through the setup in order.',
|
||||
point2: 'Forms and grids auto-save locally, so manual save is usually unnecessary.',
|
||||
point3: 'Project-level data affects later segment calculation and report output, so fill it first.'
|
||||
},
|
||||
step2: {
|
||||
title: 'Project-Level Setup',
|
||||
description: 'Project-level setup mainly includes Basic Info, Scale Info, Consult Category Factor, and Major Factor.',
|
||||
point1: 'Basic Info: maintain project name, industry, and other base data.',
|
||||
point2: 'Scale Info: fill project scale by major as input for later budget values.',
|
||||
point3: 'The two factor pages maintain budget values and notes used to adjust calculation.'
|
||||
},
|
||||
step3: {
|
||||
title: 'Fill Basic Info First',
|
||||
description: 'Complete the Basic Info page first. Project name and industry are core inputs for later project calculation.',
|
||||
point1: 'Project name is used in the home list, tab labels, and exported reports.',
|
||||
point2: 'Project industry determines the scale structure, major tree, and part of the budget logic.',
|
||||
point3: 'If project name is left empty, the system falls back to the default project name.'
|
||||
},
|
||||
step4: {
|
||||
title: 'Maintain Scale Info',
|
||||
description: 'The Scale Info page stores project-level scale data, which is one of the base inputs of project calculation.',
|
||||
point1: 'Fill scale values by major. These numbers participate in later service budget values and summary.',
|
||||
point2: 'The grid supports direct edit, batch paste, and undo/redo for fast multi-row input.',
|
||||
point3: 'Grouped rows and the pinned summary row are calculated automatically for quick checking.'
|
||||
},
|
||||
step5: {
|
||||
title: 'Maintain Project Factors',
|
||||
description: 'Consult Category Factor and Major Factor are used to adjust project budget values and should be reviewed before segment calculation.',
|
||||
point1: 'Consult Category Factor page: maintain budget values and notes by consult category.',
|
||||
point2: 'Major Factor page: maintain budget values and notes by major tree.',
|
||||
point3: 'Both pages support batch paste and undo/redo for efficient maintenance.'
|
||||
},
|
||||
step6: {
|
||||
title: 'Enter Segment Calculation',
|
||||
description: 'After project-level setup is complete, go to Contract Segment Management and calculate each segment one by one.',
|
||||
point1: 'Create a contract segment first, then open its detail page to continue.',
|
||||
point2: 'Scale data under one segment belongs only to that segment and does not affect others.',
|
||||
point3: 'The consulting service page generates service rows and acts as the entry for pricing methods.'
|
||||
},
|
||||
step7: {
|
||||
title: 'Choose Pricing Methods',
|
||||
description: 'From consulting service details inside a segment, open the service pricing page and fill method data based on the service.',
|
||||
point1: 'Common methods include investment scale, land scale, workload, and hourly pricing.',
|
||||
point2: 'Different services can enable different methods, and the system summarizes them into subtotal and final amount.',
|
||||
point3: 'If the default calculated value needs adjustment, edit the final amount or note fields directly.'
|
||||
},
|
||||
step8: {
|
||||
title: 'Review and Export',
|
||||
description: 'After project-level and segment-level calculation is complete, review the final summary and then export the report.',
|
||||
point1: 'Check project name, scale info, factors, and segment service amounts before exporting.',
|
||||
point2: 'Export uses the current project data and generates the final report accordingly.',
|
||||
point3: 'If you make large changes, export a backup first before continuing.'
|
||||
}
|
||||
}
|
||||
},
|
||||
toast: {
|
||||
export: 'Export Report',
|
||||
success: 'Export Success',
|
||||
failed: 'Export Failed'
|
||||
},
|
||||
messages: {
|
||||
defaultProjectLabel: 'Default Project',
|
||||
defaultProjectName: 'Cost Project',
|
||||
projectNamePrefix: 'Project-{id}',
|
||||
contractFallbackName: 'Contract-{index}',
|
||||
reportFileSuffix: 'Budget Report',
|
||||
reportGenerating: 'Generating report file...',
|
||||
reportExportDone: 'Report export completed',
|
||||
reportExportFailedRetry: 'Report export failed, please retry',
|
||||
importFailedTitle: 'Import Failed',
|
||||
importProjectIdMissing: 'This package does not contain project ID (legacy export). Import is blocked to avoid cross-project overwrite.',
|
||||
importProjectMismatch: 'This package belongs to another project and cannot override current project.',
|
||||
importInvalidFile: 'File is invalid, corrupted, or modified.',
|
||||
importCryptoUnavailable: 'This runtime does not support secure archive decryption. Please open the site over HTTPS or another secure local context and try again.',
|
||||
importWriteError: 'An error occurred while writing local data.',
|
||||
openFile: 'Open file'
|
||||
}
|
||||
},
|
||||
typeLine: {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Copy failed',
|
||||
brandAlt: 'Zhongwei',
|
||||
supportText: 'This website is supported by Zhongwei Engineering Consulting Co., Ltd.',
|
||||
aboutTitle: 'About Us',
|
||||
companyName: 'Zhongwei Engineering Consulting Co., Ltd.',
|
||||
openOfficialSiteAria: 'Open official website',
|
||||
officialSiteTitle: 'Official Website',
|
||||
aboutParagraph1: 'Zhongwei Engineering Consulting Co., Ltd. was founded in 2009, focusing on whole-process consulting for project cost and cost control. It is a preferred audit vendor for Guangdong government. The company serves multi-domain and diverse clients, with cumulative project investment over one trillion CNY, deep participation in major national projects such as the Hong Kong-Zhuhai-Macao Bridge and Hengqin Campus of the University of Macau, and participation in over 30 national/provincial/municipal standards.',
|
||||
aboutParagraph2: 'Based in the Greater Bay Area and expanding globally, the company has offices in Macau and Sri Lanka, with cross-border and overseas delivery capabilities. With 15 years of expertise and trillion-level project experience, it provides precise and reliable engineering consulting services.'
|
||||
},
|
||||
agGrid: {
|
||||
resetDefault: 'Reset to default'
|
||||
},
|
||||
ht: {
|
||||
title: 'Contract Segments',
|
||||
projectTotalBudget: 'Project Total Budget: {amount}',
|
||||
budgetLoading: 'Calculating...',
|
||||
selectedCount: '{count} selected',
|
||||
exportSelected: 'Export Selected',
|
||||
deleteSelected: 'Delete Selected',
|
||||
cancelSelect: 'Cancel',
|
||||
addContract: 'Add Segment',
|
||||
batchDelete: 'Batch Delete',
|
||||
exportContracts: 'Export Segments',
|
||||
importContracts: 'Import Segments',
|
||||
searchPlaceholder: 'Search by segment name or ID',
|
||||
clearFilter: 'Clear Filter',
|
||||
searchingHint: 'Searching ({filtered} / {total}), drag sorting is disabled',
|
||||
selectModeExportHint: 'Export mode: select segments and click "Export Selected"',
|
||||
selectModeDeleteHint: 'Delete mode: select segments and click "Delete Selected"',
|
||||
setupRequiredHint: 'Set project industry in "Basic Info" before adding or importing segments',
|
||||
listLayout: 'List',
|
||||
gridLayout: 'Grid',
|
||||
dragSort: 'Drag to Sort',
|
||||
dragSortSearchOff: 'Drag Sort (Disabled in Search)',
|
||||
edit: 'Edit',
|
||||
remove: 'Delete',
|
||||
idLabel: 'ID: {id}',
|
||||
contractBudget: 'Budget: {amount}',
|
||||
contractBudgetLine: 'Segment Budget: {amount}',
|
||||
createdAt: 'Created: {time}',
|
||||
emptyTitle: 'No Contract Segments',
|
||||
emptyDesc: 'Add one to get started',
|
||||
notFound: 'No matching contract segment',
|
||||
backToTop: 'Back to Top',
|
||||
editContract: 'Edit Segment',
|
||||
createContract: 'New Segment',
|
||||
contractTabTitle: 'Segment {name}',
|
||||
contractName: 'Segment Name',
|
||||
contractNamePlaceholder: 'Enter segment name',
|
||||
save: 'Save',
|
||||
ok: 'OK',
|
||||
toastSuccessTitle: 'Success',
|
||||
createSuccess: 'Created successfully',
|
||||
editSuccess: 'Updated successfully',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
sortDone: 'Sort completed',
|
||||
exportSuccess: 'Exported successfully ({count} segments)',
|
||||
importSuccess: 'Imported successfully ({count} segments)',
|
||||
deleteBatchSuccess: 'Deleted successfully ({count} segments)',
|
||||
tipTitle: 'Notice',
|
||||
exportFailedTitle: 'Export Failed',
|
||||
importFailedTitle: 'Import Failed',
|
||||
batchDeleteFailedTitle: 'Batch Delete Failed',
|
||||
retry: 'Please try again.',
|
||||
selectAtLeastOne: 'Please select at least one contract segment.',
|
||||
noContractsToDelete: 'No contract segment found to delete.',
|
||||
industryMissingForExport: 'Project industry is missing. Please set it in "Basic Info" first.',
|
||||
importIndustryMismatch: 'Industry mismatch (package: {importIndustry}, current: {currentIndustry}).',
|
||||
importCurrentIndustryMissing: 'Current project industry is not set. Please set it in "Basic Info" first.',
|
||||
importPackageIndustryMissing: 'Import package missing industry info. Re-export with latest version and try again.',
|
||||
importFileInvalid: 'Invalid or corrupted file, or not a contract-segment package.',
|
||||
importCryptoUnavailable: 'This runtime does not support secure archive decryption. Please open the site over HTTPS or another secure local context and try again.',
|
||||
deleteSingleTitle: 'Confirm Delete Segment',
|
||||
deleteSingleDesc: 'Delete "{name}" and all related service/pricing data. Continue?',
|
||||
deleteBatchTitle: 'Confirm Batch Delete',
|
||||
deleteBatchDesc: 'Delete {count} segments and related service/pricing data. Continue?'
|
||||
},
|
||||
htCard: {
|
||||
title: 'Segment: {name}',
|
||||
subtitle: 'Segment ID: {id}',
|
||||
metaBudget: 'Segment Budget: {amount}',
|
||||
currencySuffix: 'CNY',
|
||||
categories: {
|
||||
baseInfo: 'Basic Info',
|
||||
scaleInfo: 'Scale Info',
|
||||
services: 'Consulting Services',
|
||||
consultFactor: 'Consult Category Factor',
|
||||
majorFactor: 'Major Factor',
|
||||
additionalFee: 'Additional Fee',
|
||||
reserveFee: 'Reserve Fee',
|
||||
summary: 'Summary'
|
||||
}
|
||||
},
|
||||
htBaseInfo: {
|
||||
title: 'Basic Info',
|
||||
defaultQuality: 'The comprehensive evaluation of cost consulting services should reach "Good" or a score of 90.',
|
||||
qualityLabel: 'Quality Requirement',
|
||||
qualityPlaceholder: 'Enter quality requirement',
|
||||
durationLabel: 'Duration Requirement',
|
||||
durationPlaceholder: 'Enter duration requirement'
|
||||
},
|
||||
htFactors: {
|
||||
consultCategoryTitle: 'Consult Category Factor Details',
|
||||
majorTitle: 'Major Factor Details'
|
||||
},
|
||||
htFee: {
|
||||
additionalTitle: 'Additional Work Fee',
|
||||
reserveTitle: 'Reserve Fee'
|
||||
},
|
||||
htInfo: {
|
||||
scaleDetailTitle: 'Contract Scale Details',
|
||||
scaleDetailHint: 'When the scale data in this table differs from the project scale data, pricing will use this table.',
|
||||
scaleDetailHintAria: 'Contract scale details hint'
|
||||
},
|
||||
htFeeRate: {
|
||||
baseLabel: 'Base (total budget of all service fees)',
|
||||
reserveBaseLabel: 'Base (consulting services total + additional work fee total)',
|
||||
rateLabel: 'Rate (%)',
|
||||
ratePlaceholder: 'Enter rate, suggested 1 ~ 5',
|
||||
budgetFeeLabel: 'Budget Fee (Auto)',
|
||||
remarkLabel: 'Remark',
|
||||
remarkPlaceholder: 'Enter remark'
|
||||
},
|
||||
htZxFw: {
|
||||
title: 'Consulting Service Details',
|
||||
warning: 'Please review and adjust recommended limits/special values in the specification, then update final fee if needed.',
|
||||
editTabTitle: 'Service Edit-{name}',
|
||||
subtotal: 'Total',
|
||||
edit: 'Edit',
|
||||
resetDefault: 'Reset',
|
||||
delete: 'Remove',
|
||||
processDraft: 'Draft',
|
||||
processReview: 'Review',
|
||||
columns: {
|
||||
code: 'Code',
|
||||
name: 'Name',
|
||||
process: 'Process',
|
||||
investScale: 'Investment Scale',
|
||||
landScale: 'Land Scale',
|
||||
workload: 'Workload',
|
||||
hourly: 'Hourly',
|
||||
subtotal: 'Subtotal',
|
||||
finalFee: 'Final Fee ✎',
|
||||
finalFeeTooltip: 'This column supports manual edits and will auto-sync to the fixed subtotal row.',
|
||||
remark: 'Remark',
|
||||
actions: 'Actions'
|
||||
},
|
||||
dialog: {
|
||||
resetTitle: 'Confirm Reset to Default',
|
||||
resetDesc: 'This will recalculate default data from latest scale/factor values and overwrite current data for "{name}". Continue?',
|
||||
confirmReset: 'Confirm Reset',
|
||||
deleteTitle: 'Confirm Delete Service',
|
||||
deleteDesc: 'This will logically remove "{name}". Existing entered data is kept and will restore if re-selected. Continue?'
|
||||
}
|
||||
},
|
||||
htSummary: {
|
||||
title: 'Contract Summary',
|
||||
total: 'Total',
|
||||
remark: 'Remark',
|
||||
placeholder: 'Fill consulting services / additional work fee / reserve fee first',
|
||||
additionalPrefix: 'Additional Work Fee',
|
||||
reservePrefix: 'Reserve Fee',
|
||||
explainByRate: 'By rate {rate}%, calculated {fee} CNY',
|
||||
explainByHourly: 'By hourly method, calculated {fee} CNY',
|
||||
explainByQuantity: 'By quantity-unit-price method, calculated {fee} CNY',
|
||||
columns: {
|
||||
code: 'Code',
|
||||
name: 'Name',
|
||||
investScale: 'Investment Scale',
|
||||
landScale: 'Land Scale',
|
||||
workload: 'Workload',
|
||||
hourly: 'Hourly',
|
||||
subtotal: 'Subtotal',
|
||||
finalFee: 'Final Fee'
|
||||
}
|
||||
},
|
||||
htFeeGrid: {
|
||||
subtotal: 'Total',
|
||||
currentRow: 'Current Row',
|
||||
unnamed: 'Unnamed',
|
||||
edit: 'Edit',
|
||||
clear: 'Clear',
|
||||
add: 'Add',
|
||||
editTabTitle: 'Fee Edit-{name}',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
rateFee: 'Rate Fee',
|
||||
hourlyFee: 'Hourly',
|
||||
quantityUnitPriceFee: 'Quantity Unit Price',
|
||||
subtotal: 'Subtotal',
|
||||
actions: 'Actions'
|
||||
},
|
||||
dialog: {
|
||||
clearTitle: 'Confirm Clear',
|
||||
clearDesc: 'This will clear editable and auto-calculated data for "{name}" and its edit page. Continue?',
|
||||
confirmClear: 'Confirm Clear'
|
||||
}
|
||||
},
|
||||
xmFactorGrid: {
|
||||
clickToInput: 'Click to input',
|
||||
columns: {
|
||||
standardFactor: 'Standard Factor',
|
||||
budgetValue: 'Budget Value',
|
||||
remark: 'Remark',
|
||||
groupName: 'Major Code and Major Name'
|
||||
}
|
||||
},
|
||||
serviceSelector: {
|
||||
title: 'Select Services',
|
||||
titleHint: 'Some selectable services have been added beyond those listed in the specification. These additions do not conflict with the specification and are only included to support fee calculation under the specification.',
|
||||
titleHintAria: 'Select services hint',
|
||||
clear: 'Clear',
|
||||
empty: 'No services'
|
||||
},
|
||||
zxFwView: {
|
||||
contractPrefix: 'Contract: {name}',
|
||||
calcSuffix: ' Calculation',
|
||||
contractId: 'Contract ID: {id}',
|
||||
workContentTitle: 'Work Content',
|
||||
categories: {
|
||||
investmentScale: 'Investment Scale',
|
||||
investmentScaleFormula: 'Investment Scale Formula',
|
||||
landScale: 'Land Scale',
|
||||
landScaleFormula: 'Land Scale Formula',
|
||||
workload: 'Workload',
|
||||
hourly: 'Hourly',
|
||||
workContent: 'Work Content'
|
||||
},
|
||||
formulaColumns: {
|
||||
subtitle: 'Shows the latest detail rows from the current pricing-method store and stays in sync with store updates.',
|
||||
amount: 'Amount (CNY)',
|
||||
basicFormula: 'Basic Work Formula',
|
||||
optionalFormula: 'Optional Work Formula'
|
||||
},
|
||||
unavailable: {
|
||||
investmentScaleTitle: 'Investment Scale Not Applicable',
|
||||
investmentScaleMessage: 'Scale method is not enabled for this service, so Investment Scale is not editable.',
|
||||
landScaleTitle: 'Land Scale Not Applicable',
|
||||
landScaleMessage: 'This service only supports Investment Scale, so Land Scale is not editable.',
|
||||
workloadTitle: 'Workload Not Applicable',
|
||||
workloadMessage: 'Workload method is not enabled for this service, so Workload is not editable.',
|
||||
hourlyTitle: 'Hourly Not Applicable',
|
||||
hourlyMessage: 'Hourly method is not enabled for this service, so Hourly is not editable.'
|
||||
}
|
||||
},
|
||||
htFeeDetail: {
|
||||
subtotal: 'Total',
|
||||
currentRow: 'Current Row',
|
||||
clickToInput: 'Click to input',
|
||||
addRow: 'Add Row',
|
||||
columns: {
|
||||
no: 'No.',
|
||||
feeItem: 'Fee Item',
|
||||
unit: 'Unit',
|
||||
quantity: 'Quantity',
|
||||
unitPrice: 'Unit Price (CNY)',
|
||||
budgetFee: 'Budget Fee (CNY)',
|
||||
remark: 'Remark',
|
||||
actions: 'Actions'
|
||||
},
|
||||
dialog: {
|
||||
deleteTitle: 'Confirm Delete Row',
|
||||
deleteDesc: 'Delete row "{name}"?'
|
||||
}
|
||||
},
|
||||
workContent: {
|
||||
title: 'Work Content',
|
||||
addCustom: 'Add Custom Content',
|
||||
clickToInput: 'Click to input',
|
||||
clickToInputContent: 'Click to input work content',
|
||||
currentRow: 'Current Row',
|
||||
unnamed: 'Unnamed',
|
||||
ungrouped: 'Ungrouped',
|
||||
type: {
|
||||
basic: 'Basic Work',
|
||||
optional: 'Optional Work',
|
||||
daily: 'Daily Advisory',
|
||||
special: 'Special Advisory',
|
||||
additional: 'Additional Work',
|
||||
custom: 'Custom'
|
||||
},
|
||||
columns: {
|
||||
no: 'No.',
|
||||
content: 'Content',
|
||||
type: 'Type',
|
||||
remark: 'Remark',
|
||||
actions: 'Actions'
|
||||
},
|
||||
dialog: {
|
||||
deleteTitle: 'Confirm Delete Row',
|
||||
deleteDesc: 'Delete row "{name}"?'
|
||||
}
|
||||
},
|
||||
quickCalc: {
|
||||
projectName: 'Quick Calculation',
|
||||
industryLabel: 'Industry {name}',
|
||||
selectIndustry: 'Select industry',
|
||||
saving: 'Saving...',
|
||||
synced: 'Industry synced',
|
||||
notSelectedIndustry: 'Industry not selected',
|
||||
notSelected: 'Not selected',
|
||||
consultCategory: 'Consult Category',
|
||||
majorCategory: 'Major',
|
||||
types: {
|
||||
consult: {
|
||||
label: 'Consult Category (Common)',
|
||||
hint: 'Select consult category first, then complete scale and budget parameters.'
|
||||
},
|
||||
general: {
|
||||
label: 'General Major',
|
||||
hint: 'Cross-industry common compensation and other expense majors.'
|
||||
},
|
||||
road: {
|
||||
label: 'Highway Major',
|
||||
hint: 'Shown by default when industry is Highway Engineering.'
|
||||
},
|
||||
railway: {
|
||||
label: 'Railway Major',
|
||||
hint: 'Shown by default when industry is Railway Engineering.'
|
||||
},
|
||||
waterway: {
|
||||
label: 'Waterway Major',
|
||||
hint: 'Shown by default when industry is Waterway Engineering.'
|
||||
}
|
||||
},
|
||||
fields: {
|
||||
industry: 'Industry',
|
||||
code: 'Code',
|
||||
investScale: 'Investment Scale (10k CNY)',
|
||||
landScale: 'Land Scale (mu)',
|
||||
formula: 'Formula',
|
||||
amount: 'Amount (CNY)',
|
||||
consultFactor: 'Consult Category Factor',
|
||||
majorFactor: 'Major Factor',
|
||||
workEnvCoefficient: 'Work environment coefficient',
|
||||
workEnvCoefficientPlaceholder: 'Default 1',
|
||||
budgetAmount: 'Budget Amount (CNY)'
|
||||
},
|
||||
sections: {
|
||||
currentSelection: 'Current Selection',
|
||||
basicInfo: 'Basic Info',
|
||||
scaleBase: 'Scale Base',
|
||||
benchmarkBudget: 'Benchmark Budget',
|
||||
serviceBudget: 'Service Budget',
|
||||
},
|
||||
empty: {
|
||||
selectIndustry: 'Select an industry first. Then choose consult category and matched majors will appear.',
|
||||
selectConsult: 'Select a consult category first. Matched general and major categories will then appear.',
|
||||
scaleUnavailable: 'The selected consult category does not support scale method, so major categories are hidden.',
|
||||
consultCostOnly: 'The selected consult category is priced by industry summary. Major factor is auto-applied by industry.'
|
||||
},
|
||||
placeholder: {
|
||||
selectConsultFirst: 'Select consult category first',
|
||||
scaleUnavailable: 'Current category does not support scale method',
|
||||
selectMajorFirst: 'Select major first',
|
||||
preferLandScale: 'Current major is priced by land scale',
|
||||
investUnavailable: 'Current major does not support investment scale',
|
||||
consultCostOnly: 'Current category supports investment scale only',
|
||||
landUnavailable: 'Current major does not support land scale',
|
||||
input: 'Please input',
|
||||
selectScaleFirst: 'Select and input a scale value first'
|
||||
}
|
||||
},
|
||||
methodUnavailable: {
|
||||
defaultTitle: 'This Service Is Not Applicable to Current Pricing Method'
|
||||
},
|
||||
xmCard: {
|
||||
categories: {
|
||||
info: 'Basic Info',
|
||||
scaleInfo: 'Scale Info',
|
||||
consultCategoryFactor: 'Consult Category Factor',
|
||||
majorFactor: 'Major Factor',
|
||||
contract: 'Contract Segment Management'
|
||||
}
|
||||
},
|
||||
htFeeMethodTypeLine: {
|
||||
feeDetail: 'Fee Details',
|
||||
unnamed: 'Unnamed',
|
||||
title: 'Segment: {contractName} · {rowName}',
|
||||
contractId: 'Contract ID: {id}',
|
||||
quantityUnitPrice: 'Quantity Unit Price'
|
||||
},
|
||||
pricingScale: {
|
||||
totalInvestmentByIndustry: '{industryName} Total Investment',
|
||||
totalInvestment: 'Total Investment',
|
||||
clickToInput: 'Click to input',
|
||||
projectLabel: 'Project {index}',
|
||||
columns: {
|
||||
investAmount: 'Cost Amount (10k CNY)',
|
||||
landArea: 'Land Area (mu)',
|
||||
benchmarkBudget: 'Benchmark Budget (CNY)',
|
||||
basicWork: 'Basic Work',
|
||||
optionalWork: 'Optional Work',
|
||||
subtotal: 'Subtotal',
|
||||
budgetFee: 'Budget Fee',
|
||||
consultCategoryFactor: 'Consult Category Factor',
|
||||
majorFactor: 'Major Factor',
|
||||
workStageFactor: 'Work Stage Factor (Draft/Review)',
|
||||
workRatio: 'Service Budget Composition Ratio and Quantity Ratio',
|
||||
total: 'Total',
|
||||
remark: 'Remark',
|
||||
majorGroup: 'Major Code and Major Name'
|
||||
},
|
||||
tooltip: {
|
||||
resetInvestAmount: 'Click ↻ to restore default cost amount for this column',
|
||||
resetLandArea: 'Click ↻ to restore default land area for this column',
|
||||
resetConsultCategoryFactor: 'Click ↻ to restore default consult category factor for this column',
|
||||
resetMajorFactor: 'Click ↻ to restore default major factor for this column',
|
||||
workRatio: 'This coefficient applies in two cases: service budget composition ratio, for cases where only part of the entrusted work in Appendix D Tables D.2 to D.7 of the specification is included; and quantity ratio, for cases where the calculation base uses the scale of each deliverable, batched task, single project, or unit project rather than the total amount, with quantity representing multiple items, copies, or units.'
|
||||
}
|
||||
},
|
||||
pricingPane: {
|
||||
projectCount: 'Project Count',
|
||||
clearTitle: 'Confirm Clear Current Details',
|
||||
confirmClear: 'Confirm Clear',
|
||||
useDefault: 'Use Default Data',
|
||||
overrideTitle: 'Confirm Override Current Details',
|
||||
confirmOverride: 'Confirm Override',
|
||||
investment: {
|
||||
title: 'Investment Scale Details',
|
||||
titleHint: 'Budget amount values in this pane follow whichever was operated later between the contract scale table and this pane.',
|
||||
titleHintAria: 'Investment scale details hint',
|
||||
clearDesc: 'This will clear current investment scale details. Continue?',
|
||||
overrideDesc: 'Use contract default data to override current investment scale details. Continue?'
|
||||
},
|
||||
land: {
|
||||
title: 'Land Scale Details',
|
||||
clearDesc: 'This will clear current land scale details. Continue?',
|
||||
overrideDesc: 'Use contract default data to override current land scale details. Continue?'
|
||||
}
|
||||
},
|
||||
workloadPricing: {
|
||||
title: 'Workload Details',
|
||||
unavailableTitle: 'Workload Method Not Applicable',
|
||||
unavailableMessage: 'No workload tasks are associated with this service. No input is needed.',
|
||||
clickToInput: 'Click to input',
|
||||
none: 'N/A',
|
||||
total: 'Grand Total',
|
||||
columns: {
|
||||
code: 'Code',
|
||||
name: 'Name',
|
||||
budgetBase: 'Budget Base',
|
||||
budgetReferenceUnitPrice: 'Budget Reference Unit Price',
|
||||
budgetAdoptedUnitPrice: 'Budget Adopted Unit Price',
|
||||
workload: 'Workload',
|
||||
consultCategoryFactor: 'Consult Category Factor',
|
||||
serviceFee: 'Service Fee (CNY)',
|
||||
remark: 'Remark'
|
||||
}
|
||||
},
|
||||
hourlyFeeGrid: {
|
||||
title: 'Hourly Method Details',
|
||||
clickToInput: 'Click to input',
|
||||
total: 'Grand Total',
|
||||
columns: {
|
||||
code: 'Code',
|
||||
name: 'Personnel Name',
|
||||
referenceUnitPrice: 'Budget Reference Unit Price',
|
||||
laborBudgetUnitPrice: 'Labor Budget Unit Price (CNY/workday)',
|
||||
compositeBudgetUnitPrice: 'Composite Budget Unit Price (CNY/workday)',
|
||||
adoptedBudgetUnitPrice: 'Adopted Budget Unit Price (CNY/workday)',
|
||||
personnelCount: 'Personnel Count',
|
||||
workdayCount: 'Workday Count',
|
||||
serviceBudget: 'Service Budget (CNY)',
|
||||
remark: 'Remark'
|
||||
}
|
||||
},
|
||||
xmScaleGrid: {
|
||||
syncToastTitle: 'Consulting Services Synced',
|
||||
syncToastDesc: 'Scale info synced to consulting services ({serviceCount} services, {methodCount} pricing pages, {rowCount} rows)'
|
||||
},
|
||||
xmInfo: {
|
||||
defaultProjectName: 'xxx Cost Consulting Service',
|
||||
defaultDesc: 'When providing cost consulting services, penalties should be graded by service quality. For scores >=85 and <90, penalty is 10% of budget fee; >=80 and <85: 20%; >=75 and <80: 30%; >=70 and <75: 40%; <70: 50% or above.',
|
||||
industryHint: 'Changing industry requires reset and re-selection',
|
||||
industryHintAria: 'Industry hint',
|
||||
reportContentHint: 'This field is optional and is only used to auto-generate report content.',
|
||||
reportContentHintAria: 'Report content hint',
|
||||
otherDescHint: 'This field is optional. The current content is only a sample. The preparer may fill it in according to actual needs or leave it blank.',
|
||||
otherDescHintAria: 'Other notes hint',
|
||||
createFromHomeFirst: 'Please create a project from Home before entering this page.',
|
||||
fields: {
|
||||
projectName: 'Project Name',
|
||||
projectIndustry: 'Industry',
|
||||
overview: 'Project Overview',
|
||||
preparedBy: 'Prepared By',
|
||||
reviewedBy: 'Reviewed By',
|
||||
preparedCompany: 'Prepared Company',
|
||||
preparedDate: 'Prepared Date',
|
||||
desc: 'Other Notes'
|
||||
},
|
||||
placeholders: {
|
||||
overview: 'Enter project overview',
|
||||
desc: 'Other Notes',
|
||||
preparedBy: 'XXX',
|
||||
reviewedBy: 'XXX',
|
||||
preparedCompany: 'XXX'
|
||||
}
|
||||
}
|
||||
} as const
|
||||
@@ -0,0 +1,752 @@
|
||||
export const zhCN = {
|
||||
common: {
|
||||
cancel: '取消',
|
||||
confirm: '确认',
|
||||
delete: '删除',
|
||||
close: '关闭',
|
||||
clear: '清空',
|
||||
loading: '加载中...'
|
||||
},
|
||||
app: {
|
||||
projectConflict: {
|
||||
title: '检测到项目重复打开',
|
||||
desc: '项目「{name}」已在其他页面处于活跃状态。为避免 IndexedDB 数据冲突,本页面已阻断编辑。',
|
||||
countdown: '本页将在 {seconds} 秒后自动尝试关闭。你也可以先在新标签页打开其他项目。',
|
||||
opened: '(已打开)',
|
||||
lastEdited: '最后编辑:{time}',
|
||||
openDefault: '返回首页',
|
||||
createAndOpen: '新建项目并打开'
|
||||
}
|
||||
},
|
||||
home: {
|
||||
title: '粤公学标字〔2026〕5号\n交通运输工程造价咨询服务预算编制规范',
|
||||
subtitle: '项目计算 · 单项速算 · 导入数据 · 相关文件',
|
||||
projectCalcTab: '项目计算',
|
||||
quickCalcTab: '快速计算',
|
||||
cards: {
|
||||
heroTitle: '众会易|算费真容易',
|
||||
heroTitles: [
|
||||
'众会易 | 算费真容易',
|
||||
'众会易 | 算费不熬夜',
|
||||
'众会易 | 算费不费力'
|
||||
],
|
||||
heroSubTitle: '',
|
||||
heroDesc: '智算费用 即点即出 您的时间留给创造',
|
||||
heroDescs: [
|
||||
'智算费用 即点即出 您的时间留给创造。',
|
||||
'智算费用 一键即出 您的时间留给创造。',
|
||||
'智算费用 一键生成 您的时间留给创造。',
|
||||
'众会易 真容易 不熬夜 不费力'
|
||||
],
|
||||
projectBudget: '项目预算',
|
||||
projectBudgetDesc: '适用于多合同段、项目级整体计算,支持导出/导入完整项目数据',
|
||||
quickCalc: '单项速算',
|
||||
quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果',
|
||||
importData: '导入数据',
|
||||
importDataDesc: '导入".zw"数据包,并自动新建一个项目用于恢复数据,不会覆盖现有项目',
|
||||
relatedFiles: '相关文件',
|
||||
relatedFilesDesc: '在线查看、打印和下载与该计算器依据的收费文件、相关招标文件与合同文件范本',
|
||||
viewFiles: '查看文件',
|
||||
enter: '进入计算',
|
||||
pickFile: '选择文件',
|
||||
pickExisting: '选择已有项目',
|
||||
openFileSystem: '打开文件系统',
|
||||
file: '文件系统',
|
||||
fileDataDesc: '文件系统可查看相关收费文件、招标文件、合同文件、服务内容、工作要求'
|
||||
},
|
||||
disclaimer: {
|
||||
link: '查看免责声明',
|
||||
supportText: '本计算工具由众为工程咨询有限公司提供免费技术支持'
|
||||
},
|
||||
dialog: {
|
||||
newProject: '新建项目',
|
||||
chooseIndustryDesc: '选择工程行业后,直接进入项目计算页面。',
|
||||
industry: '工程行业',
|
||||
selectIndustry: '请选择工程行业',
|
||||
entering: '进入中...',
|
||||
enterProjectCalc: '进入项目计算',
|
||||
confirmImport: '确认导入数据',
|
||||
confirmImportDesc: '将导入“{file}”数据包',
|
||||
confirmImportAction: '确认导入并新建项目',
|
||||
chooseExistingProject: '选择已有项目',
|
||||
chooseExistingProjectDesc: '从项目列表中选择一个项目并直接进入工作台。',
|
||||
noProjectYet: '当前暂无可进入的项目,请先新建项目。'
|
||||
}
|
||||
},
|
||||
disclaimerPage: {
|
||||
documentTitle: '预算编制工具免责声明',
|
||||
eyebrow: 'DISCLAIMER',
|
||||
pageTitle: '《交通运输工程造价咨询服务预算编制规范》(T/GDHS 017-2026)预算编制工具免责声明',
|
||||
lastUpdatedLabel: '最后更新日期:',
|
||||
lastUpdatedValue: '2026年04月16日',
|
||||
leadText: '感谢您使用本网站提供的《交通运输工程造价咨询服务预算编制规范》(T/GDHS 017-2026)造价咨询服务预算编制工具。在您使用本工具前,请仔细阅读以下免责声明条款。您继续使用本工具,即视为您已阅读、理解并同意接受本声明的全部内容。',
|
||||
sections: {
|
||||
standardBasisTitle: '1. 标准依据说明',
|
||||
standardBasisP1: '1.1 本工具依据广东省公路学会发布的团体标准《交通运输工程造价咨询服务预算编制规范》(T/GDHS 017-2026)设定编制方法。使用者应自行判断该标准是否适用于其具体项目及所在地主管部门要求。',
|
||||
standardBasisP2: '1.2 本工具所依据的规范版本已在工具界面标注为 T/GDHS 017-2026。如该规范后续发布修订内容、补充规定或被新版本替代,本工具可能无法及时同步更新。使用者有责任在使用前确认所依据规范是否仍为最新有效版本。',
|
||||
standardBasisP3: '1.3 本工具的计算结果基于本规范中的预算编制方法、费用组成及编制规则,但不同地区、不同项目法人对造价咨询服务预算编制的具体要求和计算方法可能存在差异。本工具不保证其计算结果符合任何特定项目或特定主管部门的审核要求。',
|
||||
referenceOnlyTitle: '2. 计算结果仅供参考',
|
||||
referenceOnlyP1: '本工具所提供的所有计算结果,包括但不限于数值、明细表、汇总报表及编制说明,均基于您输入的参数(如工程行业、项目规模、咨询类别、工程专业、工作内容、调整系数等)以及本规范中的数学模型与公式自动生成,仅供参考使用。这些结果不构成任何形式的专业建议,也不代表任何官方或强制性的预算审批依据。',
|
||||
accuracyTitle: '3. 不保证准确性与完整性',
|
||||
accuracyP1: '尽管我们尽力确保本工具可用,但本工具按现状提供,不附带任何明示或暗示的保证。我们无法保证计算结果在任何情况下均准确、无误或完整。由于数据输入错误、公式取舍、四舍五入或系统延迟等原因,结果可能与实际情况存在偏差。',
|
||||
riskTitle: '4. 用户自行承担风险',
|
||||
riskP1: '您应独立判断计算结果的可信性,并承担将其用于任何决策所产生的全部风险与责任。您不应依赖本工具替代专业人士的具体计算或复核。在作出重大决定前,建议咨询持有交通运输工程造价工程师注册证书的专业人员,或结合项目具体情况进行人工验证与复核。',
|
||||
liabilityTitle: '5. 责任限制',
|
||||
liabilityP1: '在适用法律允许的最大范围内,本工具的开发方、管理方、发布方及其关联方,不对因使用或无法使用本工具而导致的任何直接、间接、偶然、特殊或后果性损失承担法律责任,即使已被告知可能发生此类损失。',
|
||||
liabilityP2: '特别声明:任何造价咨询企业或个人依据本工具计算结果出具的成果文件,其质量责任由出具方自行承担。本工具不对任何第三方成果文件的准确性、合规性或由此引发的争议承担责任。',
|
||||
interruptionTitle: '6. 服务中断与修改',
|
||||
interruptionP1: '我们保留随时修改、暂停或终止本工具部分或全部功能的权利,且可能不另行通知。对于因技术维护、网络故障、第三方服务中断、规范版本变更等原因导致的工具不可用、数据丢失或计算结果变化,我们不承担责任。',
|
||||
externalTitle: '7. 外部链接与第三方内容',
|
||||
externalP1: '如果本工具引用或链接至全国团体标准信息平台、广东省公路学会官网或其他第三方网站,该等链接仅为方便用户查阅规范原文而提供,不代表我们认可其内容的准确性、时效性或完整性。对于任何第三方网站或工具的信息、服务或内容,我们不承担责任。',
|
||||
lawTitle: '8. 适用法律',
|
||||
lawP1: '本声明的解释、效力及争议解决均适用中华人民共和国法律。若本声明任何条款被认定为无效或不可执行,不影响其余条款的效力。'
|
||||
},
|
||||
confirm: {
|
||||
title: '用户确认',
|
||||
desc1: '我已阅读、理解并同意本免责声明的全部内容。',
|
||||
desc2: '勾选后方可继续使用本工具。',
|
||||
checkbox: '我已阅读、理解并同意本免责声明的全部内容。',
|
||||
continue: '同意并继续',
|
||||
hint: '勾选后将记录当前浏览器的同意状态,后续从同一受限入口访问时不再重复提示。'
|
||||
},
|
||||
actions: {
|
||||
back: '返回入口',
|
||||
switchLocale: '切换语言'
|
||||
}
|
||||
},
|
||||
tab: {
|
||||
toolbar: {
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
language: '语言',
|
||||
importExport: '导入/导出',
|
||||
importData: '导入数据',
|
||||
exportData: '导出数据',
|
||||
exportReport: '导出报表',
|
||||
userGuide: '使用引导',
|
||||
reset: '重置',
|
||||
resetting: '重置中...',
|
||||
projectList: '项目列表',
|
||||
projectCount: '项目数量:{count}',
|
||||
createProject: '新建项目',
|
||||
backHome: '返回入口',
|
||||
resetAll: '清除全部项目',
|
||||
opened: '(已打开)',
|
||||
lastEdited: '最后编辑:{time}'
|
||||
},
|
||||
menu: {
|
||||
closeAll: '删除所有',
|
||||
closeLeft: '删除左侧',
|
||||
closeRight: '删除右侧',
|
||||
closeOther: '删除其他'
|
||||
},
|
||||
dialog: {
|
||||
resetTitle: '确认重置',
|
||||
resetDesc: '将清空全部项目数据,并恢复默认页面,确认继续吗?',
|
||||
confirmReset: '确认重置',
|
||||
importOverrideTitle: '确认导入覆盖',
|
||||
importOverrideDesc: '将使用“{file}”覆盖当前本地全部数据,是否继续?',
|
||||
confirmOverride: '确认覆盖',
|
||||
newProjectTitle: '新建项目',
|
||||
newProjectDesc: '选择工程行业后,将在新标签页直接打开新项目计算页面。',
|
||||
createAndOpen: '新建并打开',
|
||||
creating: '创建中...',
|
||||
projectLimitTitle: '项目数量已达上限',
|
||||
projectLimitDesc: '当前项目数量已达到 {max} 个,请先删除一个项目后再添加。',
|
||||
iKnow: '我知道了',
|
||||
deleteProjectTitle: '确认删除项目',
|
||||
deleteCurrentProjectDesc: '确认删除当前项目「{name}」吗?将先清空该项目全部本地数据并返回首页。',
|
||||
deleteProjectDesc: '确认删除项目「{name}」吗?这会移除该项目本地数据。'
|
||||
},
|
||||
guide: {
|
||||
title: '新用户引导',
|
||||
later: '稍后再看',
|
||||
prev: '上一步',
|
||||
next: '下一步',
|
||||
finish: '完成并不再自动弹出',
|
||||
jumpToStep: '跳转到第 {index} 步',
|
||||
steps: {
|
||||
step1: {
|
||||
title: '项目计算总览',
|
||||
description: '这个引导只说明项目计算主链路,按“项目级设置 -> 合同段 -> 服务计费 -> 导出报表”的顺序理解即可。',
|
||||
point1: '项目计算入口是“项目卡片”,左侧流程线会带你按顺序完成配置。',
|
||||
point2: '页面里的表单和表格会自动保存,本地修改通常无需手动点击保存。',
|
||||
point3: '项目级数据会影响后续合同段和报表结果,建议先补齐项目级信息。'
|
||||
},
|
||||
step2: {
|
||||
title: '项目级配置入口',
|
||||
description: '项目级配置主要包括基础信息、规模信息、咨询分类系数、工程专业系数四部分。',
|
||||
point1: '基础信息:维护项目名称、工程行业等项目基础资料。',
|
||||
point2: '规模信息:按专业填写项目规模,为后续预算取值提供依据。',
|
||||
point3: '两个系数页:维护预算取值和说明,作为项目计算的调节项。'
|
||||
},
|
||||
step3: {
|
||||
title: '先填基础信息',
|
||||
description: '先完成基础信息页,尤其是项目名称和工程行业,后续所有项目计算都会依赖这里的数据。',
|
||||
point1: '项目名称会用于主页列表、标签页显示和报表导出。',
|
||||
point2: '工程行业决定规模信息结构、专业树和部分预算取值逻辑。',
|
||||
point3: '如果项目名称留空,系统会自动回填默认项目名称。'
|
||||
},
|
||||
step4: {
|
||||
title: '维护规模信息',
|
||||
description: '规模信息页用于录入项目级规模数据,这是项目计算的基础输入之一。',
|
||||
point1: '按专业填写对应规模值,数值会参与后续服务预算取值和汇总。',
|
||||
point2: '表格支持直接编辑、批量粘贴、撤销重做,适合一次性录入多行数据。',
|
||||
point3: '分组行和固定汇总行会自动计算,便于快速检查录入结果。'
|
||||
},
|
||||
step5: {
|
||||
title: '维护项目系数',
|
||||
description: '咨询分类系数和工程专业系数用于调整项目预算取值,建议在进入合同段前先检查完整。',
|
||||
point1: '咨询分类系数页:按咨询分类维护预算取值与说明。',
|
||||
point2: '工程专业系数页:按专业树维护预算取值与说明。',
|
||||
point3: '两个系数页都支持批量粘贴和撤销重做,适合集中维护。'
|
||||
},
|
||||
step6: {
|
||||
title: '进入合同段计算',
|
||||
description: '项目级配置完成后,再进入合同段管理,逐个维护合同段的规模和服务费用。',
|
||||
point1: '先新增合同段,再进入合同段详情页继续计算。',
|
||||
point2: '合同段下的规模信息用于该合同段自己的计费数据,不会和其他合同段串数据。',
|
||||
point3: '咨询服务页负责生成服务明细,并作为各计费方法页面的入口。'
|
||||
},
|
||||
step7: {
|
||||
title: '选择计费方法',
|
||||
description: '在合同段的咨询服务明细中,可进入具体服务计算页,按服务适用情况填写计费方法数据。',
|
||||
point1: '常见方法包括投资规模法、用地规模法、工作量法和工时法。',
|
||||
point2: '不同服务可启用不同方法,系统会按填写结果汇总到服务小计和确认金额。',
|
||||
point3: '如果默认计算值需要调整,可直接修改确认金额或说明字段。'
|
||||
},
|
||||
step8: {
|
||||
title: '汇总与导出',
|
||||
description: '完成项目级和合同段级计算后,最后再检查汇总结果并导出报表。',
|
||||
point1: '先检查项目名称、规模信息、系数和各合同段服务金额是否完整。',
|
||||
point2: '确认无误后再执行报表导出,导出结果会按当前项目数据生成。',
|
||||
point3: '如果做了大范围调整,建议先导出备份,再继续修改。'
|
||||
}
|
||||
}
|
||||
},
|
||||
toast: {
|
||||
export: '导出报表',
|
||||
success: '导出成功',
|
||||
failed: '导出失败'
|
||||
},
|
||||
messages: {
|
||||
defaultProjectLabel: '默认项目',
|
||||
defaultProjectName: '造价项目',
|
||||
projectNamePrefix: '项目-{id}',
|
||||
contractFallbackName: '合同段-{index}',
|
||||
reportFileSuffix: '预算文件',
|
||||
reportGenerating: '正在生成报表文件...',
|
||||
reportExportDone: '报表导出完成',
|
||||
reportExportFailedRetry: '报表导出失败,请重试',
|
||||
importFailedTitle: '导入失败',
|
||||
importProjectIdMissing: '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。',
|
||||
importProjectMismatch: '该数据包属于其他项目,不能覆盖当前项目。',
|
||||
importInvalidFile: '文件无效、已损坏或被修改。',
|
||||
importCryptoUnavailable: '当前运行环境不支持安全解密导入,请使用 HTTPS 域名或本地安全环境访问后重试。',
|
||||
importWriteError: '写入本地数据时发生错误。',
|
||||
openFile: '打开文件'
|
||||
}
|
||||
},
|
||||
typeLine: {
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
brandAlt: '众为咨询',
|
||||
supportText: '本计算工具由众为工程咨询有限公司提供免费技术支持',
|
||||
aboutTitle: '关于我们',
|
||||
companyName: '众为工程咨询有限公司',
|
||||
openOfficialSiteAria: '跳转到官网首页',
|
||||
officialSiteTitle: '官网首页',
|
||||
aboutParagraph1: '众为工程咨询有限公司 2009 年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。',
|
||||
aboutParagraph2: '公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。'
|
||||
},
|
||||
agGrid: {
|
||||
resetDefault: '恢复默认值'
|
||||
},
|
||||
ht: {
|
||||
title: '合同段列表',
|
||||
projectTotalBudget: '项目总预算金额:{amount}',
|
||||
budgetLoading: '计算中...',
|
||||
selectedCount: '已选 {count} 个',
|
||||
exportSelected: '导出已选',
|
||||
deleteSelected: '删除已选',
|
||||
cancelSelect: '取消',
|
||||
addContract: '添加合同段',
|
||||
batchDelete: '批量删除',
|
||||
exportContracts: '导出合同段',
|
||||
importContracts: '导入合同段',
|
||||
searchPlaceholder: '搜索合同段名称或ID',
|
||||
clearFilter: '清空筛选',
|
||||
searchingHint: '搜索中({filtered} / {total}),已关闭拖拽排序',
|
||||
selectModeExportHint: '导出选择模式:勾选合同段后点击“导出已选”',
|
||||
selectModeDeleteHint: '删除选择模式:勾选合同段后点击“删除已选”',
|
||||
setupRequiredHint: '请先在“基础信息”里新建项目并选择工程行业后,再新增或导入合同段',
|
||||
listLayout: '列表布局',
|
||||
gridLayout: '网格布局',
|
||||
dragSort: '拖动排序',
|
||||
dragSortSearchOff: '拖动排序(搜索时关闭)',
|
||||
edit: '编辑',
|
||||
remove: '删除',
|
||||
idLabel: 'ID:{id}',
|
||||
contractBudget: '预算:{amount}',
|
||||
contractBudgetLine: '本合同预算金额:{amount}',
|
||||
createdAt: '创建时间:{time}',
|
||||
emptyTitle: '暂无合同卡片',
|
||||
emptyDesc: '赶紧来添加吧',
|
||||
notFound: '未找到匹配的合同段',
|
||||
backToTop: '回到顶部',
|
||||
editContract: '编辑合同段',
|
||||
createContract: '新增合同段',
|
||||
contractTabTitle: '合同段{name}',
|
||||
contractName: '合同段名称',
|
||||
contractNamePlaceholder: '请输入合同段名称',
|
||||
save: '保存',
|
||||
ok: '确定',
|
||||
toastSuccessTitle: '操作成功',
|
||||
createSuccess: '新建成功',
|
||||
editSuccess: '编辑成功',
|
||||
deleteSuccess: '删除成功',
|
||||
sortDone: '排序完成',
|
||||
exportSuccess: '导出成功({count} 个合同段)',
|
||||
importSuccess: '导入成功({count} 个合同段)',
|
||||
deleteBatchSuccess: '删除成功({count} 个合同段)',
|
||||
tipTitle: '提示',
|
||||
exportFailedTitle: '导出失败',
|
||||
importFailedTitle: '导入失败',
|
||||
batchDeleteFailedTitle: '批量删除失败',
|
||||
retry: '请重试。',
|
||||
selectAtLeastOne: '请先勾选至少一个合同段。',
|
||||
noContractsToDelete: '未找到可删除的合同段。',
|
||||
industryMissingForExport: '未读取到当前项目工程行业,请先在“基础信息”里新建项目。',
|
||||
importIndustryMismatch: '工程行业不一致(导入包:{importIndustry},当前项目:{currentIndustry})。',
|
||||
importCurrentIndustryMissing: '当前项目未设置工程行业,请先在“基础信息”里新建项目。',
|
||||
importPackageIndustryMissing: '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。',
|
||||
importFileInvalid: '文件无效、已损坏或不是合同段导出文件。',
|
||||
importCryptoUnavailable: '当前运行环境不支持安全解密导入,请使用 HTTPS 域名或本地安全环境访问后重试。',
|
||||
deleteSingleTitle: '确认删除合同段',
|
||||
deleteSingleDesc: '即将删除“{name}”及其关联咨询服务和计价数据,是否继续?',
|
||||
deleteBatchTitle: '确认批量删除',
|
||||
deleteBatchDesc: '即将删除 {count} 个合同段及其关联咨询服务和计价数据,是否继续?'
|
||||
},
|
||||
htCard: {
|
||||
title: '合同段:{name}',
|
||||
subtitle: '合同段ID:{id}',
|
||||
metaBudget: '合同段预算金额:{amount}',
|
||||
currencySuffix: '元',
|
||||
categories: {
|
||||
baseInfo: '基础信息',
|
||||
scaleInfo: '规模信息',
|
||||
services: '咨询服务',
|
||||
consultFactor: '咨询分类系数',
|
||||
majorFactor: '工程专业系数',
|
||||
additionalFee: '附加工作费',
|
||||
reserveFee: '预备费',
|
||||
summary: '汇总'
|
||||
}
|
||||
},
|
||||
htBaseInfo: {
|
||||
title: '基础信息',
|
||||
defaultQuality: '造价咨询服务的综合评价应达到"较好"或综合评分90分',
|
||||
qualityLabel: '质量要求',
|
||||
qualityPlaceholder: '请输入质量要求',
|
||||
durationLabel: '工期要求',
|
||||
durationPlaceholder: '请输入工期要求'
|
||||
},
|
||||
htFactors: {
|
||||
consultCategoryTitle: '咨询分类系数明细',
|
||||
majorTitle: '工程专业系数明细'
|
||||
},
|
||||
htFee: {
|
||||
additionalTitle: '附加工作费',
|
||||
reserveTitle: '预备费'
|
||||
},
|
||||
htInfo: {
|
||||
scaleDetailTitle: '合同规模明细',
|
||||
scaleDetailHint: '当本表规模与项目规模数据不一致时,计费以本表规模为准',
|
||||
scaleDetailHintAria: '合同规模明细提示'
|
||||
},
|
||||
htFeeRate: {
|
||||
baseLabel: '基数(所有服务费预算合计)',
|
||||
reserveBaseLabel: '基数(咨询服务总计 + 附加工作费总计)',
|
||||
rateLabel: '费率(%)',
|
||||
ratePlaceholder: '请输入费率,建议1 ~ 5',
|
||||
budgetFeeLabel: '预算费用(自动计算)',
|
||||
remarkLabel: '说明',
|
||||
remarkPlaceholder: '请输入说明'
|
||||
},
|
||||
htZxFw: {
|
||||
title: '咨询服务明细',
|
||||
warning: '※ 请注意检查并修改《规范》建议的限值或特殊值,并在确认金额栏修改',
|
||||
editTabTitle: '服务编辑-{name}',
|
||||
subtotal: '总计',
|
||||
edit: '编辑',
|
||||
resetDefault: '恢复默认',
|
||||
delete: '删除',
|
||||
processDraft: '编制',
|
||||
processReview: '审核',
|
||||
columns: {
|
||||
code: '编码',
|
||||
name: '名称',
|
||||
process: '工作环节',
|
||||
investScale: '投资规模法',
|
||||
landScale: '用地规模法',
|
||||
workload: '工作量法',
|
||||
hourly: '工时法',
|
||||
subtotal: '小计',
|
||||
finalFee: '确认金额 ✎',
|
||||
finalFeeTooltip: '该列支持手动修改,修改后会自动汇总到固定小计行',
|
||||
remark: '备注',
|
||||
actions: '操作'
|
||||
},
|
||||
dialog: {
|
||||
resetTitle: '确认恢复默认数据',
|
||||
resetDesc: '会使用合同卡片里面最新填写的规模信息以及系数,自动计算默认数据,覆盖“{name}”当前数据,是否继续?',
|
||||
confirmReset: '确认恢复',
|
||||
deleteTitle: '确认删除服务',
|
||||
deleteDesc: '将逻辑删除“{name}”,已填写的数据不会清空,重新勾选后会恢复,是否继续?'
|
||||
}
|
||||
},
|
||||
htSummary: {
|
||||
title: '合同段汇总',
|
||||
total: '总计',
|
||||
remark: '说明',
|
||||
placeholder: '请先填咨询服务/附加工作费/预备费的数据',
|
||||
additionalPrefix: '附加工作费',
|
||||
reservePrefix: '预备费',
|
||||
explainByRate: '按费率{rate}%计得{fee}元',
|
||||
explainByHourly: '按工时法计得{fee}元',
|
||||
explainByQuantity: '按数量单价计得{fee}元',
|
||||
columns: {
|
||||
code: '编码',
|
||||
name: '名称',
|
||||
investScale: '投资规模法',
|
||||
landScale: '用地规模法',
|
||||
workload: '工作量法',
|
||||
hourly: '工时法',
|
||||
subtotal: '小计',
|
||||
finalFee: '确认金额'
|
||||
}
|
||||
},
|
||||
htFeeGrid: {
|
||||
subtotal: '总计',
|
||||
currentRow: '当前行',
|
||||
unnamed: '未命名',
|
||||
edit: '编辑',
|
||||
clear: '清空',
|
||||
add: '新增',
|
||||
editTabTitle: '费用编辑-{name}',
|
||||
columns: {
|
||||
name: '名字',
|
||||
rateFee: '费率计取',
|
||||
hourlyFee: '工时法',
|
||||
quantityUnitPriceFee: '数量单价',
|
||||
subtotal: '小计',
|
||||
actions: '操作'
|
||||
},
|
||||
dialog: {
|
||||
clearTitle: '确认清空',
|
||||
clearDesc: '将清空“{name}”及其编辑页面的可填和自动计算数据,是否继续?',
|
||||
confirmClear: '确认清空'
|
||||
}
|
||||
},
|
||||
xmFactorGrid: {
|
||||
clickToInput: '点击输入',
|
||||
columns: {
|
||||
standardFactor: '标准系数',
|
||||
budgetValue: '预算取值',
|
||||
remark: '说明',
|
||||
groupName: '专业编码以及工程专业名称'
|
||||
}
|
||||
},
|
||||
serviceSelector: {
|
||||
title: '选择服务',
|
||||
titleHint: '本选择项较《规范》列明的服务项有所增加。此增加与《规范》无冲突,只为满足《规范》费用计算之需',
|
||||
titleHintAria: '选择服务提示',
|
||||
clear: '清空',
|
||||
empty: '暂无服务'
|
||||
},
|
||||
zxFwView: {
|
||||
contractPrefix: '合同段:{name}',
|
||||
calcSuffix: '计算',
|
||||
contractId: '合同ID:{id}',
|
||||
workContentTitle: '工作内容',
|
||||
categories: {
|
||||
investmentScale: '投资规模法',
|
||||
investmentScaleFormula: '投资规模法计算公式',
|
||||
landScale: '用地规模法',
|
||||
landScaleFormula: '用地规模法计算公式',
|
||||
workload: '工作量法',
|
||||
hourly: '工时法',
|
||||
workContent: '工作内容'
|
||||
},
|
||||
formulaColumns: {
|
||||
subtitle: '直接展示当前计价法 store 的最新明细,随数据变更自动同步。',
|
||||
amount: '金额(元)',
|
||||
basicFormula: '基本工作计算式',
|
||||
optionalFormula: '可选工作计算式'
|
||||
},
|
||||
unavailable: {
|
||||
investmentScaleTitle: '该服务不适用投资规模法',
|
||||
investmentScaleMessage: '当前服务未启用规模法,投资规模法不可编辑。',
|
||||
landScaleTitle: '该服务不适用用地规模法',
|
||||
landScaleMessage: '当前服务仅支持投资规模法,用地规模法不可编辑。',
|
||||
workloadTitle: '该服务不适用工作量法',
|
||||
workloadMessage: '当前服务未启用工作量法,工作量法不可编辑。',
|
||||
hourlyTitle: '该服务不适用工时法',
|
||||
hourlyMessage: '当前服务未启用工时法,工时法不可编辑。'
|
||||
}
|
||||
},
|
||||
htFeeDetail: {
|
||||
subtotal: '总计',
|
||||
currentRow: '当前行',
|
||||
clickToInput: '点击输入',
|
||||
addRow: '添加行',
|
||||
columns: {
|
||||
no: '序号',
|
||||
feeItem: '费用项',
|
||||
unit: '单位',
|
||||
quantity: '数量',
|
||||
unitPrice: '单价(元)',
|
||||
budgetFee: '预算费用(元)',
|
||||
remark: '说明',
|
||||
actions: '操作'
|
||||
},
|
||||
dialog: {
|
||||
deleteTitle: '确认删除行',
|
||||
deleteDesc: '将删除“{name}”这条明细,是否继续?'
|
||||
}
|
||||
},
|
||||
workContent: {
|
||||
title: '工作内容',
|
||||
addCustom: '添加自定义内容',
|
||||
clickToInput: '点击输入',
|
||||
clickToInputContent: '点击输入工作内容',
|
||||
currentRow: '当前行',
|
||||
unnamed: '未命名',
|
||||
ungrouped: '未分组',
|
||||
type: {
|
||||
basic: '基本工作',
|
||||
optional: '可选工作',
|
||||
daily: '日常顾问',
|
||||
special: '专项顾问',
|
||||
additional: '附加工作',
|
||||
custom: '自定义'
|
||||
},
|
||||
columns: {
|
||||
no: '序号',
|
||||
content: '工作内容',
|
||||
type: '工作类型',
|
||||
remark: '备注',
|
||||
actions: '操作'
|
||||
},
|
||||
dialog: {
|
||||
deleteTitle: '确认删除行',
|
||||
deleteDesc: '将删除“{name}”这条明细,是否继续?'
|
||||
}
|
||||
},
|
||||
quickCalc: {
|
||||
projectName: '快速计算',
|
||||
industryLabel: '行业 {name}',
|
||||
selectIndustry: '请选择工程行业',
|
||||
saving: '保存中...',
|
||||
synced: '已同步行业',
|
||||
notSelectedIndustry: '未选择行业',
|
||||
notSelected: '未选择',
|
||||
consultCategory: '咨询类别',
|
||||
majorCategory: '工程专业',
|
||||
types: {
|
||||
consult: {
|
||||
label: '咨询类别(常用)',
|
||||
hint: '先选择咨询类别,再补规模和预算参数。'
|
||||
},
|
||||
general: {
|
||||
label: '通用专业',
|
||||
hint: '跨行业共用的补偿与其他费用专业。'
|
||||
},
|
||||
road: {
|
||||
label: '公路工程专业',
|
||||
hint: '首页行业为公路工程时默认展示。'
|
||||
},
|
||||
railway: {
|
||||
label: '铁路工程专业',
|
||||
hint: '首页行业为铁路工程时默认展示。'
|
||||
},
|
||||
waterway: {
|
||||
label: '水运工程专业',
|
||||
hint: '首页行业为水运工程时默认展示。'
|
||||
}
|
||||
},
|
||||
fields: {
|
||||
industry: '工程行业',
|
||||
code: '编码',
|
||||
investScale: '投资规模(万元)',
|
||||
landScale: '用地规模(亩)',
|
||||
formula: '计算式',
|
||||
amount: '金额(元)',
|
||||
consultFactor: '咨询分类系数',
|
||||
majorFactor: '工程专业系数',
|
||||
workEnvCoefficient: '工作环节系数',
|
||||
workEnvCoefficientPlaceholder: '默认 1',
|
||||
budgetAmount: '预算金额(元)'
|
||||
},
|
||||
sections: {
|
||||
basicInfo: '基础信息',
|
||||
scaleBase: '计算基数',
|
||||
benchmarkBudget: '基准预算',
|
||||
serviceBudget: '服务预算',
|
||||
},
|
||||
empty: {
|
||||
selectIndustry: '请选择工程行业。选中后可先选择咨询类别,再显示对应专业分类。',
|
||||
selectConsult: '请先选择咨询类别。选中后才会显示匹配的通用专业和工程专业分类。',
|
||||
scaleUnavailable: '当前咨询类别不适用规模法,因此不显示专业分类。',
|
||||
consultCostOnly: '当前咨询类别按行业汇总计价,工程专业系数已按所选行业自动带入,不再显示内部互补专业行。'
|
||||
},
|
||||
placeholder: {
|
||||
selectConsultFirst: '请先选择咨询类别',
|
||||
scaleUnavailable: '当前分类不适用规模法',
|
||||
selectMajorFirst: '请先选择工程专业',
|
||||
preferLandScale: '当前专业按用地规模计价',
|
||||
investUnavailable: '当前专业不适用投资规模',
|
||||
consultCostOnly: '当前分类仅支持投资规模',
|
||||
landUnavailable: '当前专业不适用用地规模',
|
||||
input: '请输入',
|
||||
selectScaleFirst: '请先选择输入对应规模'
|
||||
}
|
||||
},
|
||||
methodUnavailable: {
|
||||
defaultTitle: '该服务不适用当前计价方法'
|
||||
},
|
||||
xmCard: {
|
||||
categories: {
|
||||
info: '基础信息',
|
||||
scaleInfo: '规模信息',
|
||||
consultCategoryFactor: '咨询分类系数',
|
||||
majorFactor: '工程专业系数',
|
||||
contract: '合同段管理'
|
||||
}
|
||||
},
|
||||
htFeeMethodTypeLine: {
|
||||
feeDetail: '费用明细',
|
||||
unnamed: '未命名',
|
||||
title: '合同段:{contractName} · {rowName}',
|
||||
contractId: '合同ID:{id}',
|
||||
quantityUnitPrice: '数量单价'
|
||||
},
|
||||
pricingScale: {
|
||||
totalInvestmentByIndustry: '{industryName}总投资',
|
||||
totalInvestment: '总投资',
|
||||
clickToInput: '点击输入',
|
||||
projectLabel: '项目{index}',
|
||||
columns: {
|
||||
investAmount: '造价金额(万元)',
|
||||
landArea: '用地面积(亩)',
|
||||
benchmarkBudget: '基准预算(元)',
|
||||
basicWork: '基本工作',
|
||||
optionalWork: '可选工作',
|
||||
subtotal: '小计',
|
||||
budgetFee: '预算费用',
|
||||
consultCategoryFactor: '咨询分类系数',
|
||||
majorFactor: '专业系数',
|
||||
workStageFactor: '工作环节系数(编审系数)',
|
||||
workRatio: '服务预算构成比率与数量比',
|
||||
total: '合计',
|
||||
remark: '说明',
|
||||
majorGroup: '专业编码以及工程专业名称'
|
||||
},
|
||||
tooltip: {
|
||||
resetInvestAmount: '点击右侧↻本列造价金额数值恢复为本合同规模数值',
|
||||
resetLandArea: '点击右侧↻本列用地面积数值恢复为本合同规模数值',
|
||||
resetConsultCategoryFactor: '点击右侧↻本列咨询分类系数值恢复为本项目的相应的咨询分类系数值',
|
||||
resetMajorFactor: '点击右侧↻本列专业系数值恢复为本项目的相应的专业系数值',
|
||||
workRatio: '本列系数适用于以下两种情形:服务预算构成比率:适用于《规范》附录D表D.2~D.7中委托工作内容仅为部分工作时的情形;数量比,适用于计算基数按每份成果、分批次任务、单项工程或单位工程的规模(非总额)的情形,数量表示有多个个、份或项'
|
||||
}
|
||||
},
|
||||
pricingPane: {
|
||||
projectCount: '项目数量',
|
||||
clearTitle: '确认清空当前明细',
|
||||
confirmClear: '确认清空',
|
||||
useDefault: '使用默认数据',
|
||||
overrideTitle: '确认覆盖当前明细',
|
||||
confirmOverride: '确认覆盖',
|
||||
investment: {
|
||||
title: '投资规模明细',
|
||||
titleHint: '本表造价金额取值规则:以本合同规模表与本表造价金额中操作时间较晚的值为准',
|
||||
titleHintAria: '投资规模明细提示',
|
||||
clearDesc: '将清空当前投资规模明细,是否继续?',
|
||||
overrideDesc: '将使用合同默认数据覆盖当前投资规模明细,是否继续?'
|
||||
},
|
||||
land: {
|
||||
title: '用地规模明细',
|
||||
clearDesc: '将清空当前用地规模明细,是否继续?',
|
||||
overrideDesc: '将使用合同默认数据覆盖当前用地规模明细,是否继续?'
|
||||
}
|
||||
},
|
||||
workloadPricing: {
|
||||
title: '工作量明细',
|
||||
unavailableTitle: '该服务不适用工作量法',
|
||||
unavailableMessage: '当前服务没有关联工作量法任务,无需填写此部分内容。',
|
||||
clickToInput: '点击输入',
|
||||
none: '无',
|
||||
total: '总计',
|
||||
columns: {
|
||||
code: '编码',
|
||||
name: '名称',
|
||||
budgetBase: '预算基数',
|
||||
budgetReferenceUnitPrice: '预算参考单价',
|
||||
budgetAdoptedUnitPrice: '预算采用单价',
|
||||
workload: '工作量',
|
||||
consultCategoryFactor: '咨询分类系数',
|
||||
serviceFee: '服务费用(元)',
|
||||
remark: '说明'
|
||||
}
|
||||
},
|
||||
hourlyFeeGrid: {
|
||||
title: '工时法明细',
|
||||
clickToInput: '点击输入',
|
||||
total: '总计',
|
||||
columns: {
|
||||
code: '编码',
|
||||
name: '人员名称',
|
||||
referenceUnitPrice: '预算参考单价',
|
||||
laborBudgetUnitPrice: '人工预算单价(元/工日)',
|
||||
compositeBudgetUnitPrice: '综合预算单价(元/工日)',
|
||||
adoptedBudgetUnitPrice: '预算采用单价(元/工日)',
|
||||
personnelCount: '人员数量(人)',
|
||||
workdayCount: '工日数量(工日)',
|
||||
serviceBudget: '服务预算(元)',
|
||||
remark: '说明'
|
||||
}
|
||||
},
|
||||
xmScaleGrid: {
|
||||
syncToastTitle: '已同步咨询服务',
|
||||
syncToastDesc: '规模信息已同步到咨询服务({serviceCount} 项服务,{methodCount} 个计价页,{rowCount} 行)'
|
||||
},
|
||||
xmInfo: {
|
||||
defaultProjectName: 'xxx造价咨询服务',
|
||||
defaultDesc: '在履行造价咨询服务时,宜根据咨询服务质量情况分级确定相应的处罚金额。其中考评得分在大于及等于85和小于90分时,处罚金额为预算费用的10%;其中考评得分在大于及等于80和小于85分时,处罚金额为预算费用的20%;其中考评得分在大于及等于75和小于80分时,处罚金额为预算费用的30%;其中考评得分在大于及等于70和小于75分时,处罚金额为预算费用的40%;其中考评得分小于70分时,处罚金额为预算费用的50%以上。',
|
||||
industryHint: '变更需要重置后重新选择',
|
||||
industryHintAria: '工程行业提示',
|
||||
reportContentHint: '本内容为选择性填写,填写内容仅用于自动生成编制报告内容',
|
||||
reportContentHintAria: '说明内容提示',
|
||||
otherDescHint: '本内容为选择性填写。当前显示内容仅为示意,编制人可根据实际情况填写,亦可不填。',
|
||||
otherDescHintAria: '其他说明提示',
|
||||
createFromHomeFirst: '请从首页先新建项目后再进入此页面。',
|
||||
fields: {
|
||||
projectName: '项目名称',
|
||||
projectIndustry: '工程行业',
|
||||
overview: '项目概况',
|
||||
preparedBy: '编制人',
|
||||
reviewedBy: '复核人',
|
||||
preparedCompany: '编制单位',
|
||||
preparedDate: '编制日期',
|
||||
desc: '其他说明'
|
||||
},
|
||||
placeholders: {
|
||||
overview: '请输入项目概况',
|
||||
desc: '其他说明',
|
||||
preparedBy: 'XXX',
|
||||
reviewedBy: 'XXX',
|
||||
preparedCompany: 'XXX'
|
||||
}
|
||||
}
|
||||
} as const
|
||||
+2197
-234
File diff suppressed because it is too large.
Load diff
+378
-38
@@ -1,7 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger, DialogDescription
|
||||
} from 'reka-ui'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
||||
import { readCurrentProjectId } from '@/lib/workspace'
|
||||
interface TypeLineCategory {
|
||||
key: string
|
||||
label: string
|
||||
@@ -12,23 +26,45 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
scene?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
metaText?: string
|
||||
copyText?: string
|
||||
categories: TypeLineCategory[]
|
||||
storageKey?: string
|
||||
defaultCategory?: string
|
||||
persistActiveCategory?: boolean
|
||||
}>(),
|
||||
{
|
||||
scene: 'default',
|
||||
title: '配置',
|
||||
title: undefined,
|
||||
subtitle: '',
|
||||
metaText: '',
|
||||
copyText: '',
|
||||
storageKey: '',
|
||||
defaultCategory: ''
|
||||
defaultCategory: '',
|
||||
persistActiveCategory: true
|
||||
}
|
||||
)
|
||||
const { t } = useI18n()
|
||||
|
||||
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
|
||||
const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`)
|
||||
|
||||
const readStoredCategory = (key: string) => {
|
||||
const sessionValue = sessionStorage.getItem(key)
|
||||
if (sessionValue) return sessionValue
|
||||
return localStorage.getItem(key)
|
||||
}
|
||||
|
||||
const writeStoredCategory = (key: string, value: string) => {
|
||||
sessionStorage.setItem(key, value)
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
const resolveInitialCategory = () => {
|
||||
const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
|
||||
const savedKey = sessionStorage.getItem(cacheKey.value)
|
||||
if (!props.persistActiveCategory) return defaultKey
|
||||
const savedKey = readStoredCategory(scopedCacheKey.value)
|
||||
const validSavedKey = props.categories.some(item => item.key === savedKey)
|
||||
return validSavedKey ? (savedKey as string) : defaultKey
|
||||
}
|
||||
@@ -36,65 +72,341 @@ const resolveInitialCategory = () => {
|
||||
const activeCategory = ref(resolveInitialCategory())
|
||||
|
||||
watch(
|
||||
() => [props.categories, props.defaultCategory, cacheKey.value],
|
||||
() => [props.categories, props.defaultCategory, scopedCacheKey.value],
|
||||
() => {
|
||||
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
|
||||
if (isCurrentValid) return
|
||||
activeCategory.value = resolveInitialCategory()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
|
||||
|
||||
const switchCategory = (cat: string) => {
|
||||
activeCategory.value = cat
|
||||
sessionStorage.setItem(cacheKey.value, cat)
|
||||
if (!props.persistActiveCategory) return
|
||||
writeStoredCategory(scopedCacheKey.value, cat)
|
||||
}
|
||||
|
||||
const activeComponent = computed(() => {
|
||||
const selected = props.categories.find(item => item.key === activeCategory.value)
|
||||
return selected?.component || props.categories[0]?.component || null
|
||||
})
|
||||
|
||||
const sideWidthStyle = computed(() => ({ width: 'var(--app-typeline-side-w)' }))
|
||||
const itemGapStyle = computed(() => ({ gap: 'var(--app-typeline-gap)' }))
|
||||
const axisColStyle = computed(() => ({ width: 'var(--app-typeline-dot)' }))
|
||||
const dotStyle = computed(() => ({ width: 'var(--app-typeline-dot)', height: 'var(--app-typeline-dot)' }))
|
||||
const dotInnerStyle = computed(() => ({ width: 'var(--app-typeline-dot-inner)', height: 'var(--app-typeline-dot-inner)' }))
|
||||
const labelStyle = computed(() => ({ fontSize: 'var(--app-typeline-label-font)', lineHeight: 'var(--app-typeline-label-line)' }))
|
||||
const connectorStyle = computed(() => ({
|
||||
height: 'var(--app-typeline-arrow-shaft-h)',
|
||||
width: 'var(--app-typeline-arrow-line-w)'
|
||||
}))
|
||||
|
||||
const copyBtnText = ref(t('typeLine.copy'))
|
||||
const sheetOpen = ref(false)
|
||||
|
||||
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let titleOverflowRafId: number | null = null
|
||||
|
||||
const copyTextWithFallback = async (text: string) => {
|
||||
if (!text) return false
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') return false
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '-9999px'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
try {
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
return document.execCommand('copy')
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handleCopySubtitle = async () => {
|
||||
const text = (props.copyText || '').trim()
|
||||
if (!text) return
|
||||
|
||||
try {
|
||||
const copied = await copyTextWithFallback(text)
|
||||
copyBtnText.value = copied ? t('typeLine.copied') : t('typeLine.copyFailed')
|
||||
} catch (error) {
|
||||
console.error('copy failed:', error)
|
||||
copyBtnText.value = t('typeLine.copyFailed')
|
||||
}
|
||||
|
||||
if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
||||
copyBtnTimer = setTimeout(() => {
|
||||
copyBtnText.value = t('typeLine.copy')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
||||
if (titleOverflowRafId != null) {
|
||||
cancelAnimationFrame(titleOverflowRafId)
|
||||
titleOverflowRafId = null
|
||||
}
|
||||
if (!root) return
|
||||
root.style.scale = ''
|
||||
root.style.translate = ''
|
||||
root.style.borderRadius = ''
|
||||
})
|
||||
|
||||
//
|
||||
|
||||
|
||||
const inertiaTransition = {
|
||||
type: 'inertia' as const,
|
||||
bounceStiffness: 300,
|
||||
bounceDamping: 40,
|
||||
timeConstant: 300,
|
||||
}
|
||||
|
||||
const staticTransition = {
|
||||
duration: 0.5,
|
||||
ease: [0.32, 0.72, 0, 1] as const,
|
||||
}
|
||||
|
||||
const SHEET_TOP_RATIO = 0.1
|
||||
const SHEET_RADIUS = 12
|
||||
const OFFICIAL_SITE_URL = 'http://www.zwgczx.com.cn/'
|
||||
|
||||
let root: HTMLElement | null = null
|
||||
|
||||
onMounted(() => {
|
||||
root = document.body.firstElementChild as HTMLElement | null
|
||||
})
|
||||
|
||||
const { height, width } = useWindowSize()
|
||||
|
||||
const sheetTop = computed(() => Math.round(height.value * SHEET_TOP_RATIO))
|
||||
const h = computed(() => Math.max(0, height.value - sheetTop.value))
|
||||
const y = useMotionValue(h.value)
|
||||
|
||||
watch(
|
||||
() => h.value,
|
||||
(nextHeight) => {
|
||||
if (!sheetOpen.value) y.jump(nextHeight)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sheetOpen.value,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
y.jump(h.value)
|
||||
return
|
||||
}
|
||||
y.jump(h.value)
|
||||
animate(y, 0, staticTransition)
|
||||
}
|
||||
)
|
||||
|
||||
// Scale the body down and adjust the border radius when the sheet is open.
|
||||
const bodyScale = useTransform(
|
||||
y,
|
||||
[0, h.value],
|
||||
[(width.value - sheetTop.value) / width.value, 1],
|
||||
)
|
||||
const bodyTranslate = useTransform(y, [0, h.value], [sheetTop.value - SHEET_RADIUS, 0])
|
||||
const bodyBorderRadius = useTransform(y, [0, h.value], [SHEET_RADIUS, 0])
|
||||
|
||||
useMotionValueEvent(bodyScale, 'change', (v) => {
|
||||
if (!root) return
|
||||
root.style.scale = `${v}`
|
||||
})
|
||||
useMotionValueEvent(
|
||||
bodyTranslate,
|
||||
'change',
|
||||
(v) => {
|
||||
if (!root) return
|
||||
root.style.translate = `0 ${v}px`
|
||||
},
|
||||
)
|
||||
useMotionValueEvent(
|
||||
bodyBorderRadius,
|
||||
'change',
|
||||
(v) => {
|
||||
if (!root) return
|
||||
root.style.borderRadius = `${v}px`
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full bg-background">
|
||||
<div class="w-12/100 border-r p-6 flex flex-col gap-8 relative">
|
||||
<!-- <div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div> -->
|
||||
|
||||
<div class="flex flex-col gap-10 relative">
|
||||
<div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
|
||||
|
||||
<div
|
||||
v-for="item in props.categories"
|
||||
:key="item.key"
|
||||
class="relative flex items-center gap-4 cursor-pointer group"
|
||||
@click="switchCategory(item.key)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
|
||||
activeCategory === item.key
|
||||
? 'bg-primary border-primary scale-110'
|
||||
: 'bg-background border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
<div v-if="activeCategory === item.key" class="w-2 h-2 bg-background rounded-full"></div>
|
||||
<TooltipProvider>
|
||||
<div class="flex h-full w-full bg-background p-2">
|
||||
<div :style="sideWidthStyle" class="shrink-0 border-r px-4 py-3 flex flex-col gap-6 relative">
|
||||
<div v-if="props.title || props.subtitle || props.metaText" class="space-y-1">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<div v-if="props.title" ref="titleRef" class="title-ellipsis-2 max-w-full font-bold text-base leading-6 text-primary">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" :avoid-collisions="false">{{ props.title }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
<div v-if="props.subtitle" class="flex min-w-0 items-center gap-2 text-xs leading-5 text-muted-foreground">
|
||||
<div class="min-w-0 flex-1">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<span ref="subtitleRef" class="block max-w-full truncate">{{ props.subtitle }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" :avoid-collisions="false">{{ props.subtitle }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'text-sm transition-colors',
|
||||
<Button v-if="props.copyText" type="button" variant="outline" size="sm"
|
||||
class="h-6 rounded-md px-2 text-[11px]" @click.stop="handleCopySubtitle">
|
||||
{{ copyBtnText }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="props.metaText" class="text-xs leading-5 text-muted-foreground">
|
||||
{{ props.metaText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['flex flex-col gap-2 relative ', (props.title || props.subtitle || props.metaText) ? 'mt-3' : 'mt-6']">
|
||||
<div v-for="(item, index) in props.categories" :key="item.key"
|
||||
:style="itemGapStyle" class="flex items-start cursor-pointer group" @click="switchCategory(item.key)">
|
||||
<div :style="axisColStyle" class="flex shrink-0 flex-col items-center">
|
||||
<div :class="[
|
||||
'z-10 rounded-full border-2 flex shrink-0 items-center justify-center transition-all duration-200',
|
||||
activeCategory === item.key
|
||||
? 'font-bold text-primary'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
]"
|
||||
>
|
||||
? 'bg-primary border-primary shadow-[0_0_0_3px_rgba(var(--primary),0.15)]'
|
||||
: 'bg-background border-muted-foreground/40 group-hover:border-muted-foreground/70'
|
||||
]" :style="dotStyle">
|
||||
<div v-if="activeCategory === item.key" class="bg-background rounded-full" :style="dotInnerStyle"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < props.categories.length - 1"
|
||||
class="flex flex-col items-center"
|
||||
:style="{ paddingTop: 'var(--app-typeline-arrow-offset)' }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div :style="connectorStyle" class="typeline-arrow-connector"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span :class="[
|
||||
'pt-px transition-colors duration-200',
|
||||
activeCategory === item.key
|
||||
? 'font-semibold text-primary'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
]" :style="labelStyle">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogRoot v-model:open="sheetOpen">
|
||||
<DialogTrigger as-child>
|
||||
<button type="button"
|
||||
class="cursor-pointer absolute left-3 right-3 bottom-3 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[11px] leading-4 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground">
|
||||
<img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-8 w-8 shrink-0 rounded-sm" />
|
||||
<span>{{ t('typeLine.supportText') }}</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<AnimatePresence multiple as="div">
|
||||
<DialogOverlay as-child>
|
||||
<Motion class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]" :initial="{ opacity: 0 }"
|
||||
:animate="{ opacity: 1 }" :exit="{ opacity: 0 }" :transition="staticTransition" />
|
||||
</DialogOverlay>
|
||||
|
||||
<DialogContent as-child>
|
||||
<Motion
|
||||
class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-border/60 bg-card/95 shadow-2xl backdrop-blur-xl will-change-transform"
|
||||
:style="{
|
||||
y,
|
||||
top: `${sheetTop}px`,
|
||||
}" drag="y" :drag-constraints="{ top: 0 }" @drag-end="(e, { offset, velocity }) => {
|
||||
if (offset.y > h * 0.35 || velocity.y > 10) {
|
||||
sheetOpen = false;
|
||||
}
|
||||
else {
|
||||
animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
|
||||
}
|
||||
}">
|
||||
<div
|
||||
class="mx-auto mt-2 h-1.5 w-12 cursor-grab rounded-full bg-muted-foreground/35 active:cursor-grabbing" />
|
||||
<div class="mx-auto flex h-full w-full max-w-2xl flex-col px-4 pb-5 pt-3">
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-end">
|
||||
<DialogClose
|
||||
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<DialogTitle class="mt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-7 w-7 shrink-0 rounded-sm" />
|
||||
<span class="text-2xl font-semibold leading-none">{{ t('typeLine.aboutTitle') }}</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<DialogDescription class="mb-4 text-base text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline">
|
||||
{{ t('typeLine.companyName') }}
|
||||
</a>
|
||||
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||
:aria-label="t('typeLine.openOfficialSiteAria')" :title="t('typeLine.officialSiteTitle')">
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M7 7h10v10M7 17L17 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
|
||||
<div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
|
||||
<p>
|
||||
{{ t('typeLine.aboutParagraph1') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ t('typeLine.aboutParagraph2') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Motion>
|
||||
</DialogContent>
|
||||
</AnimatePresence>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-88/100 min-h-0 h-full flex flex-col">
|
||||
<div class="flex-1 min-w-0 min-h-0 h-full flex flex-col">
|
||||
<ScrollArea class="h-full w-full min-h-0 rightMain">
|
||||
<div class="p-3 h-full min-h-0 flex flex-col">
|
||||
<div class="p-4 h-full min-h-0 flex flex-col">
|
||||
<keep-alive>
|
||||
<component :is="activeComponent" />
|
||||
</keep-alive>
|
||||
@@ -102,6 +414,7 @@ const activeComponent = computed(() => {
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 核心修改:添加 :deep() 穿透 scoped 作用域 */
|
||||
@@ -110,4 +423,31 @@ const activeComponent = computed(() => {
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.title-ellipsis-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.typeline-arrow-connector {
|
||||
position: relative;
|
||||
background: color-mix(in oklab, var(--foreground) 22%, var(--border));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.typeline-arrow-connector::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: calc(var(--app-typeline-arrow-head-w) / 2) solid transparent;
|
||||
border-right: calc(var(--app-typeline-arrow-head-w) / 2) solid transparent;
|
||||
border-top: var(--app-typeline-arrow-head-h) solid color-mix(in oklab, var(--foreground) 22%, var(--border));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||
|
||||
type AnyColDef<TRow> = ColDef<TRow> | ColGroupDef<TRow>
|
||||
|
||||
const numericFieldKeywords = [
|
||||
'amount',
|
||||
'area',
|
||||
'cost',
|
||||
'price',
|
||||
'fee',
|
||||
'budget',
|
||||
'subtotal',
|
||||
'total',
|
||||
'ratio',
|
||||
'rate',
|
||||
'quantity',
|
||||
'count',
|
||||
'num',
|
||||
'workday',
|
||||
'workload',
|
||||
'hourly',
|
||||
'scale',
|
||||
'value',
|
||||
'coe',
|
||||
'factor'
|
||||
]
|
||||
|
||||
const isFiniteNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
const isNumericLikeString = (value: unknown) => {
|
||||
if (typeof value !== 'string') return false
|
||||
const normalized = value.replace(/[,\s]/g, '').replace(/%$/, '')
|
||||
if (!normalized) return false
|
||||
const asNumber = Number(normalized)
|
||||
return Number.isFinite(asNumber)
|
||||
}
|
||||
|
||||
const looksNumericByColumnDef = <TRow>(col: ColDef<TRow>) => {
|
||||
const fieldLike = String(col.field ?? col.colId ?? '').toLowerCase()
|
||||
const hasNumericFieldHint = fieldLike
|
||||
? numericFieldKeywords.some(keyword => fieldLike.includes(keyword))
|
||||
: false
|
||||
const type = col.type
|
||||
const typeList = Array.isArray(type) ? type : type ? [type] : []
|
||||
const hasNumericType = typeList.some(item => {
|
||||
const normalized = String(item).toLowerCase()
|
||||
return normalized.includes('numeric') || normalized.includes('rightaligned')
|
||||
})
|
||||
return hasNumericFieldHint || col.cellDataType === 'number' || hasNumericType
|
||||
}
|
||||
|
||||
const hasRightAlignedHeaderClass = (value: unknown) => {
|
||||
if (typeof value === 'string') return value.includes('ag-right-aligned-header')
|
||||
if (Array.isArray(value)) return value.some(item => String(item).includes('ag-right-aligned-header'))
|
||||
return false
|
||||
}
|
||||
|
||||
const mergeHeaderClass = <TRow>(col: ColDef<TRow>, mustRightAlign: boolean): ColDef<TRow>['headerClass'] => {
|
||||
if (!mustRightAlign) return col.headerClass
|
||||
|
||||
const base = col.headerClass
|
||||
if (!base) return 'ag-right-aligned-header'
|
||||
if (hasRightAlignedHeaderClass(base)) return base
|
||||
|
||||
if (typeof base === 'function') {
|
||||
return params => {
|
||||
const result = base(params)
|
||||
if (!result) return 'ag-right-aligned-header'
|
||||
if (typeof result === 'string') {
|
||||
return result.includes('ag-right-aligned-header')
|
||||
? result
|
||||
: `${result} ag-right-aligned-header`
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
return hasRightAlignedHeaderClass(result)
|
||||
? result
|
||||
: [...result, 'ag-right-aligned-header']
|
||||
}
|
||||
return 'ag-right-aligned-header'
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof base === 'string') return `${base} ag-right-aligned-header`
|
||||
if (Array.isArray(base)) return [...base, 'ag-right-aligned-header']
|
||||
return base
|
||||
}
|
||||
|
||||
const mergeNumericCellClassRules = <TRow>(
|
||||
col: ColDef<TRow>,
|
||||
mustRightAlign: boolean
|
||||
): ColDef<TRow>['cellClassRules'] => {
|
||||
if (!mustRightAlign) return col.cellClassRules
|
||||
|
||||
const baseRules = col.cellClassRules ? { ...col.cellClassRules } : {}
|
||||
const existing = baseRules['ag-right-aligned-cell']
|
||||
if (existing) return baseRules
|
||||
|
||||
baseRules['ag-right-aligned-cell'] = params =>
|
||||
looksNumericByColumnDef(params.colDef as ColDef<TRow>) ||
|
||||
isFiniteNumber(params.value) ||
|
||||
isNumericLikeString(params.value)
|
||||
|
||||
return baseRules
|
||||
}
|
||||
|
||||
const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] => {
|
||||
const baseStyle = { whiteSpace: 'normal', lineHeight: '1.4' }
|
||||
if (!cellStyle) return baseStyle
|
||||
if (typeof cellStyle === 'function') {
|
||||
return params => {
|
||||
const next = cellStyle(params)
|
||||
if (next && typeof next === 'object') {
|
||||
return {
|
||||
...next,
|
||||
...baseStyle
|
||||
}
|
||||
}
|
||||
return baseStyle
|
||||
}
|
||||
}
|
||||
if (typeof cellStyle === 'object') {
|
||||
return {
|
||||
...cellStyle,
|
||||
...baseStyle
|
||||
}
|
||||
}
|
||||
return cellStyle
|
||||
}
|
||||
|
||||
const enhanceLeafColumn = <TRow>(col: ColDef<TRow>): ColDef<TRow> => {
|
||||
const editable = col.editable
|
||||
const isReadonlyColumn = editable == null || editable === false
|
||||
const shouldRightAlign = looksNumericByColumnDef(col)
|
||||
if (!isReadonlyColumn) {
|
||||
return {
|
||||
...col,
|
||||
headerClass: mergeHeaderClass(col, shouldRightAlign),
|
||||
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...col,
|
||||
headerClass: mergeHeaderClass(col, shouldRightAlign),
|
||||
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign),
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: mergeCellStyle(col.cellStyle)
|
||||
}
|
||||
}
|
||||
|
||||
const enhanceColumn = <TRow>(col: AnyColDef<TRow>): AnyColDef<TRow> => {
|
||||
const maybeGroup = col as ColGroupDef<TRow>
|
||||
if (Array.isArray(maybeGroup.children)) {
|
||||
return {
|
||||
...maybeGroup,
|
||||
children: maybeGroup.children.map(child => enhanceColumn(child as AnyColDef<TRow>))
|
||||
}
|
||||
}
|
||||
return enhanceLeafColumn(col as ColDef<TRow>)
|
||||
}
|
||||
|
||||
export const withReadonlyAutoHeight = <TRow>(
|
||||
defs: Array<AnyColDef<TRow>>
|
||||
): Array<AnyColDef<TRow>> => defs.map(def => enhanceColumn(def))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { IHeaderComp, IHeaderParams } from 'ag-grid-community'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
export type ResetHeaderParams = IHeaderParams & {
|
||||
onReset?: () => void | Promise<void>
|
||||
resetTitle?: string
|
||||
}
|
||||
|
||||
export class AgGridResetHeader implements IHeaderComp {
|
||||
private params!: ResetHeaderParams
|
||||
private eGui!: HTMLDivElement
|
||||
private eLabel!: HTMLSpanElement
|
||||
private eButton!: HTMLButtonElement
|
||||
private onButtonClick = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void this.params.onReset?.()
|
||||
}
|
||||
|
||||
init(params: ResetHeaderParams) {
|
||||
this.params = params
|
||||
|
||||
const eGui = document.createElement('div')
|
||||
eGui.style.display = 'flex'
|
||||
eGui.style.alignItems = 'center'
|
||||
eGui.style.justifyContent = 'space-between'
|
||||
eGui.style.gap = '6px'
|
||||
eGui.style.width = '100%'
|
||||
|
||||
const eLabel = document.createElement('span')
|
||||
eLabel.style.flex = '1'
|
||||
eLabel.style.minWidth = '0'
|
||||
eLabel.style.whiteSpace = 'normal'
|
||||
eLabel.style.lineHeight = '1.2'
|
||||
|
||||
const eButton = document.createElement('button')
|
||||
eButton.type = 'button'
|
||||
eButton.textContent = '↻'
|
||||
const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
|
||||
eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
|
||||
eButton.style.display = 'inline-flex'
|
||||
eButton.style.alignItems = 'center'
|
||||
eButton.style.justifyContent = 'center'
|
||||
eButton.style.width = '18px'
|
||||
eButton.style.height = '18px'
|
||||
eButton.style.border = '1px solid #d1d5db'
|
||||
eButton.style.borderRadius = '999px'
|
||||
eButton.style.background = '#edff87'
|
||||
eButton.style.color = '#4b5563'
|
||||
eButton.style.cursor = 'pointer'
|
||||
eButton.style.fontSize = '12px'
|
||||
eButton.style.lineHeight = '1'
|
||||
eButton.style.flex = '0 0 auto'
|
||||
eButton.addEventListener('click', this.onButtonClick)
|
||||
|
||||
eGui.append(eLabel, eButton)
|
||||
|
||||
this.eGui = eGui
|
||||
this.eLabel = eLabel
|
||||
this.eButton = eButton
|
||||
this.refresh(params)
|
||||
}
|
||||
|
||||
getGui() {
|
||||
return this.eGui
|
||||
}
|
||||
|
||||
refresh(params: ResetHeaderParams) {
|
||||
this.params = params
|
||||
this.eLabel.textContent = params.displayName || params.column?.getColDef().headerName || ''
|
||||
const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
|
||||
const hintText = String(params.column?.getColDef().headerTooltip || '').trim()
|
||||
const hasReset = Boolean(params.onReset)
|
||||
const hasHint = Boolean(hintText)
|
||||
|
||||
if (hasReset) {
|
||||
this.eButton.textContent = '↻'
|
||||
this.eButton.title = ''
|
||||
this.eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
|
||||
this.eButton.style.visibility = 'visible'
|
||||
this.eButton.style.border = '1px solid #d1d5db'
|
||||
this.eButton.style.background = '#edff87'
|
||||
this.eButton.style.color = '#4b5563'
|
||||
this.eButton.style.cursor = 'pointer'
|
||||
return true
|
||||
}
|
||||
|
||||
if (hasHint) {
|
||||
this.eButton.textContent = '?'
|
||||
this.eButton.title = hintText
|
||||
this.eButton.setAttribute('aria-label', hintText)
|
||||
this.eButton.style.visibility = 'visible'
|
||||
this.eButton.style.border = '1px solid #cbd5e1'
|
||||
this.eButton.style.background = '#ffffff'
|
||||
this.eButton.style.color = '#64748b'
|
||||
this.eButton.style.cursor = 'help'
|
||||
return true
|
||||
}
|
||||
|
||||
this.eButton.textContent = ''
|
||||
this.eButton.title = ''
|
||||
this.eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
|
||||
this.eButton.style.visibility = 'hidden'
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.eButton?.removeEventListener('click', this.onButtonClick)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export interface DataEntry {
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export interface ContractSegmentPackage {
|
||||
version: number
|
||||
exportedAt: string
|
||||
packageType?: 'contract-segments'
|
||||
project?: {
|
||||
industry: string
|
||||
}
|
||||
storage?: {
|
||||
localforageEntries: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
}
|
||||
contracts: Array<{
|
||||
id: string
|
||||
name: string
|
||||
order: number
|
||||
createdAt: string
|
||||
}>
|
||||
projectIndustry?: string
|
||||
localforageEntries?: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
pinia?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
servicePricingStates?: Record<string, unknown>
|
||||
htFeeMainStates?: Record<string, unknown>
|
||||
htFeeMethodStates?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
piniaState?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
servicePricingStates?: Record<string, unknown>
|
||||
htFeeMainStates?: Record<string, unknown>
|
||||
htFeeMethodStates?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
|
||||
export const CONTRACT_SEGMENT_VERSION = 3
|
||||
export const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
|
||||
export const SERVICE_KEY_PREFIX = 'zxFW-'
|
||||
export const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
|
||||
export const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
|
||||
export const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-'] as const
|
||||
export const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
export const PROJECT_SCALE_KEY = 'xm-info-v3'
|
||||
export const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const
|
||||
|
||||
export const formatExportTimestamp = (date: Date): string => {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}`
|
||||
}
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
|
||||
export const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
|
||||
export const normalizeDataEntries = (value: unknown): DataEntry[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter(item => item && typeof item === 'object' && typeof (item as DataEntry).key === 'string')
|
||||
.map(item => ({
|
||||
key: String((item as DataEntry).key),
|
||||
value: (item as DataEntry).value
|
||||
}))
|
||||
}
|
||||
|
||||
export const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
|
||||
projectIndustry:
|
||||
typeof payload.project?.industry === 'string' && payload.project.industry.trim()
|
||||
? payload.project.industry.trim()
|
||||
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
||||
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
||||
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
|
||||
piniaState: payload.pinia ?? payload.piniaState
|
||||
})
|
||||
|
||||
export const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
|
||||
const payload = value as Partial<ContractSegmentPackage> | null
|
||||
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
|
||||
}
|
||||
|
||||
export const isContractRelatedForageKey = (key: string, contractId: string) => {
|
||||
if (key === `${CONTRACT_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${SERVICE_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${contractId}`) return true
|
||||
if (PRICING_KEY_PREFIXES.some(prefix => key.startsWith(`${prefix}${contractId}-`))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const isContractRelatedKeyedStateKey = (key: string, contractId: string) => {
|
||||
if (key === `ht-base-info-${contractId}`) return true
|
||||
if (key.startsWith(`work-content-${contractId}-`)) return true
|
||||
if (key.startsWith(`work-content-htExtraFee-${contractId}-`)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
|
||||
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
|
||||
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
|
||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${fromId}`) {
|
||||
return `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${toId}`
|
||||
}
|
||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
|
||||
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
|
||||
}
|
||||
if (key === `ht-base-info-${fromId}`) return `ht-base-info-${toId}`
|
||||
if (key.startsWith(`work-content-${fromId}-`)) {
|
||||
return key.replace(`work-content-${fromId}-`, `work-content-${toId}-`)
|
||||
}
|
||||
if (key.startsWith(`work-content-htExtraFee-${fromId}-`)) {
|
||||
return key.replace(`work-content-htExtraFee-${fromId}-`, `work-content-htExtraFee-${toId}-`)
|
||||
}
|
||||
for (const prefix of PRICING_KEY_PREFIXES) {
|
||||
if (key.startsWith(`${prefix}${fromId}-`)) {
|
||||
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export const generateContractId = (usedIds: Set<string>) => {
|
||||
let nextId = ''
|
||||
while (!nextId || usedIds.has(nextId)) {
|
||||
nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
}
|
||||
usedIds.add(nextId)
|
||||
return nextId
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Decimal from 'decimal.js'
|
||||
|
||||
type MaybeNumber = number | null | undefined
|
||||
type DecimalInput = Decimal.Value
|
||||
|
||||
export const toDecimal = (value: DecimalInput) => new Decimal(value)
|
||||
|
||||
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
|
||||
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
|
||||
|
||||
export const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
export const toFiniteNumberOrNull = (value: unknown): number | null =>
|
||||
isFiniteNumber(value) ? value : null
|
||||
|
||||
export const toFiniteNumberOrZero = (value: unknown): number =>
|
||||
toFiniteNumberOrNull(value) ?? 0
|
||||
|
||||
export const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (isFiniteNumber(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const numeric = Number(trimmed)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sumFiniteValues = (values: Iterable<unknown>) => {
|
||||
let total = new Decimal(0)
|
||||
for (const value of values) {
|
||||
if (!isFiniteNumber(value)) continue
|
||||
total = total.plus(value)
|
||||
}
|
||||
return total.toNumber()
|
||||
}
|
||||
|
||||
export const addNumbers = (...values: MaybeNumber[]) => sumFiniteValues(values)
|
||||
|
||||
export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
|
||||
let total = new Decimal(0)
|
||||
for (const item of list) {
|
||||
const value = pick(item)
|
||||
if (!isFiniteNumber(value)) continue
|
||||
total = total.plus(value)
|
||||
}
|
||||
return total.toNumber()
|
||||
}
|
||||
|
||||
export const sumNullableNumbers = (values: MaybeNumber[]): number | null => {
|
||||
const validValues = values.filter(isFiniteNumber)
|
||||
if (validValues.length === 0) return null
|
||||
return addNumbers(...validValues)
|
||||
}
|
||||
|
||||
export const decimalAggSum = (params: { values?: unknown[] }) => {
|
||||
const values = params.values || []
|
||||
let hasFinite = false
|
||||
for (const value of values) {
|
||||
if (!isFiniteNumber(value)) continue
|
||||
hasFinite = true
|
||||
break
|
||||
}
|
||||
if (!hasFinite) return null
|
||||
return sumFiniteValues(values)
|
||||
}
|
||||
|
||||
class DecimalExpressionParser {
|
||||
private readonly source: string
|
||||
private index = 0
|
||||
|
||||
constructor(source: string) {
|
||||
this.source = source
|
||||
}
|
||||
|
||||
parse(): Decimal | null {
|
||||
const value = this.parseExpression()
|
||||
this.skipWhitespace()
|
||||
if (!value || this.index !== this.source.length) return null
|
||||
return value
|
||||
}
|
||||
|
||||
private parseExpression(): Decimal | null {
|
||||
let value = this.parseTerm()
|
||||
if (!value) return null
|
||||
|
||||
while (true) {
|
||||
this.skipWhitespace()
|
||||
const operator = this.peek()
|
||||
if (operator !== '+' && operator !== '-') return value
|
||||
this.index += 1
|
||||
const right = this.parseTerm()
|
||||
if (!right) return null
|
||||
value = operator === '+' ? value.plus(right) : value.minus(right)
|
||||
}
|
||||
}
|
||||
|
||||
private parseTerm(): Decimal | null {
|
||||
let value = this.parseFactor()
|
||||
if (!value) return null
|
||||
|
||||
while (true) {
|
||||
this.skipWhitespace()
|
||||
const operator = this.peek()
|
||||
if (operator !== '*' && operator !== '/') return value
|
||||
this.index += 1
|
||||
const right = this.parseFactor()
|
||||
if (!right) return null
|
||||
if (operator === '/') {
|
||||
if (right.isZero()) return null
|
||||
value = value.div(right)
|
||||
continue
|
||||
}
|
||||
value = value.mul(right)
|
||||
}
|
||||
}
|
||||
|
||||
private parseFactor(): Decimal | null {
|
||||
this.skipWhitespace()
|
||||
const current = this.peek()
|
||||
if (current === '+') {
|
||||
this.index += 1
|
||||
return this.parseFactor()
|
||||
}
|
||||
if (current === '-') {
|
||||
this.index += 1
|
||||
const value = this.parseFactor()
|
||||
return value ? value.neg() : null
|
||||
}
|
||||
if (current === '(') {
|
||||
this.index += 1
|
||||
const value = this.parseExpression()
|
||||
this.skipWhitespace()
|
||||
if (!value || this.peek() !== ')') return null
|
||||
this.index += 1
|
||||
return value
|
||||
}
|
||||
return this.parseNumber()
|
||||
}
|
||||
|
||||
private parseNumber(): Decimal | null {
|
||||
this.skipWhitespace()
|
||||
const start = this.index
|
||||
let hasDigit = false
|
||||
let hasDot = false
|
||||
|
||||
while (this.index < this.source.length) {
|
||||
const char = this.source[this.index]
|
||||
if (char >= '0' && char <= '9') {
|
||||
hasDigit = true
|
||||
this.index += 1
|
||||
continue
|
||||
}
|
||||
if (char === '.' && !hasDot) {
|
||||
hasDot = true
|
||||
this.index += 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!hasDigit) {
|
||||
this.index = start
|
||||
return null
|
||||
}
|
||||
|
||||
const literal = this.source.slice(start, this.index)
|
||||
try {
|
||||
return new Decimal(literal)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private skipWhitespace() {
|
||||
while (this.index < this.source.length && /\s/.test(this.source[this.index])) {
|
||||
this.index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private peek() {
|
||||
return this.source[this.index]
|
||||
}
|
||||
}
|
||||
|
||||
// 支持 + - * / () 的高精度表达式计算,用于数字输入框和表格 valueParser。
|
||||
export const evaluateDecimalExpression = (value: string): number | null => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
const parsed = new DecimalExpressionParser(trimmed).parse()
|
||||
return parsed ? parsed.toNumber() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
CellPosition,
|
||||
ColDef,
|
||||
GridApi,
|
||||
GridSizeChangedEvent,
|
||||
FirstDataRenderedEvent,
|
||||
RowDataUpdatedEvent,
|
||||
ColumnResizedEvent,
|
||||
GridOptions,
|
||||
SuppressKeyboardEventParams
|
||||
} from 'ag-grid-community'
|
||||
import { themeQuartz } from 'ag-grid-community'
|
||||
|
||||
const borderConfig = {
|
||||
style: 'solid',
|
||||
width: 0.3,
|
||||
color: 'var(--border)'
|
||||
}
|
||||
|
||||
export const myTheme = themeQuartz.withParams({
|
||||
wrapperBorder: false,
|
||||
wrapperBorderRadius: 0,
|
||||
headerBackgroundColor: 'var(--muted)',
|
||||
headerTextColor: 'var(--foreground)',
|
||||
headerFontSize: 15,
|
||||
headerFontWeight: 'normal',
|
||||
rowBorder: borderConfig,
|
||||
columnBorder: borderConfig,
|
||||
headerRowBorder: borderConfig,
|
||||
dataBackgroundColor: 'var(--card)'
|
||||
})
|
||||
|
||||
// AG Grid 容器通用 class(占满父容器,配合父元素为 flex/grid 且有明确高度使用)
|
||||
export const agGridWrapClass = 'ag-theme-quartz h-full min-h-0 w-full flex-1'
|
||||
|
||||
// AG Grid 组件通用 style(撑满容器 div)
|
||||
export const agGridStyle = { height: '100%' }
|
||||
|
||||
const numericFieldKeywords = [
|
||||
'amount',
|
||||
'area',
|
||||
'cost',
|
||||
'price',
|
||||
'fee',
|
||||
'budget',
|
||||
'subtotal',
|
||||
'total',
|
||||
'ratio',
|
||||
'rate',
|
||||
'quantity',
|
||||
'count',
|
||||
'num',
|
||||
'workday',
|
||||
'workload',
|
||||
'hourly',
|
||||
'investscale',
|
||||
'landscale',
|
||||
'scale',
|
||||
'finalfee',
|
||||
'value',
|
||||
'coe',
|
||||
'factor'
|
||||
]
|
||||
|
||||
const isLikelyNumericColumn = (params: any) => {
|
||||
const value = params?.value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return true
|
||||
|
||||
const field = String(params?.colDef?.field || params?.column?.getColId?.() || '').toLowerCase()
|
||||
if (!field) return false
|
||||
return numericFieldKeywords.some(keyword => field.includes(keyword))
|
||||
}
|
||||
|
||||
const isPlainEnterKey = (event: KeyboardEvent) =>
|
||||
event.key === 'Enter' && !event.altKey && !event.ctrlKey && !event.metaKey
|
||||
|
||||
const findNextEditableCellInColumn = (
|
||||
params: SuppressKeyboardEventParams,
|
||||
startRowIndex: number
|
||||
): CellPosition | null => {
|
||||
const column = params.column
|
||||
for (let rowIndex = startRowIndex + 1; rowIndex < params.api.getDisplayedRowCount(); rowIndex += 1) {
|
||||
const rowNode = params.api.getDisplayedRowAtIndex(rowIndex)
|
||||
if (!rowNode || rowNode.group || rowNode.rowPinned) continue
|
||||
if (!column.isCellEditable(rowNode)) continue
|
||||
return {
|
||||
rowIndex,
|
||||
rowPinned: rowNode.rowPinned ?? null,
|
||||
column
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const focusCellPosition = (
|
||||
params: SuppressKeyboardEventParams,
|
||||
cellPosition: CellPosition | null
|
||||
) => {
|
||||
const target = cellPosition || {
|
||||
rowIndex: params.node.rowIndex ?? 0,
|
||||
rowPinned: params.node.rowPinned ?? null,
|
||||
column: params.column
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (params.api.isDestroyed?.()) return
|
||||
params.api.ensureIndexVisible(target.rowIndex)
|
||||
params.api.setFocusedCell(target.rowIndex, target.column, target.rowPinned)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const suppressExcelLikeEnter = (params: SuppressKeyboardEventParams) => {
|
||||
if (!isPlainEnterKey(params.event)) return false
|
||||
if (params.event.defaultPrevented || params.event.isComposing) return false
|
||||
|
||||
params.event.preventDefault()
|
||||
params.event.stopPropagation()
|
||||
params.api.stopEditing()
|
||||
|
||||
const currentRowIndex = params.node.rowIndex
|
||||
if (currentRowIndex == null) {
|
||||
focusCellPosition(params, null)
|
||||
return true
|
||||
}
|
||||
|
||||
const nextCell = findNextEditableCellInColumn(params, currentRowIndex)
|
||||
focusCellPosition(params, nextCell)
|
||||
return true
|
||||
}
|
||||
|
||||
const syncRowHeightsWithJs = (api: GridApi | null | undefined) => {
|
||||
if (!api || api.isDestroyed?.()) return
|
||||
// 统一使用 JS 重算,规避 wrapText/居中样式组合导致的高度滞后。
|
||||
setTimeout(() => {
|
||||
if (!api || api.isDestroyed?.()) return
|
||||
api.onRowHeightChanged()
|
||||
api.refreshCells({ force: true })
|
||||
api.redrawRows()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export const agGridDefaultColDef: ColDef = {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
wrapHeaderText: true,
|
||||
autoHeaderHeight: true,
|
||||
suppressKeyboardEvent: suppressExcelLikeEnter,
|
||||
// 默认把数值型单元格右对齐,减少每个列重复配置。
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': params => isLikelyNumericColumn(params)
|
||||
}
|
||||
}
|
||||
|
||||
export const gridOptions: GridOptions = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
tooltipShowMode: 'whenTruncated',
|
||||
suppressAggFuncInHeader: true,
|
||||
singleClickEdit: true,
|
||||
stopEditingWhenCellsLoseFocus: true,
|
||||
suppressClickEdit: false,
|
||||
suppressContextMenu: false,
|
||||
groupDefaultExpanded: -1,
|
||||
suppressFieldDotNotation: true,
|
||||
enterNavigatesVertically: true,
|
||||
enterNavigatesVerticallyAfterEdit: true,
|
||||
// rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。
|
||||
getRowId: params => {
|
||||
const id = params.data?.id
|
||||
if (id != null && String(id).trim()) return String(id)
|
||||
const path = Array.isArray(params.data?.path)
|
||||
? params.data.path.map((segment: unknown) => String(segment ?? '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (path.length > 0) return path.join('/')
|
||||
return '__row__'
|
||||
},
|
||||
// 兜底避免 AG Grid #185:treeData 模式下 path 不能为空数组。
|
||||
getDataPath: data => {
|
||||
const path = Array.isArray(data?.path)
|
||||
? data.path.map((segment: unknown) => String(segment ?? '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (path.length > 0) return path
|
||||
const fallback = String(data?.id ?? '').trim()
|
||||
return [fallback || '__row__']
|
||||
},
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: agGridDefaultColDef,
|
||||
defaultColGroupDef: {
|
||||
wrapHeaderText: true,
|
||||
autoHeaderHeight: true
|
||||
},
|
||||
onFirstDataRendered: (event: FirstDataRenderedEvent) => {
|
||||
syncRowHeightsWithJs(event.api)
|
||||
},
|
||||
onRowDataUpdated: (event: RowDataUpdatedEvent) => {
|
||||
syncRowHeightsWithJs(event.api)
|
||||
},
|
||||
onGridSizeChanged: (event: GridSizeChangedEvent) => {
|
||||
syncRowHeightsWithJs(event.api)
|
||||
},
|
||||
onColumnResized: (event: ColumnResizedEvent) => {
|
||||
syncRowHeightsWithJs(event.api)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
|
||||
themeQuartz
|
||||
} from "ag-grid-community"
|
||||
const borderConfig = {
|
||||
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
|
||||
width: 0.5, // 更细的边框,减少视觉干扰
|
||||
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
|
||||
};
|
||||
|
||||
// 简洁清新风格的主题配置
|
||||
export const myTheme = themeQuartz.withParams({
|
||||
// 核心:移除外边框,减少视觉包裹感
|
||||
wrapperBorder: false,
|
||||
|
||||
// 表头样式(柔和浅蓝,无加粗,更轻盈)
|
||||
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
|
||||
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
|
||||
headerFontSize: 15, // 字体稍大一点,更易读
|
||||
headerFontWeight: "normal", // 取消加粗,降低视觉重量
|
||||
|
||||
// 行/列/表头边框(统一浅灰细边框)
|
||||
rowBorder: borderConfig,
|
||||
columnBorder: borderConfig,
|
||||
headerRowBorder: borderConfig,
|
||||
|
||||
|
||||
// 可选:偶数行背景色(轻微区分,更清新)
|
||||
dataBackgroundColor: "#fefefe"
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
evaluateDecimalExpression,
|
||||
isFiniteNumber,
|
||||
roundTo,
|
||||
toFiniteNumberOrNull
|
||||
} from '@/lib/decimal'
|
||||
|
||||
export { isFiniteNumber, toFiniteNumberOrNull }
|
||||
|
||||
export const parseNumberOrNull = (
|
||||
value: unknown,
|
||||
options?: { sanitize?: boolean; precision?: number }
|
||||
): number | null => {
|
||||
if (value === '' || value == null) return null
|
||||
|
||||
const normalizedValue =
|
||||
options?.sanitize && typeof value === 'string'
|
||||
? value.replace(/[^0-9.+\-*/()\s]/g, '')
|
||||
: value
|
||||
|
||||
if (normalizedValue === '' || normalizedValue == null) return null
|
||||
|
||||
const normalized =
|
||||
typeof normalizedValue === 'string' ? normalizedValue.trim() : normalizedValue
|
||||
if (normalized === '') return null
|
||||
|
||||
let numericValue = Number(normalized)
|
||||
if (!Number.isFinite(numericValue) && typeof normalized === 'string') {
|
||||
const evaluated = evaluateDecimalExpression(normalized)
|
||||
if (evaluated == null || !Number.isFinite(evaluated)) return null
|
||||
numericValue = evaluated
|
||||
}
|
||||
if (!Number.isFinite(numericValue)) return null
|
||||
|
||||
const precision = options?.precision
|
||||
if (typeof precision !== 'number' || !Number.isInteger(precision) || precision < 0) {
|
||||
return numericValue
|
||||
}
|
||||
|
||||
return roundTo(numericValue, precision)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
|
||||
const fixedFormatterCache = new Map<number, Intl.NumberFormat>()
|
||||
const flexibleFormatterCache = new Map<number, Intl.NumberFormat>()
|
||||
|
||||
const getFixedFormatter = (fractionDigits: number) => {
|
||||
if (!fixedFormatterCache.has(fractionDigits)) {
|
||||
fixedFormatterCache.set(
|
||||
fractionDigits,
|
||||
new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits
|
||||
})
|
||||
)
|
||||
}
|
||||
return fixedFormatterCache.get(fractionDigits)!
|
||||
}
|
||||
|
||||
const getFlexibleFormatter = (maxFractionDigits: number) => {
|
||||
if (!flexibleFormatterCache.has(maxFractionDigits)) {
|
||||
flexibleFormatterCache.set(
|
||||
maxFractionDigits,
|
||||
new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: maxFractionDigits
|
||||
})
|
||||
)
|
||||
}
|
||||
return flexibleFormatterCache.get(maxFractionDigits)!
|
||||
}
|
||||
|
||||
export const formatThousands = (value: unknown, fractionDigits = 2) => {
|
||||
const numericValue = parseNumberOrNull(value)
|
||||
if (numericValue == null) return ''
|
||||
return getFixedFormatter(fractionDigits).format(numericValue)
|
||||
}
|
||||
|
||||
export const formatThousandsFlexible = (value: unknown, maxFractionDigits = 20) => {
|
||||
const numericValue = parseNumberOrNull(value)
|
||||
if (numericValue == null) return ''
|
||||
return getFlexibleFormatter(maxFractionDigits).format(numericValue)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 工时法计算模块
|
||||
*
|
||||
* 提供工时法的行构建、合并、费用计算等纯函数,
|
||||
* 供 HourlyPricingPane/HourlyFeeGrid 和 pricingMethodTotals.ts 共用。
|
||||
*/
|
||||
|
||||
import { expertList } from '@/sql'
|
||||
import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 专家字典查询
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** 获取专家条目列表(按 ID 排序) */
|
||||
export const getExpertEntries = (): [string, ExpertLite][] =>
|
||||
Object.entries(expertList as Record<string, ExpertLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
|
||||
/** 计算专家默认采用单价 = 基准单价 × 管理系数 */
|
||||
const getDefaultHourlyAdoptedPrice = (expert: ExpertLite): number | null => {
|
||||
if (expert.defPrice == null || expert.manageCoe == null) return null
|
||||
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 行构建与合并
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** 构建工时法默认行 */
|
||||
export const buildDefaultHourlyRows = (): HourlyDetailRow[] =>
|
||||
getExpertEntries().map(([expertId, expert]) => ({
|
||||
id: `expert-${expertId}`,
|
||||
adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert),
|
||||
personnelCount: null,
|
||||
workdayCount: null
|
||||
}))
|
||||
|
||||
/** 合并持久化行与默认行 */
|
||||
export const mergeHourlyRows = (
|
||||
rowsFromDb: Array<Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>> | undefined
|
||||
): HourlyDetailRow[] => {
|
||||
const dbMap = new Map<string, Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>>()
|
||||
for (const row of rowsFromDb || []) dbMap.set(row.id, row)
|
||||
|
||||
return buildDefaultHourlyRows().map(row => {
|
||||
const fromDb = dbMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
return {
|
||||
...row,
|
||||
adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice),
|
||||
personnelCount: toFiniteNumberOrNull(fromDb.personnelCount),
|
||||
workdayCount: toFiniteNumberOrNull(fromDb.workdayCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 费用计算
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** 计算工时法单行费用 = 采用单价 × 人数 × 工日数 */
|
||||
export const calcHourlyServiceBudget = (row: HourlyDetailRow): number | null => {
|
||||
const { adoptedBudgetUnitPrice, personnelCount, workdayCount } = row
|
||||
if (adoptedBudgetUnitPrice == null || personnelCount == null || workdayCount == null) return null
|
||||
return roundTo(toDecimal(adoptedBudgetUnitPrice).mul(personnelCount).mul(workdayCount), 2)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 计价法持久化控制
|
||||
*
|
||||
* 管理 sessionStorage 中的 skip/force 标记,
|
||||
* 用于控制计价法组件在清除/重建默认数据时的竞态。
|
||||
*/
|
||||
|
||||
import { readCurrentProjectId } from '@/lib/workspace'
|
||||
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
|
||||
export const buildProjectScopedSessionKey = (prefix: string, dbKey: string) =>
|
||||
`${prefix}${readCurrentProjectId()}:${dbKey}`
|
||||
|
||||
/**
|
||||
* 判断当前是否应跳过持久化写入
|
||||
* 用于防止组件卸载时覆盖刚被清除的数据
|
||||
*/
|
||||
export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [issuedRaw, untilRaw] = raw.split(':')
|
||||
const issuedAt = Number(issuedRaw)
|
||||
const skipUntil = Number(untilRaw)
|
||||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
||||
return paneCreatedAt <= issuedAt
|
||||
}
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const skipUntil = Number(raw)
|
||||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前是否应强制加载默认数据(忽略已有持久化数据)
|
||||
* 读取后立即清除标记(一次性)
|
||||
*/
|
||||
export const shouldForceDefaultLoad = (dbKey: string): boolean => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置跳过持久化标记
|
||||
* @param dbKey 存储键
|
||||
* @param durationMs 有效时长(毫秒),默认 3000ms
|
||||
*/
|
||||
export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
|
||||
const now = Date.now()
|
||||
sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置强制加载默认数据标记
|
||||
* @param dbKey 存储键
|
||||
* @param durationMs 有效时长(毫秒),默认 3000ms
|
||||
*/
|
||||
export const markForceDefaultLoad = (dbKey: string, durationMs = 3000): void => {
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
|
||||
sessionStorage.setItem(storageKey, String(Date.now() + durationMs))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface ScalePinnedTotalRowBase {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount?: number | null
|
||||
landArea?: number | null
|
||||
benchmarkBudget: number | null
|
||||
benchmarkBudgetBasic: number | null
|
||||
benchmarkBudgetOptional: number | null
|
||||
benchmarkBudgetBasicChecked: boolean
|
||||
benchmarkBudgetOptionalChecked: boolean
|
||||
basicFormula: string | null
|
||||
optionalFormula: string | null
|
||||
consultCategoryFactor: number | null
|
||||
majorFactor: number | null
|
||||
workStageFactor: number | null
|
||||
workRatio: number | null
|
||||
budgetFee: number | null
|
||||
budgetFeeBasic: number | null
|
||||
budgetFeeOptional: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
export const createScalePinnedTotalRow = <TRow extends ScalePinnedTotalRowBase>(
|
||||
overrides: Partial<TRow> & Pick<TRow, 'budgetFee' | 'budgetFeeBasic' | 'budgetFeeOptional'>
|
||||
) => {
|
||||
const baseRow: ScalePinnedTotalRowBase = {
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
hasCost: false,
|
||||
hasArea: false,
|
||||
amount: null,
|
||||
benchmarkBudget: null,
|
||||
benchmarkBudgetBasic: null,
|
||||
benchmarkBudgetOptional: null,
|
||||
benchmarkBudgetBasicChecked: true,
|
||||
benchmarkBudgetOptionalChecked: true,
|
||||
basicFormula: '',
|
||||
optionalFormula: '',
|
||||
consultCategoryFactor: null,
|
||||
majorFactor: null,
|
||||
workStageFactor: null,
|
||||
workRatio: null,
|
||||
budgetFee: null,
|
||||
budgetFeeBasic: null,
|
||||
budgetFeeOptional: null,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
return {
|
||||
...baseRow,
|
||||
...overrides
|
||||
} as TRow
|
||||
}
|
||||
|
||||
export const createPinnedTopRowData = <TRow>(row: TRow): TRow[] => [row]
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 规模法通用计算(投资规模法 + 用地规模法共用)
|
||||
*
|
||||
* 提供行默认值构建、行合并、费用计算等纯函数,
|
||||
* 供 ScalePricingPane.vue 和 pricingMethodTotals.ts 共用。
|
||||
*/
|
||||
|
||||
import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||||
import { toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||
import { isInvestScaleSingleTotalService } from '@/lib/servicePricing'
|
||||
import type {
|
||||
ScaleCalcRow,
|
||||
ScaleType,
|
||||
MajorLite,
|
||||
ServiceLite
|
||||
} from '@/types/pricing'
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 专业字典查询
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
const majorById = new Map(
|
||||
getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite])
|
||||
)
|
||||
const majorIdAliasMap = getMajorIdAliasMap()
|
||||
|
||||
/** 获取专业叶子节点 ID 列表(code 含 '-' 的为叶子) */
|
||||
export const getMajorLeafIds = (): string[] =>
|
||||
getMajorDictEntries()
|
||||
.filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
|
||||
.map(({ id }) => id)
|
||||
|
||||
/** 解析专业 ID 别名 */
|
||||
export const resolveMajorId = (id: string): string =>
|
||||
majorById.has(id) ? id : majorIdAliasMap.get(id) || id
|
||||
|
||||
/** 获取专业默认系数 */
|
||||
export const getDefaultMajorFactor = (id: string): number | null => {
|
||||
const resolvedId = resolveMajorId(id)
|
||||
return toFiniteNumberOrNull(majorById.get(resolvedId)?.defCoe)
|
||||
}
|
||||
|
||||
/** 判断专业是否支持投资规模(hasCost) */
|
||||
export const isCostMajor = (id: string): boolean => {
|
||||
const resolvedId = resolveMajorId(id)
|
||||
return majorById.get(resolvedId)?.hasCost !== false
|
||||
}
|
||||
|
||||
/** 判断专业是否支持用地规模(hasArea) */
|
||||
export const isAreaMajor = (id: string): boolean => {
|
||||
const resolvedId = resolveMajorId(id)
|
||||
return majorById.get(resolvedId)?.hasArea !== false
|
||||
}
|
||||
|
||||
/** 判断专业是否同时支持投资和用地 */
|
||||
export const isDualScaleMajor = (id: string): boolean =>
|
||||
isCostMajor(id) && isAreaMajor(id)
|
||||
|
||||
/** 根据行业 ID 查找对应的专业条目 */
|
||||
export const getIndustryMajorEntry = (industryId: string | null | undefined) => {
|
||||
const key = String(industryId || '').trim()
|
||||
if (!key) return null
|
||||
for (const [id, item] of majorById.entries()) {
|
||||
const majorIndustryId = String(item?.industryId ?? '').trim()
|
||||
if (majorIndustryId === key && !String(item?.code || '').includes('-')) {
|
||||
return { id, item }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 咨询服务字典查询
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** 获取咨询服务默认分类系数 */
|
||||
export const getDefaultConsultCategoryFactor = (serviceId: string | number): number | null => {
|
||||
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||||
return toFiniteNumberOrNull(service?.defCoe)
|
||||
}
|
||||
|
||||
/** 判断是否为仅投资规模服务 */
|
||||
export const isInvestScaleSingleTotalByService = (serviceId: string | number): boolean => {
|
||||
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||||
return isInvestScaleSingleTotalService(service)
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 行构建与合并
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** 判断是否为分组汇总行(AG Grid tree 用) */
|
||||
export const isGroupScaleRow = (row: unknown): boolean =>
|
||||
Boolean(row && typeof row === 'object' && (row as Record<string, unknown>).isGroupRow === true)
|
||||
|
||||
/** 过滤掉分组汇总行 */
|
||||
export const stripGroupScaleRows = <TRow>(rows: TRow[] | undefined): TRow[] =>
|
||||
(rows || []).filter(row => !isGroupScaleRow(row))
|
||||
|
||||
/** 构建规模法默认行(全部专业叶子) */
|
||||
export const buildDefaultScaleRows = (
|
||||
serviceId: string | number,
|
||||
consultCategoryFactorMap?: Map<string, number | null>,
|
||||
majorFactorMap?: Map<string, number | null>
|
||||
): ScaleCalcRow[] => {
|
||||
const defaultFactor =
|
||||
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
|
||||
return getMajorLeafIds().map(id => ({
|
||||
id,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
consultCategoryFactor: defaultFactor,
|
||||
majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactor(id),
|
||||
workStageFactor: 1,
|
||||
workRatio: 1
|
||||
}))
|
||||
}
|
||||
|
||||
const hasOwn = (obj: unknown, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(obj || {}, key)
|
||||
|
||||
const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
|
||||
const map = new Map<string, TRow>()
|
||||
for (const row of rows || []) map.set(String(row.id), row)
|
||||
return map
|
||||
}
|
||||
|
||||
/** 合并持久化行与默认行(保留用户编辑值,补全缺失字段) */
|
||||
export const mergeScaleRows = (
|
||||
serviceId: string | number,
|
||||
rowsFromDb: Array<Partial<ScaleCalcRow> & Pick<ScaleCalcRow, 'id'>> | undefined,
|
||||
consultCategoryFactorMap?: Map<string, number | null>,
|
||||
majorFactorMap?: Map<string, number | null>
|
||||
): ScaleCalcRow[] => {
|
||||
const sourceRows = stripGroupScaleRows(rowsFromDb)
|
||||
const dbValueMap = toRowMap(sourceRows)
|
||||
// 处理 ID 别名映射
|
||||
for (const row of sourceRows) {
|
||||
const nextId = majorIdAliasMap.get(String(row.id))
|
||||
if (nextId && !dbValueMap.has(nextId)) {
|
||||
dbValueMap.set(nextId, row as ScaleCalcRow)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultFactor =
|
||||
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
|
||||
|
||||
return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap).map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
return {
|
||||
...row,
|
||||
amount: toFiniteNumberOrNull(fromDb.amount),
|
||||
landArea: toFiniteNumberOrNull(fromDb.landArea),
|
||||
consultCategoryFactor:
|
||||
toFiniteNumberOrNull(fromDb.consultCategoryFactor) ??
|
||||
(hasOwn(fromDb, 'consultCategoryFactor') ? null : defaultFactor),
|
||||
majorFactor:
|
||||
toFiniteNumberOrNull(fromDb.majorFactor) ??
|
||||
(hasOwn(fromDb, 'majorFactor') ? null : (majorFactorMap?.get(row.id) ?? getDefaultMajorFactor(row.id))),
|
||||
workStageFactor:
|
||||
toFiniteNumberOrNull(fromDb.workStageFactor) ??
|
||||
(hasOwn(fromDb, 'workStageFactor') ? null : row.workStageFactor),
|
||||
workRatio:
|
||||
toFiniteNumberOrNull(fromDb.workRatio) ??
|
||||
(hasOwn(fromDb, 'workRatio') ? null : row.workRatio)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 费用计算
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** 计算投资规模法单行费用 */
|
||||
export const calcInvestBudgetFee = (row: ScaleCalcRow): number | null =>
|
||||
getScaleBudgetFee({
|
||||
benchmarkBudget: getBenchmarkBudgetByScale(row.amount, 'cost'),
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})
|
||||
|
||||
/** 计算用地规模法单行费用 */
|
||||
export const calcLandBudgetFee = (row: ScaleCalcRow): number | null =>
|
||||
getScaleBudgetFee({
|
||||
benchmarkBudget: getBenchmarkBudgetByScale(row.landArea, 'area'),
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})
|
||||
|
||||
/** 根据规模类型计算单行费用 */
|
||||
export const calcScaleBudgetFee = (row: ScaleCalcRow, scaleType: ScaleType): number | null =>
|
||||
scaleType === 'invest' ? calcInvestBudgetFee(row) : calcLandBudgetFee(row)
|
||||
|
||||
/** 判断行是否属于指定规模类型 */
|
||||
export const isRowForScaleType = (rowId: string, scaleType: ScaleType): boolean =>
|
||||
scaleType === 'invest' ? isCostMajor(rowId) : isAreaMajor(rowId)
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 可空数值求和
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** 对数组求和,全部为 null 时返回 null */
|
||||
export const sumNullableBy = <T>(list: T[], pick: (item: T) => number | null | undefined): number | null => {
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const item of list) {
|
||||
const value = toFiniteNumberOrNull(pick(item))
|
||||
if (value == null) continue
|
||||
hasValid = true
|
||||
total += value
|
||||
}
|
||||
return hasValid ? total : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||
import {
|
||||
formatScaleEditableNumber,
|
||||
formatScaleReadonlyMoney,
|
||||
getScaleMergeColSpanBeforeTotal
|
||||
} from '@/lib/pricingScaleGrid'
|
||||
import { AgGridResetHeader } from '@/lib/agGridResetHeader'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
type ScaleColumnField<TRow> = Extract<keyof TRow, string> | string
|
||||
const scaleT = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? i18n.global.t(`pricingScale.${key}`, params) : i18n.global.t(`pricingScale.${key}`)
|
||||
|
||||
export const createScaleValueColumn = <TRow>(options: {
|
||||
headerName: string
|
||||
field: ScaleColumnField<TRow>
|
||||
headerTooltip: string
|
||||
onReset: () => Promise<void> | void
|
||||
resetTitle: string
|
||||
headerComponent: any
|
||||
minWidth?: number
|
||||
flex?: number
|
||||
isEditable: (row: TRow | undefined) => boolean
|
||||
emptyTextPredicate: (row: TRow | undefined, value: unknown) => boolean
|
||||
valueParser: (params: any) => any
|
||||
valueFormatter: (params: any) => string
|
||||
}) : ColDef<TRow> => ({
|
||||
headerName: options.headerName,
|
||||
field: options.field as any,
|
||||
headerTooltip: options.headerTooltip,
|
||||
headerComponent: options.headerComponent,
|
||||
headerComponentParams: {
|
||||
onReset: options.onReset,
|
||||
resetTitle: options.resetTitle
|
||||
},
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: options.minWidth ?? 90,
|
||||
flex: options.flex ?? 2,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && options.isEditable(params.data),
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned && options.isEditable(params.data)
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && options.emptyTextPredicate(params.data, params.value)
|
||||
},
|
||||
valueParser: options.valueParser,
|
||||
valueFormatter: options.valueFormatter
|
||||
})
|
||||
|
||||
export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
|
||||
getCheckedSplit: (row: TRow | undefined) => { basic?: number | null; optional?: number | null; total?: number | null } | null
|
||||
createBudgetCellRendererWithCheck: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
|
||||
getHeaderComponent?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
|
||||
getHeaderComponentParams?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => Record<string, unknown>
|
||||
}) : ColGroupDef<TRow> => ({
|
||||
headerName: scaleT('columns.benchmarkBudget'),
|
||||
marryChildren: true,
|
||||
children: [
|
||||
{
|
||||
headerName: scaleT('columns.basicWork'),
|
||||
field: 'benchmarkBudgetBasic' as any,
|
||||
colId: 'benchmarkBudgetBasic',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
headerComponent: options.getHeaderComponent?.('benchmarkBudgetBasicChecked'),
|
||||
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetBasicChecked'),
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.basic ?? null),
|
||||
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
|
||||
cellRendererParams: {
|
||||
suppressMouseEventHandling: () => true
|
||||
},
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
},
|
||||
{
|
||||
headerName: scaleT('columns.optionalWork'),
|
||||
field: 'benchmarkBudgetOptional' as any,
|
||||
colId: 'benchmarkBudgetOptional',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
headerComponent: options.getHeaderComponent?.('benchmarkBudgetOptionalChecked'),
|
||||
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetOptionalChecked'),
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.optional ?? null),
|
||||
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
|
||||
cellRendererParams: {
|
||||
suppressMouseEventHandling: () => true
|
||||
},
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
},
|
||||
{
|
||||
headerName: scaleT('columns.subtotal'),
|
||||
field: 'benchmarkBudget' as any,
|
||||
colId: 'benchmarkBudgetTotal',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.total ?? null),
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
|
||||
headerComponent: any
|
||||
restoreConsultCategoryFactorColumnDefaults: () => Promise<void> | void
|
||||
restoreMajorFactorColumnDefaults: () => Promise<void> | void
|
||||
parseNumberOrNull: (value: any, options?: any) => any
|
||||
getBudgetFee: (row: TRow | undefined) => number | null
|
||||
aggFunc: any
|
||||
}) : ColGroupDef<TRow> => ({
|
||||
headerName: scaleT('columns.budgetFee'),
|
||||
marryChildren: true,
|
||||
children: [
|
||||
{
|
||||
headerName: scaleT('columns.consultCategoryFactor'),
|
||||
field: 'consultCategoryFactor' as any,
|
||||
colId: 'consultCategoryFactor',
|
||||
headerTooltip: scaleT('tooltip.resetConsultCategoryFactor'),
|
||||
headerComponent: options.headerComponent,
|
||||
headerComponentParams: {
|
||||
onReset: options.restoreConsultCategoryFactorColumnDefaults,
|
||||
resetTitle: scaleT('tooltip.resetConsultCategoryFactor')
|
||||
},
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params)
|
||||
},
|
||||
{
|
||||
headerName: scaleT('columns.majorFactor'),
|
||||
field: 'majorFactor' as any,
|
||||
colId: 'majorFactor',
|
||||
headerTooltip: scaleT('tooltip.resetMajorFactor'),
|
||||
headerComponent: options.headerComponent,
|
||||
headerComponentParams: {
|
||||
onReset: options.restoreMajorFactorColumnDefaults,
|
||||
resetTitle: scaleT('tooltip.resetMajorFactor')
|
||||
},
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params)
|
||||
},
|
||||
{
|
||||
headerName: scaleT('columns.workStageFactor'),
|
||||
field: 'workStageFactor' as any,
|
||||
colId: 'workStageFactor',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params)
|
||||
},
|
||||
{
|
||||
headerName: scaleT('columns.workRatio'),
|
||||
field: 'workRatio' as any,
|
||||
colId: 'workRatio',
|
||||
headerTooltip: scaleT('tooltip.workRatio'),
|
||||
headerComponent: AgGridResetHeader,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params, 2)
|
||||
},
|
||||
{
|
||||
headerName: scaleT('columns.total'),
|
||||
field: 'budgetFee' as any,
|
||||
colId: 'budgetFeeTotal',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 120,
|
||||
flex: 1,
|
||||
aggFunc: options.aggFunc,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? (params.data as any)?.budgetFee ?? null : options.getBudgetFee(params.data)),
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export const createScaleRemarkColumn = <TRow>() : ColDef<TRow> => ({
|
||||
headerName: scaleT('columns.remark'),
|
||||
field: 'remark' as any,
|
||||
minWidth: 100,
|
||||
flex: 1.2,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return scaleT('clickToInput')
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
})
|
||||
|
||||
export const createScaleAutoGroupColumn = <TRow>(options: {
|
||||
totalLabel: string
|
||||
idLabelMap: Map<string, string>
|
||||
parseProjectIndexFromPathKey: (key: string) => number | null
|
||||
}) : ColDef<TRow> => ({
|
||||
headerName: scaleT('columns.majorGroup'),
|
||||
minWidth: 250,
|
||||
flex: 2,
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellClassRules: {
|
||||
'ag-summary-label-cell': params => Boolean(params.node?.rowPinned)
|
||||
},
|
||||
cellStyle: {
|
||||
whiteSpace: 'normal',
|
||||
lineHeight: '1.4'
|
||||
},
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
colSpan: getScaleMergeColSpanBeforeTotal,
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return options.totalLabel
|
||||
}
|
||||
const rowData = params.data as any
|
||||
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
|
||||
return `${rowData.majorCode} ${rowData.majorName}`
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
|
||||
if (projectIndex != null) return scaleT('projectLabel', { index: projectIndex })
|
||||
return options.idLabelMap.get(nodeId) || nodeId
|
||||
},
|
||||
tooltipValueGetter: params => {
|
||||
if (params.node?.rowPinned) return options.totalLabel
|
||||
const rowData = params.data as any
|
||||
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
|
||||
return `${rowData.majorCode} ${rowData.majorName}`
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
|
||||
if (projectIndex != null) return scaleT('projectLabel', { index: projectIndex })
|
||||
return options.idLabelMap.get(nodeId) || nodeId
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit, type ScaleFeeSplitResult } from '@/lib/pricingScaleFee'
|
||||
|
||||
export type ScaleMode = 'cost' | 'area'
|
||||
|
||||
export interface ScaleBudgetCheckRow {
|
||||
benchmarkBudgetBasicChecked?: boolean
|
||||
benchmarkBudgetOptionalChecked?: boolean
|
||||
}
|
||||
|
||||
export interface ScaleBudgetSourceRow extends ScaleBudgetCheckRow {
|
||||
amount?: number | null
|
||||
landArea?: number | null
|
||||
majorFactor?: number | null
|
||||
consultCategoryFactor?: number | null
|
||||
workStageFactor?: number | null
|
||||
workRatio?: number | null
|
||||
}
|
||||
|
||||
export interface ScaleDetailComputedRow extends ScaleBudgetSourceRow {
|
||||
benchmarkBudget?: number | null
|
||||
benchmarkBudgetBasic?: number | null
|
||||
benchmarkBudgetOptional?: number | null
|
||||
basicFormula?: string | null
|
||||
optionalFormula?: string | null
|
||||
budgetFee?: number | null
|
||||
budgetFeeBasic?: number | null
|
||||
budgetFeeOptional?: number | null
|
||||
}
|
||||
|
||||
export type CheckedScaleFeeSplitResult = Omit<ScaleFeeSplitResult, 'total'> & {
|
||||
total: number | null
|
||||
}
|
||||
|
||||
const getScaleValueByMode = (
|
||||
row: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea'> | undefined,
|
||||
mode: ScaleMode
|
||||
) => (mode === 'cost' ? row?.amount : row?.landArea)
|
||||
|
||||
export const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
|
||||
if (left == null && right == null) return true
|
||||
if (left == null || right == null) return false
|
||||
return roundTo(left, 6) === roundTo(right, 6)
|
||||
}
|
||||
|
||||
export const isSameNullableText = (left: string | null | undefined, right: string | null | undefined) =>
|
||||
String(left ?? '') === String(right ?? '')
|
||||
|
||||
export const isBenchmarkBudgetFullyUnchecked = (
|
||||
row?: Pick<ScaleBudgetCheckRow, 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
|
||||
) => row?.benchmarkBudgetBasicChecked === false && row?.benchmarkBudgetOptionalChecked === false
|
||||
|
||||
export const getBenchmarkBudgetRawSplitByRow = (
|
||||
row: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea'> | undefined,
|
||||
mode: ScaleMode
|
||||
) => getBenchmarkBudgetSplitByScale(getScaleValueByMode(row, mode), mode)
|
||||
|
||||
export const getCheckedBenchmarkBudgetSplitByRow = (
|
||||
row?: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea' | 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>,
|
||||
mode: ScaleMode = 'cost'
|
||||
): CheckedScaleFeeSplitResult | null => {
|
||||
const split = getBenchmarkBudgetRawSplitByRow(row, mode)
|
||||
if (!split) return null
|
||||
const basic = row?.benchmarkBudgetBasicChecked === false ? 0 : split.basic
|
||||
const optional = row?.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
|
||||
return {
|
||||
...split,
|
||||
basic,
|
||||
optional,
|
||||
total: isBenchmarkBudgetFullyUnchecked(row) ? null : roundTo(addNumbers(basic, optional), 2)
|
||||
}
|
||||
}
|
||||
|
||||
export const getScaleBudgetFeeSplitByRow = (
|
||||
row?: Pick<
|
||||
ScaleBudgetSourceRow,
|
||||
| 'amount'
|
||||
| 'landArea'
|
||||
| 'benchmarkBudgetBasicChecked'
|
||||
| 'benchmarkBudgetOptionalChecked'
|
||||
| 'majorFactor'
|
||||
| 'consultCategoryFactor'
|
||||
| 'workStageFactor'
|
||||
| 'workRatio'
|
||||
>,
|
||||
mode: ScaleMode = 'cost'
|
||||
) => {
|
||||
if (isBenchmarkBudgetFullyUnchecked(row)) return null
|
||||
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByRow(row, mode)
|
||||
if (!benchmarkBudgetSplit) return null
|
||||
return getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
|
||||
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
|
||||
majorFactor: row?.majorFactor,
|
||||
consultCategoryFactor: row?.consultCategoryFactor,
|
||||
workStageFactor: row?.workStageFactor,
|
||||
workRatio: row?.workRatio
|
||||
})
|
||||
}
|
||||
|
||||
export const getScaleBudgetFeeByRow = (
|
||||
row?: Pick<
|
||||
ScaleBudgetSourceRow,
|
||||
| 'amount'
|
||||
| 'landArea'
|
||||
| 'benchmarkBudgetBasicChecked'
|
||||
| 'benchmarkBudgetOptionalChecked'
|
||||
| 'majorFactor'
|
||||
| 'consultCategoryFactor'
|
||||
| 'workStageFactor'
|
||||
| 'workRatio'
|
||||
>,
|
||||
mode: ScaleMode = 'cost'
|
||||
) => getScaleBudgetFeeSplitByRow(row, mode)?.total ?? null
|
||||
|
||||
export const recomputeScaleDetailRow = <TRow extends ScaleDetailComputedRow>(
|
||||
row: TRow,
|
||||
mode: ScaleMode
|
||||
): TRow => {
|
||||
const benchmarkBudgetRawSplit = getBenchmarkBudgetRawSplitByRow(row, mode)
|
||||
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByRow(row, mode)
|
||||
const budgetFeeSplit = getScaleBudgetFeeSplitByRow(row, mode)
|
||||
|
||||
return {
|
||||
...row,
|
||||
benchmarkBudget: benchmarkBudgetSplit?.total ?? null,
|
||||
benchmarkBudgetBasic: benchmarkBudgetSplit?.basic ?? null,
|
||||
benchmarkBudgetOptional: benchmarkBudgetSplit?.optional ?? null,
|
||||
basicFormula: row.benchmarkBudgetBasicChecked === false ? null : (benchmarkBudgetRawSplit?.basicFormula ?? ''),
|
||||
optionalFormula: row.benchmarkBudgetOptionalChecked === false ? null : (benchmarkBudgetRawSplit?.optionalFormula ?? ''),
|
||||
budgetFee: budgetFeeSplit?.total ?? null,
|
||||
budgetFeeBasic: budgetFeeSplit?.basic ?? null,
|
||||
budgetFeeOptional: budgetFeeSplit?.optional ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export const recomputeScaleDetailRowsInPlace = <TRow extends ScaleDetailComputedRow>(
|
||||
rows: TRow[],
|
||||
mode: ScaleMode
|
||||
) => {
|
||||
for (const row of rows) {
|
||||
Object.assign(row, recomputeScaleDetailRow(row, mode))
|
||||
}
|
||||
}
|
||||
|
||||
export const isSameScaleDetailRow = (
|
||||
left: ScaleDetailComputedRow,
|
||||
right: ScaleDetailComputedRow,
|
||||
mode: ScaleMode
|
||||
) => {
|
||||
const isSameScaleValue = mode === 'cost'
|
||||
? isSameNullableNumber(left.amount, right.amount)
|
||||
: isSameNullableNumber(left.landArea, right.landArea)
|
||||
|
||||
return isSameScaleValue
|
||||
&& isSameNullableNumber(left.benchmarkBudget, right.benchmarkBudget)
|
||||
&& isSameNullableNumber(left.benchmarkBudgetBasic, right.benchmarkBudgetBasic)
|
||||
&& isSameNullableNumber(left.benchmarkBudgetOptional, right.benchmarkBudgetOptional)
|
||||
&& isSameNullableText(left.basicFormula, right.basicFormula)
|
||||
&& isSameNullableText(left.optionalFormula, right.optionalFormula)
|
||||
&& isSameNullableNumber(left.budgetFee, right.budgetFee)
|
||||
&& isSameNullableNumber(left.budgetFeeBasic, right.budgetFeeBasic)
|
||||
&& isSameNullableNumber(left.budgetFeeOptional, right.budgetFeeOptional)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export interface ScaleDictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
export interface ScaleDictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: ScaleDictLeaf[]
|
||||
}
|
||||
|
||||
type MajorLite = {
|
||||
code: string
|
||||
name: string
|
||||
hasCost?: boolean
|
||||
hasArea?: boolean
|
||||
}
|
||||
|
||||
export const buildScaleDetailDict = (
|
||||
entries: Array<[string, MajorLite]>,
|
||||
includeLeaf: (params: { id: string; item: MajorLite; hasCost: boolean; hasArea: boolean }) => boolean
|
||||
): ScaleDictGroup[] => {
|
||||
const groupMap = new Map<string, ScaleDictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
||||
|
||||
for (const [key, item] of entries) {
|
||||
const code = item.code
|
||||
const isGroup = !code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(code)) groupOrder.push(code)
|
||||
groupMap.set(code, {
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
const hasCost = item.hasCost !== false
|
||||
const hasArea = item.hasArea !== false
|
||||
if (!includeLeaf({ id: key, item, hasCost, hasArea })) continue
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
hasCost,
|
||||
hasArea
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is ScaleDictGroup => Boolean(group))
|
||||
}
|
||||
|
||||
export const buildScaleIdLabelMap = (detailDict: ScaleDictGroup[]) => {
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
return idLabelMap
|
||||
}
|
||||
|
||||
export const buildScaleRowsFromDict = <TRow>(options: {
|
||||
detailDict: ScaleDictGroup[]
|
||||
projectCount: number
|
||||
activeIndustryCode: string
|
||||
isMajorInIndustryScope: (groupId: string, industryCode: string) => boolean
|
||||
buildScopedRowId: (projectIndex: number, majorId: string) => string
|
||||
buildProjectGroupPathKey: (projectIndex: number) => string
|
||||
isMutipleService: boolean
|
||||
createRow: (params: {
|
||||
projectIndex: number
|
||||
group: ScaleDictGroup
|
||||
child: ScaleDictLeaf
|
||||
rowId: string
|
||||
path: string[]
|
||||
}) => TRow
|
||||
}) => {
|
||||
if (!options.activeIndustryCode) return [] as TRow[]
|
||||
const rows: TRow[] = []
|
||||
for (let projectIndex = 1; projectIndex <= options.projectCount; projectIndex++) {
|
||||
for (const group of options.detailDict) {
|
||||
if (options.activeIndustryCode && !options.isMajorInIndustryScope(group.id, options.activeIndustryCode)) continue
|
||||
for (const child of group.children) {
|
||||
const rowId = options.buildScopedRowId(projectIndex, child.id)
|
||||
rows.push(options.createRow({
|
||||
projectIndex,
|
||||
group,
|
||||
child,
|
||||
rowId,
|
||||
path: options.isMutipleService
|
||||
? [options.buildProjectGroupPathKey(projectIndex), group.id, rowId]
|
||||
: [group.id, rowId]
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { getBasicFeeFromScale } from '@/sql'
|
||||
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
|
||||
type ScaleMode = 'cost' | 'area'
|
||||
|
||||
export interface ScaleFeeSplitResult {
|
||||
basic: number
|
||||
optional: number
|
||||
total: number
|
||||
basicFormula: string
|
||||
optionalFormula: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据“规模值 + 规模类型”查表,得到基准预算拆分结果。
|
||||
*
|
||||
* 这个方法只负责“规模法基准预算”这一层:
|
||||
* 1. 按投资规模或用地规模命中费率表
|
||||
* 2. 返回基准预算的基本部分 / 附加部分 / 合计
|
||||
* 3. 同时返回对应的计算式文本,供页面展示
|
||||
*
|
||||
* 它不参与任何咨询分类系数、专业系数、阶段系数的计算。
|
||||
*/
|
||||
export const getBenchmarkBudgetSplitByScale = (
|
||||
value: unknown,
|
||||
mode: ScaleMode
|
||||
): ScaleFeeSplitResult | null => {
|
||||
const scaleValue = toFiniteNumberOrNull(value)
|
||||
|
||||
const result = getBasicFeeFromScale(scaleValue, mode)
|
||||
|
||||
if (!result) return null
|
||||
|
||||
const basic = roundTo(result.basic, 2)
|
||||
const optional = roundTo(result.optional, 2)
|
||||
const basicFormula = typeof result.basicFormula === 'string' ? result.basicFormula : ''
|
||||
const optionalFormula = typeof result.optionalFormula === 'string' ? result.optionalFormula : ''
|
||||
return {
|
||||
basic,
|
||||
optional,
|
||||
total: roundTo(addNumbers(basic, optional), 2),
|
||||
basicFormula,
|
||||
optionalFormula
|
||||
}
|
||||
}
|
||||
|
||||
export const getBenchmarkBudgetByScale = (value: unknown, mode: ScaleMode) => {
|
||||
const splitResult = getBenchmarkBudgetSplitByScale(value, mode)
|
||||
return splitResult ? splitResult.total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据“已算出的基准预算拆分 + 各类系数”计算最终服务预算。
|
||||
*
|
||||
* 这个方法负责“乘系数”这一层:
|
||||
* 1. 输入的是已经拆好的基准预算 basic / optional
|
||||
* 2. 再乘咨询分类系数、专业系数、阶段系数、服务预算构成比率与数量比
|
||||
* 3. 返回服务费的基本部分 / 附加部分 / 合计
|
||||
*
|
||||
* 和 `getBenchmarkBudgetSplitByScale` 的区别是:
|
||||
* - `getBenchmarkBudgetSplitByScale` 解决“规模值对应多少基准预算”
|
||||
* - `getScaleBudgetFeeSplit` 解决“基准预算乘完系数后服务费是多少”
|
||||
*
|
||||
* 这里不返回计算式文本,所以 `basicFormula / optionalFormula` 固定为空字符串。
|
||||
*/
|
||||
export const getScaleBudgetFeeSplit = (params: {
|
||||
benchmarkBudgetBasic: unknown
|
||||
benchmarkBudgetOptional: unknown
|
||||
majorFactor: unknown
|
||||
consultCategoryFactor: unknown
|
||||
workStageFactor?: unknown
|
||||
workRatio?: unknown
|
||||
}): ScaleFeeSplitResult | null => {
|
||||
const hasWorkStageFactor = Object.prototype.hasOwnProperty.call(params, 'workStageFactor')
|
||||
const hasWorkRatio = Object.prototype.hasOwnProperty.call(params, 'workRatio')
|
||||
const benchmarkBudgetBasic = toFiniteNumberOrNull(params.benchmarkBudgetBasic)
|
||||
const benchmarkBudgetOptional = toFiniteNumberOrNull(params.benchmarkBudgetOptional)
|
||||
const majorFactor = toFiniteNumberOrNull(params.majorFactor)
|
||||
const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor)
|
||||
const workStageFactor = hasWorkStageFactor ? toFiniteNumberOrNull(params.workStageFactor) : 1
|
||||
const workRatio = hasWorkRatio ? toFiniteNumberOrNull(params.workRatio) : 1
|
||||
|
||||
if (
|
||||
benchmarkBudgetBasic == null ||
|
||||
benchmarkBudgetOptional == null ||
|
||||
majorFactor == null ||
|
||||
consultCategoryFactor == null ||
|
||||
workStageFactor == null ||
|
||||
workRatio == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const multiplier = toDecimal(consultCategoryFactor)
|
||||
.mul(majorFactor)
|
||||
.mul(workStageFactor)
|
||||
.mul(workRatio)
|
||||
const roundedBenchmarkBudget = roundTo(addNumbers(benchmarkBudgetBasic, benchmarkBudgetOptional), 2)
|
||||
const basic = roundTo(toDecimal(benchmarkBudgetBasic).mul(multiplier), 2)
|
||||
const optional = roundTo(toDecimal(benchmarkBudgetOptional).mul(multiplier), 2)
|
||||
return {
|
||||
basic,
|
||||
optional,
|
||||
total: roundTo(toDecimal(roundedBenchmarkBudget).mul(multiplier), 2),
|
||||
basicFormula: '',
|
||||
optionalFormula: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const getScaleBudgetFee = (params: {
|
||||
benchmarkBudget: unknown
|
||||
majorFactor: unknown
|
||||
consultCategoryFactor: unknown
|
||||
workStageFactor?: unknown
|
||||
workRatio?: unknown
|
||||
}) => {
|
||||
const hasWorkStageFactor = Object.prototype.hasOwnProperty.call(params, 'workStageFactor')
|
||||
const hasWorkRatio = Object.prototype.hasOwnProperty.call(params, 'workRatio')
|
||||
const benchmarkBudget = toFiniteNumberOrNull(params.benchmarkBudget)
|
||||
const majorFactor = toFiniteNumberOrNull(params.majorFactor)
|
||||
const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor)
|
||||
const workStageFactor = hasWorkStageFactor ? toFiniteNumberOrNull(params.workStageFactor) : 1
|
||||
const workRatio = hasWorkRatio ? toFiniteNumberOrNull(params.workRatio) : 1
|
||||
|
||||
if (
|
||||
benchmarkBudget == null ||
|
||||
majorFactor == null ||
|
||||
consultCategoryFactor == null ||
|
||||
workStageFactor == null ||
|
||||
workRatio == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roundedBenchmarkBudget = roundTo(benchmarkBudget, 2)
|
||||
const multiplier = toDecimal(consultCategoryFactor)
|
||||
.mul(majorFactor)
|
||||
.mul(workStageFactor)
|
||||
.mul(workRatio)
|
||||
return roundTo(toDecimal(roundedBenchmarkBudget).mul(multiplier), 2)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import type { GridApi } from 'ag-grid-community'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export type ScaleBudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
|
||||
export type ScaleBudgetHeaderCheckState = 'all' | 'none' | 'partial'
|
||||
|
||||
type BudgetCheckRow = {
|
||||
id: string
|
||||
benchmarkBudgetBasicChecked: boolean
|
||||
benchmarkBudgetOptionalChecked: boolean
|
||||
benchmarkBudgetBasic?: number | null
|
||||
benchmarkBudgetOptional?: number | null
|
||||
}
|
||||
|
||||
export interface ScaleBudgetToggleHeaderParams {
|
||||
displayName?: string
|
||||
field: ScaleBudgetCheckField
|
||||
getHeaderCheckState?: (field: ScaleBudgetCheckField) => ScaleBudgetHeaderCheckState
|
||||
onToggleAll?: (field: ScaleBudgetCheckField, checked: boolean) => void
|
||||
}
|
||||
|
||||
export class ScaleBudgetToggleHeader {
|
||||
private params!: ScaleBudgetToggleHeaderParams
|
||||
private eGui!: HTMLDivElement
|
||||
private checkbox!: HTMLInputElement
|
||||
private label!: HTMLSpanElement
|
||||
|
||||
init(params: ScaleBudgetToggleHeaderParams) {
|
||||
this.params = params
|
||||
const root = document.createElement('div')
|
||||
root.style.display = 'inline-flex'
|
||||
root.style.alignItems = 'center'
|
||||
root.style.gap = '6px'
|
||||
root.style.width = '100%'
|
||||
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.className = 'cursor-pointer'
|
||||
checkbox.addEventListener('click', event => event.stopPropagation())
|
||||
checkbox.addEventListener('mousedown', event => event.stopPropagation())
|
||||
checkbox.addEventListener('change', event => {
|
||||
event.stopPropagation()
|
||||
this.params.onToggleAll?.(this.params.field, checkbox.checked)
|
||||
})
|
||||
|
||||
const label = document.createElement('span')
|
||||
label.textContent = String(params.displayName || '')
|
||||
label.style.userSelect = 'none'
|
||||
label.addEventListener('click', event => event.stopPropagation())
|
||||
|
||||
root.append(checkbox, label)
|
||||
this.eGui = root
|
||||
this.checkbox = checkbox
|
||||
this.label = label
|
||||
this.applyCheckState()
|
||||
}
|
||||
|
||||
getGui() {
|
||||
return this.eGui
|
||||
}
|
||||
|
||||
refresh(params: ScaleBudgetToggleHeaderParams) {
|
||||
this.params = params
|
||||
this.label.textContent = String(params.displayName || '')
|
||||
this.applyCheckState()
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// noop
|
||||
}
|
||||
|
||||
private applyCheckState() {
|
||||
const state = this.params.getHeaderCheckState?.(this.params.field) || 'none'
|
||||
this.checkbox.indeterminate = state === 'partial'
|
||||
this.checkbox.checked = state === 'all'
|
||||
}
|
||||
}
|
||||
|
||||
export const formatScaleEditableNumber = (params: any, precision = 3, emptyText = '请输入') => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return emptyText
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, precision)
|
||||
}
|
||||
|
||||
export const formatScaleEditableConditionalNumber = (
|
||||
params: any,
|
||||
options: { enabled: boolean; precision?: number; emptyText?: string }
|
||||
) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !options.enabled) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return options.emptyText ?? '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, options.precision ?? 3)
|
||||
}
|
||||
|
||||
export const formatScaleReadonlyMoney = (params: any) => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(roundTo(params.value, 3), 3)
|
||||
}
|
||||
|
||||
export const updateScaleBudgetCheckState = <TRow extends BudgetCheckRow>(
|
||||
rows: TRow[],
|
||||
rowId: string,
|
||||
checkField: ScaleBudgetCheckField,
|
||||
checked: boolean
|
||||
) => {
|
||||
for (const row of rows) {
|
||||
if (row.id !== rowId) continue
|
||||
if (checkField === 'benchmarkBudgetBasicChecked') {
|
||||
row.benchmarkBudgetBasicChecked = checked
|
||||
row.benchmarkBudgetBasic = checked ? row.benchmarkBudgetBasic : 0
|
||||
return
|
||||
}
|
||||
row.benchmarkBudgetOptionalChecked = checked
|
||||
row.benchmarkBudgetOptional = checked ? row.benchmarkBudgetOptional : 0
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const createScaleBudgetCellRendererWithCheck = <TRow extends Record<string, any>>(
|
||||
checkField: ScaleBudgetCheckField,
|
||||
options: {
|
||||
formatValue: (params: any) => string
|
||||
onToggle: (row: TRow, checked: boolean) => void
|
||||
}
|
||||
) => (params: any) => {
|
||||
const valueText = options.formatValue(params)
|
||||
const hasValue = params.value != null && params.value !== ''
|
||||
if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
|
||||
return valueText
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.display = 'flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
wrapper.style.justifyContent = 'space-between'
|
||||
wrapper.style.gap = '6px'
|
||||
wrapper.style.width = '100%'
|
||||
wrapper.addEventListener('pointerdown', event => event.stopPropagation())
|
||||
wrapper.addEventListener('mousedown', event => event.stopPropagation())
|
||||
wrapper.addEventListener('click', event => event.stopPropagation())
|
||||
wrapper.addEventListener('dblclick', event => event.stopPropagation())
|
||||
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.className = 'cursor-pointer'
|
||||
checkbox.checked = params.data[checkField] !== false
|
||||
checkbox.addEventListener('pointerdown', event => event.stopPropagation())
|
||||
checkbox.addEventListener('mousedown', event => event.stopPropagation())
|
||||
checkbox.addEventListener('click', event => event.stopPropagation())
|
||||
checkbox.addEventListener('change', event => {
|
||||
event.stopPropagation()
|
||||
const targetRow = params.data as TRow | undefined
|
||||
if (!targetRow) return
|
||||
options.onToggle(targetRow, checkbox.checked)
|
||||
void nextTick(() => {
|
||||
params.api?.redrawRows?.({
|
||||
rowNodes: params.node ? [params.node] : undefined
|
||||
})
|
||||
params.api?.refreshCells?.({
|
||||
rowNodes: params.node ? [params.node] : undefined,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const valueSpan = document.createElement('span')
|
||||
valueSpan.textContent = valueText
|
||||
valueSpan.addEventListener('pointerdown', event => event.stopPropagation())
|
||||
valueSpan.addEventListener('mousedown', event => event.stopPropagation())
|
||||
valueSpan.addEventListener('click', event => event.stopPropagation())
|
||||
wrapper.append(checkbox, valueSpan)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
export const createScaleBudgetCellRendererToggleFactory = <TRow extends BudgetCheckRow>(
|
||||
getRows: () => TRow[],
|
||||
onAfterToggle: () => void
|
||||
) => (checkField: ScaleBudgetCheckField) =>
|
||||
createScaleBudgetCellRendererWithCheck<TRow>(checkField, {
|
||||
formatValue: formatScaleReadonlyMoney,
|
||||
onToggle: (targetRow: TRow, checked: boolean) => {
|
||||
updateScaleBudgetCheckState(getRows(), targetRow.id, checkField, checked)
|
||||
onAfterToggle()
|
||||
}
|
||||
})
|
||||
|
||||
export const getScaleMergeColSpanBeforeTotal = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned) return 1
|
||||
const displayedColumns = params.api?.getAllDisplayedColumns?.()
|
||||
if (!Array.isArray(displayedColumns) || !params.column) return 1
|
||||
const currentIndex = displayedColumns.findIndex((column: any) => column.getColId() === params.column.getColId())
|
||||
const totalIndex = displayedColumns.findIndex((column: any) => column.getColId() === 'budgetFeeTotal')
|
||||
if (currentIndex < 0 || totalIndex <= currentIndex) return 1
|
||||
return totalIndex - currentIndex
|
||||
}
|
||||
|
||||
export const refreshScaleGridAfterColumnReset = async <TRow>(gridApi: GridApi<TRow> | null | undefined) => {
|
||||
await nextTick()
|
||||
gridApi?.refreshHeader()
|
||||
gridApi?.refreshCells({ force: true })
|
||||
}
|
||||
|
||||
export const restoreScaleColumnDefaults = async <TRow>(options: {
|
||||
gridApi: GridApi<TRow> | null | undefined
|
||||
rows: TRow[]
|
||||
getCurrentValue: (row: TRow) => number | null | undefined
|
||||
getNextValue: (row: TRow) => number | null | undefined
|
||||
isSameValue: (left: number | null | undefined, right: number | null | undefined) => boolean
|
||||
applyValue: (row: TRow, nextValue: number | null) => void
|
||||
afterApply: () => Promise<void>
|
||||
}) => {
|
||||
options.gridApi?.stopEditing()
|
||||
let changed = false
|
||||
for (const row of options.rows) {
|
||||
const nextValue = options.getNextValue(row) ?? null
|
||||
if (options.isSameValue(options.getCurrentValue(row), nextValue)) continue
|
||||
options.applyValue(row, nextValue)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return false
|
||||
await options.afterApply()
|
||||
await refreshScaleGridAfterColumnReset(options.gridApi)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getMajorIdAliasMap } from '@/sql'
|
||||
|
||||
const majorIdAliasMap = getMajorIdAliasMap()
|
||||
|
||||
type ScaleLinkRow = {
|
||||
id?: unknown
|
||||
projectIndex?: unknown
|
||||
majorDictId?: unknown
|
||||
path?: unknown
|
||||
}
|
||||
|
||||
type ScaleValueLinkRow = ScaleLinkRow & {
|
||||
amount?: unknown
|
||||
landArea?: unknown
|
||||
isGroupRow?: unknown
|
||||
}
|
||||
|
||||
const normalizeProjectCount = (value: unknown) => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
return Math.max(1, Math.floor(parsed))
|
||||
}
|
||||
|
||||
export const parseProjectIndexFromPathKey = (value: string) => {
|
||||
const match = /^project-(\d+)$/.exec(value)
|
||||
if (!match) return null
|
||||
return normalizeProjectCount(Number(match[1]))
|
||||
}
|
||||
|
||||
export const parseScopedRowId = (id: unknown) => {
|
||||
const rawId = String(id || '')
|
||||
const match = /^(\d+)::(.+)$/.exec(rawId)
|
||||
if (!match) {
|
||||
return {
|
||||
projectIndex: 1,
|
||||
majorDictId: rawId
|
||||
}
|
||||
}
|
||||
return {
|
||||
projectIndex: normalizeProjectCount(Number(match[1])),
|
||||
majorDictId: String(match[2] || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveScaleRowProjectIndex = (row: ScaleLinkRow | undefined) => {
|
||||
if (!row) return 1
|
||||
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
|
||||
return normalizeProjectCount(row.projectIndex)
|
||||
}
|
||||
if (Array.isArray(row.path) && row.path.length > 0) {
|
||||
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
|
||||
if (projectIndexFromPath != null) return projectIndexFromPath
|
||||
}
|
||||
return parseScopedRowId(row.id).projectIndex
|
||||
}
|
||||
|
||||
export const resolveScaleRowMajorDictId = (row: ScaleLinkRow | undefined) => {
|
||||
if (!row) return ''
|
||||
const direct = String(row.majorDictId || '').trim()
|
||||
if (direct) return majorIdAliasMap.get(direct) || direct
|
||||
const parsed = parseScopedRowId(row.id).majorDictId
|
||||
return majorIdAliasMap.get(parsed) || parsed
|
||||
}
|
||||
|
||||
export const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
|
||||
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
|
||||
|
||||
export const buildContractScaleMap = <TRow extends ScaleLinkRow>(rows: TRow[] | undefined) => {
|
||||
const map = new Map<string, TRow>()
|
||||
for (const row of rows || []) {
|
||||
const majorDictId = resolveScaleRowMajorDictId(row)
|
||||
if (!majorDictId) continue
|
||||
const projectIndex = resolveScaleRowProjectIndex(row)
|
||||
map.set(makeProjectMajorKey(projectIndex, majorDictId), row)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const buildContractScaleIdMap = <TRow extends ScaleLinkRow>(rows: TRow[] | undefined) => {
|
||||
const map = new Map<string, TRow>()
|
||||
for (const row of rows || []) {
|
||||
const rowId = String(row?.id || '').trim()
|
||||
if (!rowId) continue
|
||||
map.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.get(rowId)
|
||||
if (aliasId && !map.has(aliasId)) {
|
||||
map.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const buildContractScaleProjectTotals = <TRow extends ScaleValueLinkRow>(
|
||||
rows: TRow[] | undefined,
|
||||
totalAmount?: unknown
|
||||
) => {
|
||||
const map = new Map<number, { amount: number | null; landArea: number | null }>()
|
||||
|
||||
const normalizedTotalAmount =
|
||||
typeof totalAmount === 'number' && Number.isFinite(totalAmount)
|
||||
? totalAmount
|
||||
: null
|
||||
|
||||
if (normalizedTotalAmount != null) {
|
||||
map.set(1, {
|
||||
amount: normalizedTotalAmount,
|
||||
landArea: null
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (row?.isGroupRow !== true) continue
|
||||
const projectIndex = resolveScaleRowProjectIndex(row)
|
||||
const current = map.get(projectIndex) || { amount: null, landArea: null }
|
||||
const nextAmount =
|
||||
current.amount != null
|
||||
? current.amount
|
||||
: (typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null)
|
||||
const nextLandArea =
|
||||
current.landArea != null
|
||||
? current.landArea
|
||||
: (typeof row?.landArea === 'number' && Number.isFinite(row.landArea) ? row.landArea : null)
|
||||
map.set(projectIndex, {
|
||||
amount: nextAmount,
|
||||
landArea: nextLandArea
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export const getContractScaleProjectTotalsByRow = (
|
||||
row: ScaleLinkRow | undefined,
|
||||
totalsMap: Map<number, { amount: number | null; landArea: number | null }>
|
||||
) => {
|
||||
const projectIndex = resolveScaleRowProjectIndex(row)
|
||||
return totalsMap.get(projectIndex)
|
||||
|| (projectIndex > 1 ? totalsMap.get(1) : undefined)
|
||||
|| { amount: null, landArea: null }
|
||||
}
|
||||
|
||||
export const getContractScaleRowByMajor = <TRow extends ScaleLinkRow>(
|
||||
row: ScaleLinkRow,
|
||||
map: Map<string, TRow>,
|
||||
idMap?: Map<string, TRow>
|
||||
) => {
|
||||
const directRowId = String(row.id || '').trim()
|
||||
if (directRowId && idMap?.has(directRowId)) return idMap.get(directRowId)
|
||||
const parsedMajorId = parseScopedRowId(row.id).majorDictId
|
||||
if (parsedMajorId && idMap?.has(parsedMajorId)) return idMap.get(parsedMajorId)
|
||||
const majorDictId = resolveScaleRowMajorDictId(row)
|
||||
if (!majorDictId) return undefined
|
||||
const projectIndex = resolveScaleRowProjectIndex(row)
|
||||
return map.get(makeProjectMajorKey(projectIndex, majorDictId))
|
||||
|| (projectIndex > 1 ? map.get(makeProjectMajorKey(1, majorDictId)) : undefined)
|
||||
}
|
||||
|
||||
export const normalizeChangedScaleRowIds = (rowIds?: Array<string | number>) =>
|
||||
new Set(
|
||||
(rowIds || [])
|
||||
.map(id => {
|
||||
const rawId = String(id || '').trim()
|
||||
if (!rawId) return rawId
|
||||
const parsedMajorId = parseScopedRowId(rawId).majorDictId
|
||||
return majorIdAliasMap.get(parsedMajorId) || parsedMajorId
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
type StoredPricingState<TRow> = {
|
||||
detailRows?: TRow[]
|
||||
projectCount?: unknown
|
||||
} | null | undefined
|
||||
|
||||
type AsyncVoid = () => Promise<void> | void
|
||||
|
||||
type ScalePaneIndustryOptions = {
|
||||
readIndustryCode: () => Promise<string>
|
||||
setIndustryCode: (code: string) => void
|
||||
}
|
||||
|
||||
const refreshIndustryCode = async (options?: ScalePaneIndustryOptions) => {
|
||||
if (!options) return
|
||||
const industryCode = await options.readIndustryCode()
|
||||
options.setIndustryCode(industryCode)
|
||||
}
|
||||
|
||||
export const loadPricingScalePaneRows = async <TRow>(options: {
|
||||
industry?: ScalePaneIndustryOptions
|
||||
setProjectCount: (count: number) => void
|
||||
ensureFactorDefaultsLoaded: AsyncVoid
|
||||
shouldForceDefaultLoad: () => boolean
|
||||
buildContractDefaultRows: (targetProjectCount: number) => Promise<TRow[]>
|
||||
loadStoredState: () => Promise<StoredPricingState<TRow>>
|
||||
isMutipleService: boolean
|
||||
normalizeProjectCount: (value: unknown) => number
|
||||
inferProjectCountFromRows: (rows: TRow[]) => number
|
||||
buildRowsFromStoredState: (rows: TRow[]) => TRow[]
|
||||
buildEmptyRows: (targetProjectCount: number) => TRow[]
|
||||
getTargetProjectCount: () => number
|
||||
applyRows: (rows: TRow[]) => void
|
||||
afterApplyRows: AsyncVoid
|
||||
onError?: (error: unknown) => void
|
||||
}) => {
|
||||
try {
|
||||
await refreshIndustryCode(options.industry)
|
||||
options.setProjectCount(1)
|
||||
|
||||
await options.ensureFactorDefaultsLoaded()
|
||||
|
||||
if (options.shouldForceDefaultLoad()) {
|
||||
options.applyRows(await options.buildContractDefaultRows(options.getTargetProjectCount()))
|
||||
await options.afterApplyRows()
|
||||
return
|
||||
}
|
||||
|
||||
const data = await options.loadStoredState()
|
||||
if (data) {
|
||||
const storedRows = Array.isArray(data.detailRows) ? data.detailRows : []
|
||||
if (options.isMutipleService) {
|
||||
const storedProjectCount = options.normalizeProjectCount(data.projectCount)
|
||||
options.setProjectCount(storedProjectCount || options.inferProjectCountFromRows(storedRows))
|
||||
}
|
||||
options.applyRows(options.buildRowsFromStoredState(storedRows))
|
||||
await options.afterApplyRows()
|
||||
return
|
||||
}
|
||||
|
||||
options.applyRows(await options.buildContractDefaultRows(options.getTargetProjectCount()))
|
||||
await options.afterApplyRows()
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
options.applyRows(options.buildEmptyRows(options.getTargetProjectCount()))
|
||||
await options.afterApplyRows()
|
||||
}
|
||||
}
|
||||
|
||||
export const importPricingScalePaneRows = async <TRow>(options: {
|
||||
industry?: ScalePaneIndustryOptions
|
||||
getTargetProjectCount: () => number
|
||||
buildContractDefaultRows: (targetProjectCount: number) => Promise<TRow[]>
|
||||
applyRows: (rows: TRow[]) => void
|
||||
saveRows: AsyncVoid
|
||||
onError?: (error: unknown) => void
|
||||
}) => {
|
||||
try {
|
||||
await refreshIndustryCode(options.industry)
|
||||
options.applyRows(await options.buildContractDefaultRows(options.getTargetProjectCount()))
|
||||
await options.saveRows()
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPricingScalePaneRows = async <TRow>(options: {
|
||||
getTargetProjectCount: () => number
|
||||
buildEmptyRows: (targetProjectCount: number) => TRow[]
|
||||
applyRows: (rows: TRow[]) => void
|
||||
saveRows: AsyncVoid
|
||||
onError?: (error: unknown) => void
|
||||
}) => {
|
||||
try {
|
||||
options.applyRows(options.buildEmptyRows(options.getTargetProjectCount()))
|
||||
await options.saveRows()
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const applyPricingScaleProjectCountChange = async <TRow>(options: {
|
||||
nextValue: unknown
|
||||
setProjectCount: (count: number) => void
|
||||
isMutipleService: boolean
|
||||
currentRows: TRow[]
|
||||
cloneRows: (rows: TRow[]) => TRow[]
|
||||
normalizeProjectCount: (value: unknown) => number
|
||||
inferProjectCountFromRows: (rows: TRow[]) => number
|
||||
buildRowsForReducedCount: (rows: TRow[], targetProjectCount: number) => TRow[]
|
||||
buildRowsFromImportDefaultSource: (targetProjectCount: number) => Promise<TRow[]>
|
||||
getRowKey: (row: Partial<TRow> | undefined) => string
|
||||
getRowProjectIndex: (row: Partial<TRow>) => number
|
||||
mergeExistingRow: (defaultRow: TRow, existingRow: TRow) => TRow
|
||||
applyRows: (rows: TRow[]) => void
|
||||
afterApplyRows: AsyncVoid
|
||||
}) => {
|
||||
const normalized = options.normalizeProjectCount(options.nextValue)
|
||||
options.setProjectCount(normalized)
|
||||
if (!options.isMutipleService) return
|
||||
|
||||
const previousRows = options.cloneRows(options.currentRows)
|
||||
const previousProjectCount = options.inferProjectCountFromRows(previousRows)
|
||||
if (normalized === previousProjectCount) return
|
||||
|
||||
if (normalized < previousProjectCount) {
|
||||
options.applyRows(options.buildRowsForReducedCount(previousRows, normalized))
|
||||
await options.afterApplyRows()
|
||||
return
|
||||
}
|
||||
|
||||
const defaultRows = await options.buildRowsFromImportDefaultSource(normalized)
|
||||
const existingMap = new Map<string, TRow>()
|
||||
for (const row of previousRows) {
|
||||
const key = options.getRowKey(row)
|
||||
if (!key) continue
|
||||
existingMap.set(key, row)
|
||||
}
|
||||
|
||||
options.applyRows(defaultRows.map(defaultRow => {
|
||||
const key = options.getRowKey(defaultRow)
|
||||
const existingRow = key ? existingMap.get(key) : undefined
|
||||
if (!existingRow) return defaultRow
|
||||
if (options.getRowProjectIndex(existingRow) > previousProjectCount) return defaultRow
|
||||
return options.mergeExistingRow(defaultRow, existingRow)
|
||||
}))
|
||||
await options.afterApplyRows()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { GridApi } from 'ag-grid-community'
|
||||
import { onActivated, onBeforeUnmount, onMounted, watch, type Ref, type WatchSource } from 'vue'
|
||||
|
||||
type AsyncTask = () => Promise<void> | void
|
||||
|
||||
export const usePricingPaneLifecycle = <TRow>(options: {
|
||||
gridApi: Ref<GridApi<TRow> | null>
|
||||
loadFromIndexedDB: () => Promise<void>
|
||||
syncLinkedFields: () => Promise<void>
|
||||
linkedSourceSignature: WatchSource<unknown>
|
||||
linkedSecondarySignature?: WatchSource<unknown>
|
||||
syncSecondaryLinkedFields?: AsyncTask
|
||||
saveToIndexedDB: AsyncTask
|
||||
}) => {
|
||||
const hydratePane = async () => {
|
||||
await options.loadFromIndexedDB()
|
||||
await options.syncLinkedFields()
|
||||
}
|
||||
|
||||
let skipNextActivated = false
|
||||
|
||||
onMounted(async () => {
|
||||
skipNextActivated = true
|
||||
await hydratePane()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (skipNextActivated) {
|
||||
skipNextActivated = false
|
||||
return
|
||||
}
|
||||
await hydratePane()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
options.gridApi.value?.stopEditing()
|
||||
options.gridApi.value = null
|
||||
void options.saveToIndexedDB()
|
||||
})
|
||||
|
||||
watch(options.linkedSourceSignature, () => {
|
||||
void options.syncLinkedFields()
|
||||
})
|
||||
|
||||
if (options.linkedSecondarySignature && options.syncSecondaryLinkedFields) {
|
||||
watch(options.linkedSecondarySignature, () => {
|
||||
void options.syncSecondaryLinkedFields?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const usePricingScalePaneLifecycle = usePricingPaneLifecycle
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user