commit 6fdf336b9e214c3a996a2c783c9e09fbb1d5e5d7
Author: wintsa <770775984@qq.com>
Date: Tue Apr 7 16:27:49 2026 +0800
1
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..22909d0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+**
+!dist/
+!dist/**
+!Dockerfile.dist
+!docker/
+!docker/dist-server/
+!docker/dist-server/**
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c3e478b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+*.exe
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..a7cea0b
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["Vue.volar"]
+}
diff --git a/Dockerfile.dist b/Dockerfile.dist
new file mode 100644
index 0000000..4236d28
--- /dev/null
+++ b/Dockerfile.dist
@@ -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"]
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..b51b06c
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,587 @@
+{
+ "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-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",
+ "vuedraggable": "^4.1.0",
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/node": "^24.10.1",
+ "@vitejs/plugin-vue": "^6.0.2",
+ "@vue/tsconfig": "^0.8.1",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "~5.9.3",
+ "vite": "^8.0.0-beta.13",
+ "vue-tsc": "^3.1.5",
+ },
+ },
+ },
+ "packages": {
+ "@ag-grid-community/locale": ["@ag-grid-community/locale@35.1.0", "", {}, "sha512-Tez1imtqfipMT3O1Ay+dyDcFJIj6H6gXBp45s44pwkzWQzxO20IBpZUrmAPTNRMYVZNXqCVbNsozWrPaVFAgeQ=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "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/types": ["@babel/types@7.29.0", "", { "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/runtime": ["@emnapi/runtime@1.8.1", "", { "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=="],
+
+ "@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "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=="],
+
+ "@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "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/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
+
+ "@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=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
+
+ "@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=="],
+
+ "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
+
+ "@iconify/vue": ["@iconify/vue@5.0.0", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3" } }, "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg=="],
+
+ "@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
+
+ "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
+
+ "@intlify/core-base": ["@intlify/core-base@11.3.1", "", { "dependencies": { "@intlify/devtools-types": "11.3.1", "@intlify/message-compiler": "11.3.1", "@intlify/shared": "11.3.1" } }, "sha512-9nG3ItSD5ApZHmTbv2UFqvJSy3m+u6C/orMohukNKoT/Yuwiz8tPtlNw6ylLuPqSP2kP7ZF4Cdqwp6V1m3BQgw=="],
+
+ "@intlify/devtools-types": ["@intlify/devtools-types@11.3.1", "", { "dependencies": { "@intlify/core-base": "11.3.1", "@intlify/shared": "11.3.1" } }, "sha512-qrOknIx294W4YYVYBgldDOeOnv2NlpabW+aYGjMuXMSrY36f7GCAPlEBE2G+qTC5x0oAWDBSY5BmvLlPUx1exg=="],
+
+ "@intlify/message-compiler": ["@intlify/message-compiler@11.3.1", "", { "dependencies": { "@intlify/shared": "11.3.1", "source-map-js": "^1.0.2" } }, "sha512-uIa4YurbphU+4Cl5CoL6nq/c7uQhVNRowEelgboNmXNs+UEcyFLQBESwaUjMvdtYxzA2qh+vGim080KZ84ruDA=="],
+
+ "@intlify/shared": ["@intlify/shared@11.3.1", "", {}, "sha512-9GWc5PKuRdeWkT7FJN43c/+rD6xpSB3WtizewkfFCK/0XzYqCk4gQBWWcTdfKo8ylEcHwqYsR2Z3HRE3XhEHrQ=="],
+
+ "@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=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@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=="],
+
+ "@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=="],
+
+ "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
+
+ "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
+
+ "@oxc-project/runtime": ["@oxc-project/runtime@0.114.0", "", {}, "sha512-mVGQvr/uFJGQ3hsvgQ1sJfh79t5owyZZZtw+VaH+WhtvsmtgjT6imznB9sz2Q67Q0/4obM9mOOtQscU4aJteSg=="],
+
+ "@oxc-project/types": ["@oxc-project/types@0.114.0", "", {}, "sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA=="],
+
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zCEmUrt1bggwgBgeKLxNj217J1OrChrp3jJt24VK9jAharSTeVaHODNL+LpcQVhRz+FktYWfT9cjo5oZ99ZLpg=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZP9xb9lPAex36pvkNWCjSEJW/Gfdm9I3ssiqOFLmpZ/vosPXgpoGxCmh+dX1Qs+/bWQE6toNFXWWL8vYoKoK9Q=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IdrPunf6dp9mywMgTOKMMGDnMHQ6+h5gRl6LW8rhD8WK2kXX0IwzcM5Zc0B5J7xQs8QWOlKjv8BJsU/1CD3pg=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-o/JCk+dL0IN68EBhZ4DqfsfvxPfMeoM6cJtxORC1YYoxGHZyth2Kb2maXDb4oddw2wu8iIbnYXYPEzBtAF5CAg=="],
+
+ "@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=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-KSol1De1spMZL+Xg7K5IBWXIvRWv7+pveaxFWXpezezAG7CS6ojzRjtCGCiLxQricutTAi/LkNWKMsd2wNhMKQ=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-WFljyDkxtXRlWxMjxeegf7xMYXxUr8u7JdXlOEWKYgDqEgxUnSEsVDxBiNWQ1D5kQKwf8Wo4sVKEYPRhCdsjwA=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.5", "", { "os": "linux", "cpu": "x64" }, "sha512-CUlplTujmbDWp2gamvrqVKi2Or8lmngXT1WxsizJfts7JrvfGhZObciaY/+CbdbS9qNnskvwMZNEhTPrn7b+WA=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.5", "", { "os": "linux", "cpu": "x64" }, "sha512-wdf7g9NbVZCeAo2iGhsjJb7I8ZFfs6X8bumfrWg82VK+8P6AlLXwk48a1ASiJQDTS7Svq2xVzZg3sGO2aXpHRA=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.5", "", { "os": "none", "cpu": "arm64" }, "sha512-0CWY7ubu12nhzz+tkpHjoG3IRSTlWYe0wrfJRf4qqjqQSGtAYgoL9kwzdvlhaFdZ5ffVeyYw9qLsChcjUMEloQ=="],
+
+ "@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-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-jUct1XVeGtyjqJXEAfvdFa8xoigYZ2rge7nYEm70ppQxpfH9ze2fbIrpHmP2tNM2vL/F6Dd0CpXhpjPbC6bSxQ=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.5", "", { "os": "win32", "cpu": "x64" }, "sha512-VQ8F9ld5gw29epjnVGdrx8ugiLTe8BMqmhDYy7nGbdeDo4HAt4bgdZvLbViEhg7DZyHLpiEUlO5/jPSUrIuxRQ=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
+
+ "@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
+
+ "@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=="],
+
+ "@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=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A=="],
+
+ "@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=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw=="],
+
+ "@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-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA=="],
+
+ "@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/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=="],
+
+ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="],
+
+ "@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=="],
+
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
+
+ "@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
+
+ "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
+
+ "@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=="],
+
+ "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
+
+ "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
+
+ "@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=="],
+
+ "@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=="],
+
+ "@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=="],
+
+ "@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=="],
+
+ "@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=="],
+
+ "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
+
+ "@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=="],
+
+ "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
+
+ "@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=="],
+
+ "@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="],
+
+ "@vue/runtime-core": ["@vue/runtime-core@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="],
+
+ "@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/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/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="],
+
+ "@vue/tsconfig": ["@vue/tsconfig@0.8.1", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g=="],
+
+ "@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=="],
+
+ "@vueuse/metadata": ["@vueuse/metadata@14.2.1", "", {}, "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw=="],
+
+ "@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
+
+ "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=="],
+
+ "ag-charts-core": ["ag-charts-core@13.1.0", "", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-mLHJZ8oU5CPeLRURescdISCtMsiiA/m4d1iBr6aQBEgiTVogRMGpFpsYNtQiYtoW2sRh+62I9sN8fhC3JQjX/g=="],
+
+ "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=="],
+
+ "ag-charts-locale": ["ag-charts-locale@13.1.0", "", {}, "sha512-mPgJnVsOI4Cf17CAlRh8BvLz19e165sdQJeUXNaB7M+DPB+pxODOcfx4oqZlR4Wc8Zu++TGb/2ueHa/aeV2qeQ=="],
+
+ "ag-charts-types": ["ag-charts-types@13.1.0", "", {}, "sha512-DytRM3CXli+Y013SC1Mr8lQBrhVTACK+11ilDHOhwUM0sRpmGuR51XFGcBKOliW1Vas1AycP31Cm3Pp0jx3hqw=="],
+
+ "ag-grid-community": ["ag-grid-community@35.1.0", "", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-yWFQfRNjv3KUBkHHzFdDOYGjPcDMU0B8Up4qG651diFlGRUGEGVs94SK73niWvk1FDZdpV9oWrwq3f30/qAoVg=="],
+
+ "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=="],
+
+ "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=="],
+
+ "alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="],
+
+ "archiver": ["archiver@5.3.2", "", { "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=="],
+
+ "archiver-utils": ["archiver-utils@2.1.0", "", { "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=="],
+
+ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
+ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
+
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
+
+ "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
+
+ "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
+
+ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
+
+ "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
+
+ "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
+ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
+
+ "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="],
+
+ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
+
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
+
+ "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "compress-commons": ["compress-commons@4.1.2", "", { "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=="],
+
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
+
+ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
+
+ "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
+
+ "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
+
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
+ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
+
+ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
+
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
+
+ "exceljs": ["exceljs@4.4.0", "", { "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=="],
+
+ "fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "framer-motion": ["framer-motion@12.34.3", "", { "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=="],
+
+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
+
+ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="],
+
+ "glob": ["glob@7.2.3", "", { "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=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
+
+ "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
+
+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
+ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
+
+ "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
+
+ "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
+
+ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+
+ "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
+
+ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
+
+ "lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
+
+ "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=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
+
+ "listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="],
+
+ "localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
+
+ "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
+
+ "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
+
+ "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
+
+ "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="],
+
+ "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="],
+
+ "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
+
+ "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
+
+ "lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="],
+
+ "lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="],
+
+ "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
+
+ "lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="],
+
+ "lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="],
+
+ "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
+
+ "lucide-vue-next": ["lucide-vue-next@0.563.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-zsE/lCKtmaa7bGfhSpN84br1K9YoQ5pCN+2oKWjQQG3Lo6ufUUKBuHSjNFI6RvUevxaajNXb8XwFUKeTXG3sIA=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
+
+ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
+
+ "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
+
+ "motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
+
+ "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
+
+ "motion-v": ["motion-v@2.0.0", "", { "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", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
+
+ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
+
+ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
+
+ "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "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=="],
+
+ "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=="],
+
+ "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=="],
+
+ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
+
+ "readable-stream": ["readable-stream@3.6.2", "", { "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", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
+
+ "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=="],
+
+ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
+
+ "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
+
+ "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=="],
+
+ "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+
+ "saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="],
+
+ "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
+
+ "sortablejs": ["sortablejs@1.14.0", "", {}, "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
+
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
+ "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
+
+ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
+
+ "tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="],
+
+ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+
+ "tar-stream": ["tar-stream@2.2.0", "", { "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", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
+
+ "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+ "unzipper": ["unzipper@0.10.14", "", { "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", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+
+ "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=="],
+
+ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
+
+ "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=="],
+
+ "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=="],
+
+ "vue-i18n": ["vue-i18n@11.3.1", "", { "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-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=="],
+
+ "vuedraggable": ["vuedraggable@4.1.0", "", { "dependencies": { "sortablejs": "1.14.0" }, "peerDependencies": { "vue": "^3.0.1" } }, "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
+ "zip-stream": ["zip-stream@4.1.1", "", { "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", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
+
+ "@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
+
+ "@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=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
+
+ "@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=="],
+
+ "@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=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "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", "", { "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", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+
+ "jszip/lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
+
+ "jszip/readable-stream": ["readable-stream@2.3.8", "", { "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", "", { "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", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
+
+ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
+
+ "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "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", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
+
+ "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "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", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "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", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+ }
+}
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..09b9b8c
--- /dev/null
+++ b/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://shadcn-vue.com/schema.json",
+ "style": "new-york",
+ "typescript": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/style.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "composables": "@/composables"
+ },
+ "registries": {}
+}
diff --git a/docker/dist-server/go.mod b/docker/dist-server/go.mod
new file mode 100644
index 0000000..80bc90b
--- /dev/null
+++ b/docker/dist-server/go.mod
@@ -0,0 +1,3 @@
+module dist-server
+
+go 1.24
diff --git a/docker/dist-server/main.go b/docker/dist-server/main.go
new file mode 100644
index 0000000..adf127d
--- /dev/null
+++ b/docker/dist-server/main.go
@@ -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")
+ }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..332cdab
--- /dev/null
+++ b/index.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+ 联众咨询
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..efdf609
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2195 @@
+{
+ "name": "my-vue-app",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "my-vue-app",
+ "version": "0.0.0",
+ "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-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",
+ "vuedraggable": "^4.1.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/node": "^24.10.1",
+ "@vitejs/plugin-vue": "^6.0.2",
+ "@vue/tsconfig": "^0.8.1",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "~5.9.3",
+ "vite": "^8.0.0-beta.13",
+ "vue-tsc": "^3.1.5"
+ }
+ },
+ "node_modules/@ag-grid-community/locale": {
+ "version": "35.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@fast-csv/format": {
+ "version": "4.3.5",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@fast-csv/format/node_modules/@types/node": {
+ "version": "14.18.63",
+ "license": "MIT"
+ },
+ "node_modules/@fast-csv/parse": {
+ "version": "4.3.6",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@fast-csv/parse/node_modules/@types/node": {
+ "version": "14.18.63",
+ "license": "MIT"
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.4",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.5",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.4",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "license": "MIT"
+ },
+ "node_modules/@floating-ui/vue": {
+ "version": "1.1.10",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.5",
+ "@floating-ui/utils": "^0.2.10",
+ "vue-demi": ">=0.13.0"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "license": "MIT"
+ },
+ "node_modules/@iconify/vue": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@iconify/types": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyberalien"
+ },
+ "peerDependencies": {
+ "vue": ">=3"
+ }
+ },
+ "node_modules/@internationalized/date": {
+ "version": "3.12.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/@internationalized/number": {
+ "version": "3.6.5",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/@intlify/core-base": {
+ "version": "11.3.0",
+ "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz",
+ "integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/devtools-types": "11.3.0",
+ "@intlify/message-compiler": "11.3.0",
+ "@intlify/shared": "11.3.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ }
+ },
+ "node_modules/@intlify/devtools-types": {
+ "version": "11.3.0",
+ "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz",
+ "integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/core-base": "11.3.0",
+ "@intlify/shared": "11.3.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ }
+ },
+ "node_modules/@intlify/message-compiler": {
+ "version": "11.3.0",
+ "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz",
+ "integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/shared": "11.3.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ }
+ },
+ "node_modules/@intlify/shared": {
+ "version": "11.3.0",
+ "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz",
+ "integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@noble/ciphers": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
+ "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
+ "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@oxc-project/runtime": {
+ "version": "0.114.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.114.0",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.5",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.18",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.0",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "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"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.0",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.2.0",
+ "@tailwindcss/oxide": "4.2.0",
+ "tailwindcss": "4.2.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.18",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/vue-virtual": {
+ "version": "3.13.18",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "vue": "^2.7.0 || ^3.0.0"
+ }
+ },
+ "node_modules/@types/bun": {
+ "version": "1.3.11",
+ "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.11.tgz",
+ "integrity": "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bun-types": "1.3.11"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.10.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.2"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.28",
+ "@vue/shared": "3.5.28"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.28",
+ "@vue/shared": "3.5.28"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "7.7.9",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-kit": "^7.7.9"
+ }
+ },
+ "node_modules/@vue/devtools-kit": {
+ "version": "7.7.9",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@vue/devtools-shared": {
+ "version": "7.7.9",
+ "license": "MIT",
+ "dependencies": {
+ "rfdc": "^1.4.1"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "3.2.5",
+ "dev": true,
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.28"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.28",
+ "@vue/shared": "3.5.28"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.28",
+ "@vue/runtime-core": "3.5.28",
+ "@vue/shared": "3.5.28",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.28",
+ "@vue/shared": "3.5.28"
+ },
+ "peerDependencies": {
+ "vue": "3.5.28"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.28",
+ "license": "MIT"
+ },
+ "node_modules/@vue/tsconfig": {
+ "version": "0.8.1",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "5.x",
+ "vue": "^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vueuse/core": {
+ "version": "14.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "14.2.1",
+ "@vueuse/shared": "14.2.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "14.2.1",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "14.2.1",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/ag-charts-community": {
+ "version": "13.1.0",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ag-charts-core": "13.1.0",
+ "ag-charts-locale": "13.1.0",
+ "ag-charts-types": "13.1.0"
+ }
+ },
+ "node_modules/ag-charts-core": {
+ "version": "13.1.0",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ag-charts-types": "13.1.0"
+ }
+ },
+ "node_modules/ag-charts-enterprise": {
+ "version": "13.1.0",
+ "license": "Commercial",
+ "optional": true,
+ "dependencies": {
+ "ag-charts-community": "13.1.0",
+ "ag-charts-core": "13.1.0"
+ }
+ },
+ "node_modules/ag-charts-locale": {
+ "version": "13.1.0",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/ag-charts-types": {
+ "version": "13.1.0",
+ "license": "MIT"
+ },
+ "node_modules/ag-grid-community": {
+ "version": "35.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "ag-charts-types": "13.1.0"
+ }
+ },
+ "node_modules/ag-grid-enterprise": {
+ "version": "35.1.0",
+ "license": "Commercial",
+ "dependencies": {
+ "ag-grid-community": "35.1.0"
+ },
+ "optionalDependencies": {
+ "ag-charts-community": "13.1.0",
+ "ag-charts-enterprise": "13.1.0"
+ }
+ },
+ "node_modules/ag-grid-vue3": {
+ "version": "35.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "ag-grid-community": "35.1.0"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/alien-signals": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/archiver": {
+ "version": "5.3.2",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/archiver-utils": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/archiver-utils/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/archiver-utils/node_modules/readable-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/big-integer": {
+ "version": "1.6.52",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/binary": {
+ "version": "0.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "buffers": "~0.1.1",
+ "chainsaw": "~0.1.0"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "2.9.0",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/bluebird": {
+ "version": "3.4.7",
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/buffer-indexof-polyfill": {
+ "version": "1.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/buffers": {
+ "version": "0.1.1",
+ "engines": {
+ "node": ">=0.2.0"
+ }
+ },
+ "node_modules/bun-types": {
+ "version": "1.3.11",
+ "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz",
+ "integrity": "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/chainsaw": {
+ "version": "0.1.0",
+ "license": "MIT/X11",
+ "dependencies": {
+ "traverse": ">=0.3.0 <0.4"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/compress-commons": {
+ "version": "4.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "^0.2.13",
+ "crc32-stream": "^4.0.2",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "license": "MIT"
+ },
+ "node_modules/copy-anything": {
+ "version": "4.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "is-what": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/crc32-stream": {
+ "version": "4.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "crc-32": "^1.2.0",
+ "readable-stream": "^3.4.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "license": "MIT"
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.19",
+ "license": "MIT"
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "license": "MIT"
+ },
+ "node_modules/defu": {
+ "version": "6.1.4",
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/duplexer2": {
+ "version": "0.1.4",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "node_modules/duplexer2/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/duplexer2/node_modules/readable-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.19.0",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "license": "MIT"
+ },
+ "node_modules/exceljs": {
+ "version": "4.4.0",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">=8.3.0"
+ }
+ },
+ "node_modules/fast-csv": {
+ "version": "4.3.6",
+ "license": "MIT",
+ "dependencies": {
+ "@fast-csv/format": "4.3.5",
+ "@fast-csv/parse": "4.3.6"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.34.3",
+ "license": "MIT",
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "license": "ISC"
+ },
+ "node_modules/fstream": {
+ "version": "1.0.12",
+ "license": "ISC",
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "inherits": "~2.0.0",
+ "mkdirp": ">=0.5 0",
+ "rimraf": "2"
+ },
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/fstream/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "license": "ISC",
+ "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"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.5",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "license": "ISC"
+ },
+ "node_modules/hey-listen": {
+ "version": "1.0.8",
+ "license": "MIT"
+ },
+ "node_modules/hookable": {
+ "version": "5.5.3",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "license": "MIT"
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "license": "ISC"
+ },
+ "node_modules/is-what": {
+ "version": "5.5.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/lie": {
+ "version": "3.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/jszip/node_modules/readable-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/lazystream": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.6.3"
+ }
+ },
+ "node_modules/lazystream/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/lazystream/node_modules/readable-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.31.1",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "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"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.31.1",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/listenercount": {
+ "version": "1.0.1",
+ "license": "ISC"
+ },
+ "node_modules/localforage": {
+ "version": "1.10.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lie": "3.1.1"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "license": "MIT"
+ },
+ "node_modules/lodash.difference": {
+ "version": "4.5.0",
+ "license": "MIT"
+ },
+ "node_modules/lodash.escaperegexp": {
+ "version": "4.1.2",
+ "license": "MIT"
+ },
+ "node_modules/lodash.flatten": {
+ "version": "4.4.0",
+ "license": "MIT"
+ },
+ "node_modules/lodash.groupby": {
+ "version": "4.6.0",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isfunction": {
+ "version": "3.0.9",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnil": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isundefined": {
+ "version": "3.0.1",
+ "license": "MIT"
+ },
+ "node_modules/lodash.union": {
+ "version": "4.6.0",
+ "license": "MIT"
+ },
+ "node_modules/lodash.uniq": {
+ "version": "4.5.0",
+ "license": "MIT"
+ },
+ "node_modules/lucide-vue-next": {
+ "version": "0.563.0",
+ "license": "ISC",
+ "peerDependencies": {
+ "vue": ">=3.0.1"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "5.1.9",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "license": "MIT"
+ },
+ "node_modules/motion-dom": {
+ "version": "12.34.3",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.29.2"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.29.2",
+ "license": "MIT"
+ },
+ "node_modules/motion-v": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pinia": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^7.7.7"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.5.0",
+ "vue": "^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pinia-plugin-persistedstate": {
+ "version": "4.7.1",
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": ">=3.0.0",
+ "@pinia/nuxt": ">=0.10.0",
+ "pinia": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "@pinia/nuxt": {
+ "optional": true
+ },
+ "pinia": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "license": "MIT"
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdir-glob": {
+ "version": "1.1.3",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.1.0"
+ }
+ },
+ "node_modules/reka-ui": {
+ "version": "2.8.2",
+ "license": "MIT",
+ "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"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/zernonia"
+ },
+ "peerDependencies": {
+ "vue": ">= 3.2.0"
+ }
+ },
+ "node_modules/reka-ui/node_modules/@internationalized/date": {
+ "version": "3.11.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "license": "MIT"
+ },
+ "node_modules/rimraf": {
+ "version": "2.7.1",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.114.0",
+ "@rolldown/pluginutils": "1.0.0-rc.5"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "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"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "5.0.1",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "license": "MIT"
+ },
+ "node_modules/sortablejs": {
+ "version": "1.14.0",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/speakingurl": {
+ "version": "14.0.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/superjson": {
+ "version": "2.2.6",
+ "license": "MIT",
+ "dependencies": {
+ "copy-anything": "^4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.5.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.2.0",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tmp": {
+ "version": "0.2.5",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/traverse": {
+ "version": "0.3.9",
+ "license": "MIT/X11"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "license": "0BSD"
+ },
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unzipper": {
+ "version": "0.10.14",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/unzipper/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/unzipper/node_modules/readable-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "license": "MIT"
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.0-beta.15",
+ "dev": true,
+ "license": "MIT",
+ "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"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.28",
+ "license": "MIT",
+ "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": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-demi": {
+ "version": "0.14.10",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-i18n": {
+ "version": "11.3.0",
+ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
+ "integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/core-base": "11.3.0",
+ "@intlify/devtools-types": "11.3.0",
+ "@intlify/shared": "11.3.0",
+ "@vue/devtools-api": "^6.5.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/vue-i18n/node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/vue-tsc": {
+ "version": "3.2.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.28",
+ "@vue/language-core": "3.2.5"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/vuedraggable": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "sortablejs": "1.14.0"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "license": "ISC"
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "license": "MIT"
+ },
+ "node_modules/zip-stream": {
+ "version": "4.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "archiver-utils": "^3.0.4",
+ "compress-commons": "^4.1.2",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/zip-stream/node_modules/archiver-utils": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..41ac945
--- /dev/null
+++ b/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "my-vue-app",
+ "private": true,
+ "version": "0.0.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",
+ "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-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",
+ "vuedraggable": "^4.1.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/node": "^24.10.1",
+ "@vitejs/plugin-vue": "^6.0.2",
+ "@vue/tsconfig": "^0.8.1",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "~5.9.3",
+ "vite": "^8.0.0-beta.13",
+ "vue-tsc": "^3.1.5"
+ }
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..0b5169c
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/logo.jpg b/public/logo.jpg
new file mode 100644
index 0000000..0b5169c
Binary files /dev/null and b/public/logo.jpg differ
diff --git a/public/template202603.xlsx b/public/template202603.xlsx
new file mode 100644
index 0000000..dcd9c5c
Binary files /dev/null and b/public/template202603.xlsx differ
diff --git a/scripts/package-dist-image.ps1 b/scripts/package-dist-image.ps1
new file mode 100644
index 0000000..f808926
--- /dev/null
+++ b/scripts/package-dist-image.ps1
@@ -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}"
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 0000000..b1f618d
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
{{ t('app.projectConflict.title') }}
+
+ {{ t('app.projectConflict.desc', { name: currentProjectName }) }}
+
+
+ {{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/vue.svg b/src/assets/vue.svg
new file mode 100644
index 0000000..770e9d3
--- /dev/null
+++ b/src/assets/vue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/ui/button/Button.vue b/src/components/ui/button/Button.vue
new file mode 100644
index 0000000..374320b
--- /dev/null
+++ b/src/components/ui/button/Button.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts
new file mode 100644
index 0000000..b1eb116
--- /dev/null
+++ b/src/components/ui/button/index.ts
@@ -0,0 +1,38 @@
+import type { VariantProps } from "class-variance-authority"
+import { cva } from "class-variance-authority"
+
+export { default as Button } from "./Button.vue"
+
+export const buttonVariants = cva(
+ "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ "default": "h-9 px-4 py-2 has-[>svg]:px-3",
+ "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ "lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
+ "icon": "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+)
+export type ButtonVariants = VariantProps
diff --git a/src/components/ui/card/Card.vue b/src/components/ui/card/Card.vue
new file mode 100644
index 0000000..f5a0707
--- /dev/null
+++ b/src/components/ui/card/Card.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/card/CardAction.vue b/src/components/ui/card/CardAction.vue
new file mode 100644
index 0000000..c91638b
--- /dev/null
+++ b/src/components/ui/card/CardAction.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/card/CardContent.vue b/src/components/ui/card/CardContent.vue
new file mode 100644
index 0000000..dfbc552
--- /dev/null
+++ b/src/components/ui/card/CardContent.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/card/CardDescription.vue b/src/components/ui/card/CardDescription.vue
new file mode 100644
index 0000000..71c1b8d
--- /dev/null
+++ b/src/components/ui/card/CardDescription.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/card/CardFooter.vue b/src/components/ui/card/CardFooter.vue
new file mode 100644
index 0000000..9e3739e
--- /dev/null
+++ b/src/components/ui/card/CardFooter.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/card/CardHeader.vue b/src/components/ui/card/CardHeader.vue
new file mode 100644
index 0000000..4fe4da4
--- /dev/null
+++ b/src/components/ui/card/CardHeader.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/card/CardTitle.vue b/src/components/ui/card/CardTitle.vue
new file mode 100644
index 0000000..5f479e7
--- /dev/null
+++ b/src/components/ui/card/CardTitle.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/card/index.ts b/src/components/ui/card/index.ts
new file mode 100644
index 0000000..1627758
--- /dev/null
+++ b/src/components/ui/card/index.ts
@@ -0,0 +1,7 @@
+export { default as Card } from "./Card.vue"
+export { default as CardAction } from "./CardAction.vue"
+export { default as CardContent } from "./CardContent.vue"
+export { default as CardDescription } from "./CardDescription.vue"
+export { default as CardFooter } from "./CardFooter.vue"
+export { default as CardHeader } from "./CardHeader.vue"
+export { default as CardTitle } from "./CardTitle.vue"
diff --git a/src/components/ui/scroll-area/ScrollArea.vue b/src/components/ui/scroll-area/ScrollArea.vue
new file mode 100644
index 0000000..781da99
--- /dev/null
+++ b/src/components/ui/scroll-area/ScrollArea.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/scroll-area/ScrollBar.vue b/src/components/ui/scroll-area/ScrollBar.vue
new file mode 100644
index 0000000..a0b6f9b
--- /dev/null
+++ b/src/components/ui/scroll-area/ScrollBar.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/scroll-area/index.ts b/src/components/ui/scroll-area/index.ts
new file mode 100644
index 0000000..c416759
--- /dev/null
+++ b/src/components/ui/scroll-area/index.ts
@@ -0,0 +1,2 @@
+export { default as ScrollArea } from "./ScrollArea.vue"
+export { default as ScrollBar } from "./ScrollBar.vue"
diff --git a/src/components/ui/tooltip/TooltipContent.vue b/src/components/ui/tooltip/TooltipContent.vue
new file mode 100644
index 0000000..1533d3f
--- /dev/null
+++ b/src/components/ui/tooltip/TooltipContent.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/tooltip/index.ts b/src/components/ui/tooltip/index.ts
new file mode 100644
index 0000000..173a7dc
--- /dev/null
+++ b/src/components/ui/tooltip/index.ts
@@ -0,0 +1,2 @@
+export { default as TooltipContent } from './TooltipContent.vue'
+export { TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
diff --git a/src/features/ht/components/Ht.vue b/src/features/ht/components/Ht.vue
new file mode 100644
index 0000000..61ed8e5
--- /dev/null
+++ b/src/features/ht/components/Ht.vue
@@ -0,0 +1,1615 @@
+
+
+
+
+
+
+
+
+
+
{{ t('ht.title') }}
+
+ {{ t('ht.projectTotalBudget', { amount: contractBudgetLoading ? t('ht.budgetLoading') : formatBudgetAmount(projectTotalBudget) }) }}
+
+
+
+
+ {{ t('ht.selectedCount', { count: selectedContractCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('ht.searchingHint', { filtered: filteredContracts.length, total: contracts.length }) }}
+
+
+ {{ selectionMode === 'export' ? t('ht.selectModeExportHint') : t('ht.selectModeDeleteHint') }}
+
+
+ {{ t('ht.setupRequiredHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ element.name }}
+
+
+ ID: {{ element.id }}
+
+
+ {{ t('ht.contractBudget', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
+
+
+ {{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
+
+
+
+
+
+
+
+
+ {{ t('ht.dragSort') }}
+
+
+
+
+
+ {{ t('ht.edit') }}
+
+
+
+
+
+ {{ t('ht.remove') }}
+
+
+
+
+
{{ t('ht.idLabel', { id: element.id }) }}
+
{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
+
{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
+
+
+
+
+
+
{{ t('ht.emptyTitle') }}
+
{{ t('ht.emptyDesc') }}
+
+
+
+
+
+
+ {{ element.name }}
+
+
+ ID: {{ element.id }}
+
+
+ {{ t('ht.contractBudget', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
+
+
+ {{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('ht.dragSortSearchOff') }}
+
+
+
+
+
+ {{ t('ht.edit') }}
+
+
+
+
+
+ {{ t('ht.remove') }}
+
+
+
+
+
{{ t('ht.idLabel', { id: element.id }) }}
+
{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
+
{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
+
+
+
+ {{ t('ht.notFound') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('ht.backToTop') }}
+
+
+
+
+
+
+ {{ editingContractId ? t('ht.editContract') : t('ht.createContract') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('ht.deleteSingleTitle') }}
+
+ {{ t('ht.deleteSingleDesc', { name: pendingDeleteContractName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('ht.deleteBatchTitle') }}
+
+ {{ t('ht.deleteBatchDesc', { count: batchDeleteCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ messageDialogTitle }}
+
+ {{ messageDialogDesc }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ toastTitle }}
+ {{ toastText }}
+
+
+ {{ t('tab.dialog.iKnow') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/features/ht/components/HtAdditionalWorkFee.vue b/src/features/ht/components/HtAdditionalWorkFee.vue
new file mode 100644
index 0000000..bcde35f
--- /dev/null
+++ b/src/features/ht/components/HtAdditionalWorkFee.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/src/features/ht/components/HtBaseInfo.vue b/src/features/ht/components/HtBaseInfo.vue
new file mode 100644
index 0000000..8455b68
--- /dev/null
+++ b/src/features/ht/components/HtBaseInfo.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
{{ t('htBaseInfo.title') }}
+
+
+
+
+
+
+
+
diff --git a/src/features/ht/components/HtConsultCategoryFactor.vue b/src/features/ht/components/HtConsultCategoryFactor.vue
new file mode 100644
index 0000000..babbcce
--- /dev/null
+++ b/src/features/ht/components/HtConsultCategoryFactor.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
diff --git a/src/features/ht/components/HtContractSummary.vue b/src/features/ht/components/HtContractSummary.vue
new file mode 100644
index 0000000..8677012
--- /dev/null
+++ b/src/features/ht/components/HtContractSummary.vue
@@ -0,0 +1,522 @@
+
+
+
+
+
+
+
+ {{ t('htSummary.title') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/ht/components/HtFeeRateMethodForm.vue b/src/features/ht/components/HtFeeRateMethodForm.vue
new file mode 100644
index 0000000..b508ec4
--- /dev/null
+++ b/src/features/ht/components/HtFeeRateMethodForm.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/ht/components/HtMajorFactor.vue b/src/features/ht/components/HtMajorFactor.vue
new file mode 100644
index 0000000..01ca1f7
--- /dev/null
+++ b/src/features/ht/components/HtMajorFactor.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
diff --git a/src/features/ht/components/HtReserveFee.vue b/src/features/ht/components/HtReserveFee.vue
new file mode 100644
index 0000000..a427b34
--- /dev/null
+++ b/src/features/ht/components/HtReserveFee.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/src/features/ht/components/htCard.vue b/src/features/ht/components/htCard.vue
new file mode 100644
index 0000000..3da05d1
--- /dev/null
+++ b/src/features/ht/components/htCard.vue
@@ -0,0 +1,361 @@
+
+
+
+
+
+
+
diff --git a/src/features/ht/components/htInfo.vue b/src/features/ht/components/htInfo.vue
new file mode 100644
index 0000000..e414ed0
--- /dev/null
+++ b/src/features/ht/components/htInfo.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/src/features/ht/components/zxFw.vue b/src/features/ht/components/zxFw.vue
new file mode 100644
index 0000000..2c1a7b8
--- /dev/null
+++ b/src/features/ht/components/zxFw.vue
@@ -0,0 +1,1429 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('htZxFw.title') }}
+
+
+
+
{{ t('htZxFw.warning') }}
+
+
+
+
+
+
+
+
+
+ {{ t('htZxFw.dialog.resetTitle') }}
+
+ {{ t('htZxFw.dialog.resetDesc', { name: pendingClearServiceName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('htZxFw.dialog.deleteTitle') }}
+
+ {{ t('htZxFw.dialog.deleteDesc', { name: pendingDeleteServiceName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/ht/contracts.ts b/src/features/ht/contracts.ts
new file mode 100644
index 0000000..1bd6483
--- /dev/null
+++ b/src/features/ht/contracts.ts
@@ -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
+ 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,
+ matcher: (entryKey: string, contractId: string) => boolean
+) => {
+ for (const contractId of contractIds) {
+ if (matcher(key, contractId)) return true
+ }
+ return false
+}
diff --git a/src/features/ht/ht.css b/src/features/ht/ht.css
new file mode 100644
index 0000000..f232653
--- /dev/null
+++ b/src/features/ht/ht.css
@@ -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;
+ }
+}
diff --git a/src/features/ht/importExport.ts b/src/features/ht/importExport.ts
new file mode 100644
index 0000000..acd01fd
--- /dev/null
+++ b/src/features/ht/importExport.ts
@@ -0,0 +1,172 @@
+import {
+ cloneJson,
+ isContractRelatedForageKey,
+ isContractRelatedKeyedStateKey,
+ isRecord,
+ SERVICE_PRICING_METHODS
+} from '@/lib/contractSegment'
+
+type AnyRecord = Record
+
+export interface KvStoreLike {
+ keys: () => Promise
+ getItem: (key: string) => Promise
+}
+
+export interface ZxFwPricingStoreLike {
+ contracts: Record
+ servicePricingStates: Record
+ htFeeMainStates: Record
+ htFeeMethodStates: Record
+ keyedStates: Record
+ loadContract: (...args: any[]) => Promise
+ getContractState: (...args: any[]) => unknown
+ setContractState: (...args: any[]) => Promise
+ 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
+) => {
+ 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,
+ 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)
+ }))
+}
diff --git a/src/features/ht/types.ts b/src/features/ht/types.ts
new file mode 100644
index 0000000..aa2207f
--- /dev/null
+++ b/src/features/ht/types.ts
@@ -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[]
+}
diff --git a/src/features/pricing/components/HourlyPricingPane.vue b/src/features/pricing/components/HourlyPricingPane.vue
new file mode 100644
index 0000000..f7ba707
--- /dev/null
+++ b/src/features/pricing/components/HourlyPricingPane.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/src/features/pricing/components/InvestmentScalePricingPane.vue b/src/features/pricing/components/InvestmentScalePricingPane.vue
new file mode 100644
index 0000000..752b65d
--- /dev/null
+++ b/src/features/pricing/components/InvestmentScalePricingPane.vue
@@ -0,0 +1,1275 @@
+
+
+
+
+
+
+
+
+
+
{{ t('pricingPane.investment.title') }}
+
+ {{ t('pricingPane.projectCount') }}
+ void applyProjectCountChange(value)"
+ >
+ -
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('pricingPane.clearTitle') }}
+
+ {{ t('pricingPane.investment.clearDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('pricingPane.overrideTitle') }}
+
+ {{ t('pricingPane.investment.overrideDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/pricing/components/LandScalePricingPane.vue b/src/features/pricing/components/LandScalePricingPane.vue
new file mode 100644
index 0000000..15e910b
--- /dev/null
+++ b/src/features/pricing/components/LandScalePricingPane.vue
@@ -0,0 +1,1108 @@
+
+
+
+
+
+
+
+
+
+
{{ t('pricingPane.land.title') }}
+
+ {{ t('pricingPane.projectCount') }}
+ void applyProjectCountChange(value)"
+ >
+ -
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('pricingPane.clearTitle') }}
+
+ {{ t('pricingPane.land.clearDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('pricingPane.overrideTitle') }}
+
+ {{ t('pricingPane.land.overrideDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/pricing/components/ScaleFormulaReadonlyPane.vue b/src/features/pricing/components/ScaleFormulaReadonlyPane.vue
new file mode 100644
index 0000000..bd64301
--- /dev/null
+++ b/src/features/pricing/components/ScaleFormulaReadonlyPane.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
{{ methodLabel }}
+
{{ t('zxFwView.formulaColumns.subtitle') }}
+
+
+
+
+
+
diff --git a/src/features/pricing/components/WorkloadPricingPane.vue b/src/features/pricing/components/WorkloadPricingPane.vue
new file mode 100644
index 0000000..3ff5823
--- /dev/null
+++ b/src/features/pricing/components/WorkloadPricingPane.vue
@@ -0,0 +1,692 @@
+
+
+
+
+
+
+
+
+
{{ t('workloadPricing.title') }}
+
+
+
+
+
+
+
+
diff --git a/src/features/shared/components/HourlyFeeGrid.vue b/src/features/shared/components/HourlyFeeGrid.vue
new file mode 100644
index 0000000..8e3744d
--- /dev/null
+++ b/src/features/shared/components/HourlyFeeGrid.vue
@@ -0,0 +1,769 @@
+
+
+
+
+
+
+
{{ props.title || t('hourlyFeeGrid.title') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/features/shared/components/HtFeeGrid.vue b/src/features/shared/components/HtFeeGrid.vue
new file mode 100644
index 0000000..11b27a7
--- /dev/null
+++ b/src/features/shared/components/HtFeeGrid.vue
@@ -0,0 +1,546 @@
+
+
+
+
+
+
+
{{ title }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('htFeeDetail.dialog.deleteTitle') }}
+
+ {{ t('htFeeDetail.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/shared/components/HtFeeMethodGrid.vue b/src/features/shared/components/HtFeeMethodGrid.vue
new file mode 100644
index 0000000..780f336
--- /dev/null
+++ b/src/features/shared/components/HtFeeMethodGrid.vue
@@ -0,0 +1,769 @@
+
+
+
+
+
+
+
{{ title }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('htFeeGrid.dialog.clearTitle') }}
+
+ {{ t('htFeeGrid.dialog.clearDesc', { name: pendingClearRowName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/shared/components/MethodUnavailableNotice.vue b/src/features/shared/components/MethodUnavailableNotice.vue
new file mode 100644
index 0000000..1061632
--- /dev/null
+++ b/src/features/shared/components/MethodUnavailableNotice.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
{{ props.title || t('methodUnavailable.defaultTitle') }}
+
{{ props.message }}
+
+
+
diff --git a/src/features/shared/components/ServiceCheckboxSelector.vue b/src/features/shared/components/ServiceCheckboxSelector.vue
new file mode 100644
index 0000000..b1f054a
--- /dev/null
+++ b/src/features/shared/components/ServiceCheckboxSelector.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('serviceSelector.empty') }}
+
+
+
+
diff --git a/src/features/shared/components/WorkContentGrid.vue b/src/features/shared/components/WorkContentGrid.vue
new file mode 100644
index 0000000..dfc1df4
--- /dev/null
+++ b/src/features/shared/components/WorkContentGrid.vue
@@ -0,0 +1,857 @@
+
+
+
+
+
+
+
{{ props.title || t('workContent.title') }}
+
+
+
+
+
+
+
+ {{ t('workContent.dialog.deleteTitle') }}
+
+ {{ t('workContent.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/shared/components/XmFactorGrid.vue b/src/features/shared/components/XmFactorGrid.vue
new file mode 100644
index 0000000..ac04e4d
--- /dev/null
+++ b/src/features/shared/components/XmFactorGrid.vue
@@ -0,0 +1,498 @@
+
+
+
+
+
+
diff --git a/src/features/shared/components/xmCommonAgGrid.vue b/src/features/shared/components/xmCommonAgGrid.vue
new file mode 100644
index 0000000..01580f3
--- /dev/null
+++ b/src/features/shared/components/xmCommonAgGrid.vue
@@ -0,0 +1,806 @@
+
+
+
+
+
+
+
+
+
+ {{ t('xmScaleGrid.syncToastTitle') }}
+ {{ syncToastText }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/tab/importExport.ts b/src/features/tab/importExport.ts
new file mode 100644
index 0000000..24e57e6
--- /dev/null
+++ b/src/features/tab/importExport.ts
@@ -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
+export type ForageStore = Pick
+
+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 => {
+ 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
+ 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
+}
diff --git a/src/features/tab/tab.css b/src/features/tab/tab.css
new file mode 100644
index 0000000..21d0b41
--- /dev/null
+++ b/src/features/tab/tab.css
@@ -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);
+}
diff --git a/src/features/tab/types.ts b/src/features/tab/types.ts
new file mode 100644
index 0000000..51f61ef
--- /dev/null
+++ b/src/features/tab/types.ts
@@ -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 {
+ 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[]
+}
diff --git a/src/features/workbench/components/HomeEntryView.vue b/src/features/workbench/components/HomeEntryView.vue
new file mode 100644
index 0000000..e996629
--- /dev/null
+++ b/src/features/workbench/components/HomeEntryView.vue
@@ -0,0 +1,822 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('home.title') }}
+
{{ t('home.subtitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('home.cards.heroTitle') }}
+
{{ t('home.cards.heroSubTitle') }}
+
+
{{ t('home.cards.heroDesc') }}
+
+
+
+
+
+ {{ t('home.cards.projectBudget') }}
+
+ {{ t('home.cards.projectBudgetDesc') }}
+
+
+
+
+
+
{{ t('home.cards.enter') }}
+
+
+
+
+
+
+
+
+ {{ t('home.cards.quickCalc') }}
+
+ {{ t('home.cards.quickCalcDesc') }}
+
+
+
+
{{ t('home.cards.enter') }}
+
+
+
+
+
+
+
+ {{ t('home.cards.importData') }}
+
+ {{ t('home.cards.importDataDesc') }}
+
+
+
+
{{ t('home.cards.pickFile') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('home.dialog.chooseExistingProject') }}
+
{{ t('home.dialog.chooseExistingProjectDesc') }}
+
+
+
+
+
+
+ {{ t('home.dialog.noProjectYet') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('home.dialog.newProject') }}
+
{{ t('home.dialog.chooseIndustryDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('home.dialog.confirmImport') }}
+
+ {{ t('home.dialog.confirmImportDesc', { file: pendingHomeImportFileName || t('home.cards.pickFile') }) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/workbench/components/HtFeeMethodTypeLineView.vue b/src/features/workbench/components/HtFeeMethodTypeLineView.vue
new file mode 100644
index 0000000..9417509
--- /dev/null
+++ b/src/features/workbench/components/HtFeeMethodTypeLineView.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
diff --git a/src/features/workbench/components/QuickCalcWorkbenchView.vue b/src/features/workbench/components/QuickCalcWorkbenchView.vue
new file mode 100644
index 0000000..12f1b72
--- /dev/null
+++ b/src/features/workbench/components/QuickCalcWorkbenchView.vue
@@ -0,0 +1,1449 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('quickCalc.empty.selectIndustry') }}
+
+
+
+ {{ t('quickCalc.empty.selectConsult') }}
+
+
+
+ {{ t('quickCalc.empty.scaleUnavailable') }}
+
+
+
+ {{ t('quickCalc.empty.consultCostOnly') }}
+
+
+
+
+
+
{{ group.key === 'consult' ? t('quickCalc.consultCategory') : t('quickCalc.majorCategory') }}
+
{{ getGroupTitle(group) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/workbench/components/ZxFwView.vue b/src/features/workbench/components/ZxFwView.vue
new file mode 100644
index 0000000..c8e2357
--- /dev/null
+++ b/src/features/workbench/components/ZxFwView.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
diff --git a/src/features/xm/components/XmConsultCategoryFactor.vue b/src/features/xm/components/XmConsultCategoryFactor.vue
new file mode 100644
index 0000000..cc7a87f
--- /dev/null
+++ b/src/features/xm/components/XmConsultCategoryFactor.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
diff --git a/src/features/xm/components/XmMajorFactor.vue b/src/features/xm/components/XmMajorFactor.vue
new file mode 100644
index 0000000..c33440b
--- /dev/null
+++ b/src/features/xm/components/XmMajorFactor.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
diff --git a/src/features/xm/components/info.vue b/src/features/xm/components/info.vue
new file mode 100644
index 0000000..bc0b4b2
--- /dev/null
+++ b/src/features/xm/components/info.vue
@@ -0,0 +1,441 @@
+
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+ {{ t('xmInfo.createFromHomeFirst') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ INDUSTRY_HINT_TEXT }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ segment.part === 'literal' ? '-' : segment.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ day }}
+
+
+
+
+
+
+
+ {{ dateValue.day }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/features/xm/components/xmCard.vue b/src/features/xm/components/xmCard.vue
new file mode 100644
index 0000000..30d3944
--- /dev/null
+++ b/src/features/xm/components/xmCard.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/src/features/xm/components/xmInfo.vue b/src/features/xm/components/xmInfo.vue
new file mode 100644
index 0000000..2d7bd15
--- /dev/null
+++ b/src/features/xm/components/xmInfo.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/src/i18n/dictionary-en.ts b/src/i18n/dictionary-en.ts
new file mode 100644
index 0000000..1bfa73b
--- /dev/null
+++ b/src/i18n/dictionary-en.ts
@@ -0,0 +1,130 @@
+export const MAJOR_NAME_EN_BY_CODE: Record = {
+ 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 = {
+ 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 = {
+ '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 = {
+ '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'
+}
+
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..0328a92
--- /dev/null
+++ b/src/i18n/index.ts
@@ -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)
+ }
+}
+
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts
new file mode 100644
index 0000000..7a17e35
--- /dev/null
+++ b/src/i18n/locales/en-US.ts
@@ -0,0 +1,679 @@
+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: 'Open Default Project',
+ createAndOpen: 'Create and Open'
+ }
+ },
+ home: {
+ title: 'Calculation Entry',
+ subtitle: 'Project Budget · Quick Calc · Import Data',
+ projectCalcTab: 'Project Calculation',
+ quickCalcTab: 'Quick Calculation',
+ cards: {
+ heroTitle: 'One-Click Smart Budget',
+ heroSubTitle: 'Accelerate standards adoption',
+ heroDesc: 'Cost consulting fee calculator for transport construction projects',
+ 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',
+ enter: 'Enter',
+ pickFile: 'Choose File',
+ pickExisting: 'Choose Existing'
+ },
+ 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.'
+ }
+ },
+ 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'
+ },
+ 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: 'Subtotal',
+ 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: 'Subtotal',
+ 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',
+ 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: 'Subtotal',
+ 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: 'Work 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'
+ }
+ },
+ 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',
+ 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',
+ 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',
+ preparedBy: 'Enter preparer',
+ reviewedBy: 'Enter reviewer',
+ preparedCompany: 'Enter prepared company'
+ }
+ }
+} as const
diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts
new file mode 100644
index 0000000..fab738e
--- /dev/null
+++ b/src/i18n/locales/zh-CN.ts
@@ -0,0 +1,678 @@
+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: '计算入口',
+ subtitle: '项目计算 · 单项速算 · 导入数据',
+ projectCalcTab: '项目计算',
+ quickCalcTab: '快速计算',
+ cards: {
+ heroTitle: '智能预算一键生成',
+ heroSubTitle: '助力《规范》高效落地',
+ heroDesc: '交通建设项目工程造价咨询服务费计算',
+ projectBudget: '项目预算',
+ projectBudgetDesc: '适用于多合同段、项目级整体计算,支持导出/导入完整项目数据',
+ quickCalc: '单项速算',
+ quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果',
+ importData: '导入数据',
+ importDataDesc: '导入".zw"数据包,并自动新建一个项目用于恢复数据,不会覆盖现有项目',
+ enter: '进入计算',
+ pickFile: '选择文件',
+ pickExisting: '选择已有项目'
+ },
+ dialog: {
+ newProject: '新建项目',
+ chooseIndustryDesc: '选择工程行业后,直接进入项目计算页面。',
+ industry: '工程行业',
+ selectIndustry: '请选择工程行业',
+ entering: '进入中...',
+ enterProjectCalc: '进入项目计算',
+ confirmImport: '确认导入数据',
+ confirmImportDesc: '将导入“{file}”数据包',
+ confirmImportAction: '确认导入并新建项目',
+ chooseExistingProject: '选择已有项目',
+ chooseExistingProjectDesc: '从项目列表中选择一个项目并直接进入工作台。',
+ noProjectYet: '当前暂无可进入的项目,请先新建项目。'
+ }
+ },
+ 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: '合同规模明细'
+ },
+ 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: '选择服务',
+ 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: '点击右侧↻恢复本列默认专业系数'
+ }
+ },
+ pricingPane: {
+ projectCount: '项目数量',
+ clearTitle: '确认清空当前明细',
+ confirmClear: '确认清空',
+ useDefault: '使用默认数据',
+ overrideTitle: '确认覆盖当前明细',
+ confirmOverride: '确认覆盖',
+ investment: {
+ title: '投资规模明细',
+ 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: '工程行业提示',
+ createFromHomeFirst: '请从首页先新建项目后再进入此页面。',
+ fields: {
+ projectName: '项目名称',
+ projectIndustry: '工程行业',
+ overview: '项目概况',
+ preparedBy: '编制人',
+ reviewedBy: '复核人',
+ preparedCompany: '编制单位',
+ preparedDate: '编制日期',
+ desc: '其他说明'
+ },
+ placeholders: {
+ overview: '请输入项目概况',
+ preparedBy: '请输入编制人',
+ reviewedBy: '请输入复核人',
+ preparedCompany: '请输入编制单位'
+ }
+ }
+} as const
diff --git a/src/layout/tab.vue b/src/layout/tab.vue
new file mode 100644
index 0000000..9a1ee29
--- /dev/null
+++ b/src/layout/tab.vue
@@ -0,0 +1,2340 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tab.title }}
+
+
+ {{ tab.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ projectCountText }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tab.dialog.resetTitle') }}
+
+ {{ t('tab.dialog.resetDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tab.dialog.importOverrideTitle') }}
+
+ {{ t('tab.dialog.importOverrideDesc', { file: pendingImportFileName || t('home.cards.pickFile') }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tab.dialog.newProjectTitle') }}
+
+ {{ t('tab.dialog.newProjectDesc') }}
+
+
+
+
+
+
+ {{ newProjectIndustryLabel || t('home.dialog.selectIndustry') }}
+
+
+
+
+
+
+
+
+
+ {{ getIndustryDisplayName(item.id, locale) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tab.dialog.projectLimitTitle') }}
+
+ {{ t('tab.dialog.projectLimitDesc', { max: MAX_PROJECT_COUNT }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tab.dialog.deleteProjectTitle') }}
+
+ {{ pendingDeleteProject?.id === currentProjectId
+ ? t('tab.dialog.deleteCurrentProjectDesc', { name: pendingDeleteProject?.name || '' })
+ : t('tab.dialog.deleteProjectDesc', { name: pendingDeleteProject?.name || '' }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ messageDialogTitle }}
+
+ {{ messageDialogDesc }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('tab.guide.title') }} · {{ guideProgressText }}
+
{{ activeGuideStep.title }}
+
+
+
+
+
+
{{ activeGuideStep.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { if (!val) dismissReportToast() }"
+ >
+
+
+ {{ reportExportStatus === 'running' ? t('tab.toast.export') : (reportExportStatus === 'success' ? t('tab.toast.success') : t('tab.toast.failed')) }}
+
+
+
+ {{ reportExportText }}
+
+
+
+
{{ reportExportProgress }}%
+
+
+
+
+
+
+
+
diff --git a/src/layout/typeLine.vue b/src/layout/typeLine.vue
new file mode 100644
index 0000000..1b7a7db
--- /dev/null
+++ b/src/layout/typeLine.vue
@@ -0,0 +1,394 @@
+
+
+
+
+
+
+
+
+
+
+ {{ props.title }}
+
+
+ {{ props.title }}
+
+
+
+
+
+ {{ props.subtitle }}
+
+ {{ props.subtitle }}
+
+
+
+
+
+ {{ props.metaText }}
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (offset.y > h * 0.35 || velocity.y > 10) {
+ sheetOpen = false;
+ }
+ else {
+ animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
+ }
+ }">
+
+
+
+
+
+
+

+
{{ t('typeLine.aboutTitle') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('typeLine.aboutParagraph1') }}
+
+
+ {{ t('typeLine.aboutParagraph2') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/agGridReadonlyAutoHeight.ts b/src/lib/agGridReadonlyAutoHeight.ts
new file mode 100644
index 0000000..ed72fba
--- /dev/null
+++ b/src/lib/agGridReadonlyAutoHeight.ts
@@ -0,0 +1,165 @@
+import type { ColDef, ColGroupDef } from 'ag-grid-community'
+
+type AnyColDef = ColDef | ColGroupDef
+
+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 = (col: ColDef) => {
+ 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 = (col: ColDef, mustRightAlign: boolean): ColDef['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 = (
+ col: ColDef,
+ mustRightAlign: boolean
+): ColDef['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) ||
+ 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 = (col: ColDef): ColDef => {
+ 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 = (col: AnyColDef): AnyColDef => {
+ const maybeGroup = col as ColGroupDef
+ if (Array.isArray(maybeGroup.children)) {
+ return {
+ ...maybeGroup,
+ children: maybeGroup.children.map(child => enhanceColumn(child as AnyColDef))
+ }
+ }
+ return enhanceLeafColumn(col as ColDef)
+}
+
+export const withReadonlyAutoHeight = (
+ defs: Array>
+): Array> => defs.map(def => enhanceColumn(def))
+
diff --git a/src/lib/agGridResetHeader.ts b/src/lib/agGridResetHeader.ts
new file mode 100644
index 0000000..6420131
--- /dev/null
+++ b/src/lib/agGridResetHeader.ts
@@ -0,0 +1,80 @@
+import type { IHeaderComp, IHeaderParams } from 'ag-grid-community'
+import { i18n } from '@/i18n'
+
+export type ResetHeaderParams = IHeaderParams & {
+ onReset?: () => void | Promise
+ 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')
+ this.eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
+ this.eButton.style.visibility = params.onReset ? 'visible' : 'hidden'
+ return true
+ }
+
+ destroy() {
+ this.eButton?.removeEventListener('click', this.onButtonClick)
+ }
+}
diff --git a/src/lib/contractSegment.ts b/src/lib/contractSegment.ts
new file mode 100644
index 0000000..ae77678
--- /dev/null
+++ b/src/lib/contractSegment.ts
@@ -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
+ servicePricingStates?: Record
+ htFeeMainStates?: Record
+ htFeeMethodStates?: Record
+ }
+ }
+ piniaState?: {
+ zxFwPricing?: {
+ contracts?: Record
+ servicePricingStates?: Record
+ htFeeMainStates?: Record
+ htFeeMethodStates?: Record
+ }
+ }
+}
+
+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 =>
+ Boolean(value && typeof value === 'object' && !Array.isArray(value))
+
+export const cloneJson = (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 | 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) => {
+ let nextId = ''
+ while (!nextId || usedIds.has(nextId)) {
+ nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
+ }
+ usedIds.add(nextId)
+ return nextId
+}
diff --git a/src/lib/decimal.ts b/src/lib/decimal.ts
new file mode 100644
index 0000000..d625b46
--- /dev/null
+++ b/src/lib/decimal.ts
@@ -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) => {
+ 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 = (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
+ }
+}
diff --git a/src/lib/diyAgGridOptions.ts b/src/lib/diyAgGridOptions.ts
new file mode 100644
index 0000000..e4a7e55
--- /dev/null
+++ b/src/lib/diyAgGridOptions.ts
@@ -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)
+ }
+}
diff --git a/src/lib/number.ts b/src/lib/number.ts
new file mode 100644
index 0000000..96594fb
--- /dev/null
+++ b/src/lib/number.ts
@@ -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)
+}
diff --git a/src/lib/numberFormat.ts b/src/lib/numberFormat.ts
new file mode 100644
index 0000000..24092b8
--- /dev/null
+++ b/src/lib/numberFormat.ts
@@ -0,0 +1,42 @@
+import { parseNumberOrNull } from '@/lib/number'
+
+const fixedFormatterCache = new Map()
+const flexibleFormatterCache = new Map()
+
+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)
+}
diff --git a/src/lib/pricingHourlyCalc.ts b/src/lib/pricingHourlyCalc.ts
new file mode 100644
index 0000000..77bf4c8
--- /dev/null
+++ b/src/lib/pricingHourlyCalc.ts
@@ -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)
+ .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 & Pick> | undefined
+): HourlyDetailRow[] => {
+ const dbMap = new Map & Pick>()
+ 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)
+}
diff --git a/src/lib/pricingMethodTotals.ts b/src/lib/pricingMethodTotals.ts
new file mode 100644
index 0000000..cfd2304
--- /dev/null
+++ b/src/lib/pricingMethodTotals.ts
@@ -0,0 +1,1023 @@
+import {
+ expertList,
+ getMajorDictEntries,
+ getMajorIdAliasMap,
+ getServiceDictById,
+ taskList
+} from '@/sql'
+import { roundTo, sumByNumber, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
+import { getScaleBudgetFee } from '@/lib/pricingScaleFee'
+import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail'
+import { isInvestScaleSingleTotalService } from '@/lib/servicePricing'
+import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
+import { useKvStore } from '@/pinia/kv'
+
+interface StoredDetailRowsState {
+ detailRows?: T[]
+ totalAmount?: number | null
+}
+
+interface StoredFactorState {
+ detailRows?: Array<{
+ id: string
+ standardFactor?: number | null
+ budgetValue?: number | null
+ }>
+}
+
+type MaybeNumber = number | null | undefined
+
+const sumByNumberNullable = (list: T[], pick: (item: T) => MaybeNumber): number | null => {
+ let hasValid = false
+ const total = sumByNumber(list, item => {
+ const value = toFiniteNumberOrNull(pick(item))
+ if (value == null) return null
+ hasValid = true
+ return value
+ })
+ return hasValid ? total : null
+}
+
+const getOnlyCostScaleSummaryAmount = (
+ rows?: Array<{ amount?: unknown; isGroupRow?: unknown }>,
+ totalAmount?: unknown
+) => {
+ if (typeof totalAmount === 'number' && Number.isFinite(totalAmount)) return totalAmount
+ const summaryRow = (rows || []).find(row => row?.isGroupRow === true)
+ if (typeof summaryRow?.amount === 'number' && Number.isFinite(summaryRow.amount)) return summaryRow.amount
+ return null
+}
+
+interface ScaleRow {
+ id: string
+ amount: number | null
+ landArea: number | null
+ benchmarkBudgetBasicChecked: boolean
+ benchmarkBudgetOptionalChecked: boolean
+ consultCategoryFactor: number | null
+ majorFactor: number | null
+ workStageFactor: number | null
+ workRatio: number | null
+}
+
+interface WorkloadRow {
+ id: string
+ conversion: number | null
+ workload: number | null
+ basicFee: number | null
+ budgetAdoptedUnitPrice: number | null
+ consultCategoryFactor: number | null
+}
+
+interface HourlyRow {
+ id: string
+ adoptedBudgetUnitPrice: number | null
+ personnelCount: number | null
+ workdayCount: number | null
+}
+
+interface MajorLite {
+ code: string
+ defCoe: number | null
+ hasCost?: boolean
+ hasArea?: boolean
+ industryId?: string | number | null
+}
+
+interface ServiceLite {
+ defCoe: number | null
+ investScaleSingleTotal?: boolean | null
+}
+
+interface TaskLite {
+ serviceID: number
+ conversion: number | null
+ defPrice: number | null
+}
+
+interface ExpertLite {
+ defPrice: number | null
+ manageCoe: number | null
+}
+
+interface XmBaseInfoState {
+ projectIndustry?: string
+}
+
+export interface PricingMethodTotals {
+ investScale: number | null
+ landScale: number | null
+ workload: number | null
+ hourly: number | null
+}
+
+interface PricingMethodTotalsOptions {
+ excludeInvestmentCostAndAreaRows?: boolean
+}
+
+interface PricingMethodDetailDbKeys {
+ investScale: string
+ landScale: string
+ workload: string
+ hourly: string
+}
+
+interface PricingMethodDefaultDetailRows {
+ investScale: unknown[]
+ landScale: unknown[]
+ workload: unknown[]
+ hourly: unknown[]
+}
+
+interface PricingMethodDefaultBuildContext {
+ htData: StoredDetailRowsState | null
+ consultCategoryFactorMap: Map
+ majorFactorMap: Map
+ industryId: string
+ excludeInvestmentCostAndAreaRows: boolean
+}
+
+const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
+const SERVICE_PRICING_METHODS: ServicePricingMethod[] = ['investScale', 'landScale', 'workload', 'hourly']
+
+const getZxFwStoreSafely = () => {
+ try {
+ return useZxFwPricingStore()
+ } catch {
+ return null
+ }
+}
+
+const getKvStoreSafely = () => {
+ try {
+ return useKvStore()
+ } catch {
+ return null
+ }
+}
+
+const kvGetItem = async (key: string): Promise => {
+ const store = getKvStoreSafely()
+ if (!store) return null
+ return store.getItem(key)
+}
+
+const kvSetItem = async (key: string, value: T): Promise => {
+ const store = getKvStoreSafely()
+ if (!store) return
+ await store.setItem(key, value)
+}
+
+const toStoredDetailRowsState = (state: { detailRows?: TRow[] } | null | undefined): StoredDetailRowsState | null => {
+ if (!state || !Array.isArray(state.detailRows)) return null
+ return {
+ detailRows: JSON.parse(JSON.stringify(state.detailRows))
+ }
+}
+
+const hasOwn = (obj: unknown, key: string) =>
+ Object.prototype.hasOwnProperty.call(obj || {}, key)
+
+const isGroupScaleRow = (row: unknown) =>
+ Boolean(row && typeof row === 'object' && (row as Record).isGroupRow === true)
+
+const stripGroupScaleRows = (rows: TRow[] | undefined): TRow[] =>
+ (rows || []).filter(row => !isGroupScaleRow(row))
+
+const getRowNumberOrFallback = (
+ row: Record | undefined,
+ key: string,
+ fallback: number | null
+) => {
+ if (!row) return fallback
+ const value = toFiniteNumberOrNull(row[key])
+ if (value != null) return value
+ return hasOwn(row, key) ? null : fallback
+}
+
+const toRowMap = (rows?: TRow[]) => {
+ const map = new Map()
+ for (const row of rows || []) {
+ map.set(String(row.id), row)
+ }
+ return map
+}
+
+const parseScopedMajorId = (value: unknown) => {
+ const raw = String(value || '').trim()
+ const scoped = /^\d+::(.+)$/.exec(raw)
+ return (scoped ? String(scoped[1] || '').trim() : raw) || raw
+}
+
+const hasScopedScaleRows = (rows?: Array>) =>
+ (rows || []).some(row => {
+ const id = String(row?.id || '')
+ if (/^\d+::/.test(id)) return true
+ const projectIndex = Number((row as { projectIndex?: unknown })?.projectIndex)
+ return Number.isFinite(projectIndex) && projectIndex > 1
+ })
+
+const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
+ const service = (getServiceDictById() as Record)[String(serviceId)]
+ return toFiniteNumberOrNull(service?.defCoe)
+}
+
+const usesInvestScaleSingleTotal = (serviceId: string | number) => {
+ const service = (getServiceDictById() as Record)[String(serviceId)]
+ return isInvestScaleSingleTotalService(service)
+}
+
+const majorById = new Map(getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite]))
+const majorIdAliasMap = getMajorIdAliasMap()
+
+const getDefaultMajorFactorById = (id: string) => {
+ const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id
+ const major = majorById.get(resolvedId)
+ return toFiniteNumberOrNull(major?.defCoe)
+}
+
+const isCostMajorById = (id: string) => {
+ const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id
+ const major = majorById.get(resolvedId)
+ if (!major) return false
+ return major.hasCost !== false
+}
+
+const isAreaMajorById = (id: string) => {
+ const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id
+ const major = majorById.get(resolvedId)
+ if (!major) return false
+ return major.hasArea !== false
+}
+
+const isDualScaleMajorById = (id: string) => {
+ const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id
+ const major = majorById.get(resolvedId)
+ if (!major) return false
+ const hasCost = major.hasCost !== false
+ const hasArea = major.hasArea !== false
+ return hasCost && hasArea
+}
+
+const getIndustryMajorEntryByIndustryId = (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
+}
+
+const resolveFactorValue = (
+ row: { budgetValue?: number | null; standardFactor?: number | null } | undefined,
+ fallback: number | null
+) => {
+ if (!row) return fallback
+ if (hasOwn(row, 'budgetValue')) {
+ return toFiniteNumberOrNull(row.budgetValue)
+ }
+ if (hasOwn(row, 'standardFactor')) {
+ return toFiniteNumberOrNull(row.standardFactor)
+ }
+ return fallback
+}
+
+const buildConsultCategoryFactorMap = (state: StoredFactorState | null) => {
+ const map = new Map()
+ const serviceDict = getServiceDictById() as Record
+ for (const [id, item] of Object.entries(serviceDict)) {
+ map.set(String(id), toFiniteNumberOrNull(item?.defCoe))
+ }
+ for (const row of state?.detailRows || []) {
+ if (!row?.id) continue
+ const id = String(row.id)
+ map.set(id, resolveFactorValue(row, map.get(id) ?? null))
+ }
+ return map
+}
+
+const buildMajorFactorMap = (state: StoredFactorState | null) => {
+ const map = new Map()
+ for (const [id, item] of majorById.entries()) {
+ map.set(String(id), toFiniteNumberOrNull(item?.defCoe))
+ }
+ for (const row of state?.detailRows || []) {
+ if (!row?.id) continue
+ const rowId = String(row.id)
+ const id = map.has(rowId) ? rowId : majorIdAliasMap.get(rowId) || rowId
+ map.set(id, resolveFactorValue(row, map.get(id) ?? null))
+ }
+ return map
+}
+
+const getMajorLeafIds = () =>
+ getMajorDictEntries()
+ .filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
+ .map(({ id }) => id)
+
+const buildDefaultScaleRows = (
+ serviceId: string | number,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map
+): ScaleRow[] => {
+ const defaultConsultCategoryFactor =
+ consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
+ return getMajorLeafIds().map(id => ({
+ id,
+ amount: null,
+ landArea: null,
+ benchmarkBudgetBasicChecked: true,
+ benchmarkBudgetOptionalChecked: true,
+ consultCategoryFactor: defaultConsultCategoryFactor,
+ majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactorById(id),
+ workStageFactor: 1,
+ workRatio: 100
+ }))
+}
+
+const mergeScaleRows = (
+ serviceId: string | number,
+ rowsFromDb: Array & Pick> | undefined,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map
+): ScaleRow[] => {
+ const sourceRows = stripGroupScaleRows(rowsFromDb)
+ const dbValueMap = toRowMap(sourceRows)
+ for (const row of sourceRows) {
+ const rowId = String(row.id)
+ const nextId = majorIdAliasMap.get(rowId)
+ if (nextId && !dbValueMap.has(nextId)) {
+ dbValueMap.set(nextId, row as ScaleRow)
+ }
+ }
+
+ const defaultConsultCategoryFactor =
+ consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
+ return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap).map(row => {
+ const fromDb = dbValueMap.get(row.id)
+ if (!fromDb) return row
+
+ const hasConsultCategoryFactor = hasOwn(fromDb, 'consultCategoryFactor')
+ const hasMajorFactor = hasOwn(fromDb, 'majorFactor')
+ const hasWorkStageFactor = hasOwn(fromDb, 'workStageFactor')
+ const hasWorkRatio = hasOwn(fromDb, 'workRatio')
+
+ return {
+ ...row,
+ amount: toFiniteNumberOrNull(fromDb.amount),
+ landArea: toFiniteNumberOrNull(fromDb.landArea),
+ benchmarkBudgetBasicChecked:
+ typeof (fromDb as { benchmarkBudgetBasicChecked?: unknown }).benchmarkBudgetBasicChecked === 'boolean'
+ ? Boolean((fromDb as { benchmarkBudgetBasicChecked?: unknown }).benchmarkBudgetBasicChecked)
+ : true,
+ benchmarkBudgetOptionalChecked:
+ typeof (fromDb as { benchmarkBudgetOptionalChecked?: unknown }).benchmarkBudgetOptionalChecked === 'boolean'
+ ? Boolean((fromDb as { benchmarkBudgetOptionalChecked?: unknown }).benchmarkBudgetOptionalChecked)
+ : true,
+ consultCategoryFactor:
+ toFiniteNumberOrNull(fromDb.consultCategoryFactor) ??
+ (hasConsultCategoryFactor ? null : defaultConsultCategoryFactor),
+ majorFactor:
+ toFiniteNumberOrNull(fromDb.majorFactor) ??
+ (hasMajorFactor ? null : (majorFactorMap?.get(row.id) ?? getDefaultMajorFactorById(row.id))),
+ workStageFactor:
+ toFiniteNumberOrNull((fromDb as Partial).workStageFactor) ??
+ (hasWorkStageFactor ? null : row.workStageFactor),
+ workRatio:
+ toFiniteNumberOrNull((fromDb as Partial).workRatio) ??
+ (hasWorkRatio ? null : row.workRatio)
+ }
+ })
+}
+
+const getInvestmentBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'cost')
+
+const getInvestScaleSingleTotalBudgetFee = (
+ serviceId: string,
+ rowsFromDb: Array> | undefined,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map,
+ industryId?: string | null,
+ totalAmount?: number | null
+) => {
+ const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
+ const rawRows = rowsFromDb || []
+ const sourceRows = stripGroupScaleRows(rowsFromDb)
+ const defaultConsultCategoryFactor =
+ consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
+ const defaultMajorFactor =
+ (industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ??
+ toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ??
+ 1
+
+ // 单行总投资模式支持“按项目行”存储(如 1::majorId、2::majorId),每行需独立计费后求和。
+ const usePerRowCalculation = sourceRows.some(row => {
+ if (typeof row?.projectIndex === 'number' && Number.isFinite(row.projectIndex)) return true
+ const id = String(row?.id || '')
+ return /^\d+::/.test(id)
+ })
+ if (usePerRowCalculation) {
+ return sumByNumberNullable(sourceRows, row => {
+ const amount = toFiniteNumberOrNull(row?.amount)
+ if (amount == null) return null
+ return getScaleBudgetFeeByRow({
+ amount,
+ benchmarkBudgetBasicChecked: typeof row?.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
+ benchmarkBudgetOptionalChecked: typeof row?.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true,
+ majorFactor: getRowNumberOrFallback(row, 'majorFactor', defaultMajorFactor),
+ consultCategoryFactor: getRowNumberOrFallback(row, 'consultCategoryFactor', defaultConsultCategoryFactor),
+ workStageFactor: getRowNumberOrFallback(row, 'workStageFactor', 1),
+ workRatio: getRowNumberOrFallback(row, 'workRatio', 100)
+ }, 'cost')
+ })
+ }
+
+ const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount)
+ if (resolvedTotalAmount == null) return null
+ const onlyRow =
+ rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
+ sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) ||
+ sourceRows[0]
+ const consultCategoryFactor = getRowNumberOrFallback(onlyRow, 'consultCategoryFactor', defaultConsultCategoryFactor)
+ const majorFactor = getRowNumberOrFallback(onlyRow, 'majorFactor', defaultMajorFactor)
+ const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1)
+ const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100)
+ return getScaleBudgetFeeByRow({
+ amount: resolvedTotalAmount,
+ benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true,
+ benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true,
+ majorFactor,
+ consultCategoryFactor,
+ workStageFactor,
+ workRatio
+ }, 'cost')
+}
+
+const buildInvestScaleSingleTotalDetailRows = (
+ serviceId: string,
+ rowsFromDb: Array> | undefined,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map,
+ industryId?: string | null,
+ totalAmount?: number | null
+) => {
+ const rawRows = rowsFromDb || []
+ const sourceRows = stripGroupScaleRows(rowsFromDb)
+ const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount)
+ const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
+ const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID
+ const onlyRow =
+ rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
+ sourceRows.find(row => String(row?.id || '') === onlyCostRowId)
+ const consultCategoryFactor = getRowNumberOrFallback(
+ onlyRow,
+ 'consultCategoryFactor',
+ consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
+ )
+ const majorFactor = getRowNumberOrFallback(
+ onlyRow,
+ 'majorFactor',
+ (industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ??
+ toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ??
+ 1
+ )
+ const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1)
+ const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100)
+
+ return [
+ {
+ id: onlyCostRowId,
+ amount: resolvedTotalAmount,
+ landArea: null,
+ consultCategoryFactor,
+ majorFactor,
+ workStageFactor,
+ workRatio,
+ benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true,
+ benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true
+ }
+ ]
+}
+
+const getLandBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'area')
+
+const getTaskEntriesByServiceId = (serviceId: string | number) =>
+ Object.entries(taskList as Record)
+ .sort((a, b) => Number(a[0]) - Number(b[0]))
+ .filter(([, task]) => Number(task.serviceID) === Number(serviceId))
+
+const buildDefaultWorkloadRows = (
+ serviceId: string | number,
+ consultCategoryFactorMap?: Map
+): WorkloadRow[] => {
+ const defaultConsultCategoryFactor =
+ consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
+ return getTaskEntriesByServiceId(serviceId).map(([taskId, task], order) => ({
+ id: `task-${taskId}-${order}`,
+ conversion: toFiniteNumberOrNull(task.conversion),
+ workload: null,
+ basicFee: null,
+ budgetAdoptedUnitPrice: toFiniteNumberOrNull(task.defPrice),
+ consultCategoryFactor: defaultConsultCategoryFactor
+ }))
+}
+
+const mergeWorkloadRows = (
+ serviceId: string | number,
+ rowsFromDb: Array & Pick> | undefined,
+ consultCategoryFactorMap?: Map
+): WorkloadRow[] => {
+ const dbValueMap = toRowMap(rowsFromDb)
+
+ return buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap).map(row => {
+ const fromDb = dbValueMap.get(row.id)
+ if (!fromDb) return row
+
+ return {
+ ...row,
+ workload: toFiniteNumberOrNull(fromDb.workload),
+ basicFee: toFiniteNumberOrNull(fromDb.basicFee),
+ budgetAdoptedUnitPrice: toFiniteNumberOrNull(fromDb.budgetAdoptedUnitPrice),
+ consultCategoryFactor: toFiniteNumberOrNull(fromDb.consultCategoryFactor)
+ }
+ })
+}
+
+const calcWorkloadBasicFee = (row: WorkloadRow) => {
+ if (
+ row.budgetAdoptedUnitPrice == null ||
+ row.conversion == null ||
+ row.workload == null
+ ) {
+ return null
+ }
+ return roundTo(
+ toDecimal(row.budgetAdoptedUnitPrice).mul(row.conversion).mul(row.workload),
+ 2
+ )
+}
+
+const calcWorkloadServiceFee = (row: WorkloadRow) => {
+ if (row.consultCategoryFactor == null) {
+ return null
+ }
+ const basicFee = row.basicFee ?? calcWorkloadBasicFee(row)
+ if (basicFee == null) return null
+ return roundTo(
+ toDecimal(basicFee).mul(row.consultCategoryFactor),
+ 2
+ )
+}
+
+const getExpertEntries = () =>
+ Object.entries(expertList as Record).sort((a, b) => Number(a[0]) - Number(b[0]))
+
+const getDefaultHourlyAdoptedPrice = (expert: ExpertLite) => {
+ if (expert.defPrice == null || expert.manageCoe == null) return null
+ return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
+}
+
+const buildDefaultHourlyRows = (): HourlyRow[] =>
+ getExpertEntries().map(([expertId, expert]) => ({
+ id: `expert-${expertId}`,
+ adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert),
+ personnelCount: null,
+ workdayCount: null
+ }))
+
+const mergeHourlyRows = (
+ rowsFromDb: Array & Pick> | undefined
+): HourlyRow[] => {
+ const dbValueMap = toRowMap(rowsFromDb)
+
+ return buildDefaultHourlyRows().map(row => {
+ const fromDb = dbValueMap.get(row.id)
+ if (!fromDb) return row
+
+ return {
+ ...row,
+ adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice),
+ personnelCount: toFiniteNumberOrNull(fromDb.personnelCount),
+ workdayCount: toFiniteNumberOrNull(fromDb.workdayCount)
+ }
+ })
+}
+
+const calcHourlyServiceBudget = (row: HourlyRow) => {
+ if (row.adoptedBudgetUnitPrice == null || row.personnelCount == null || row.workdayCount == null) return null
+ return roundTo(toDecimal(row.adoptedBudgetUnitPrice).mul(row.personnelCount).mul(row.workdayCount), 2)
+}
+
+const resolveScaleRows = (
+ serviceId: string,
+ pricingData: StoredDetailRowsState | null,
+ htData: StoredDetailRowsState | null,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map
+) => {
+ if (pricingData?.detailRows != null) {
+ return mergeScaleRows(
+ serviceId,
+ pricingData.detailRows as any,
+ consultCategoryFactorMap,
+ majorFactorMap
+ )
+ }
+ if (htData?.detailRows != null) {
+ return mergeScaleRows(
+ serviceId,
+ stripGroupScaleRows(htData.detailRows as any),
+ consultCategoryFactorMap,
+ majorFactorMap
+ )
+ }
+ return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap)
+}
+
+const normalizeScopedScaleRows = (
+ serviceId: string,
+ rowsFromDb: Array> | undefined,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map
+): ScaleRow[] => {
+ const rows = stripGroupScaleRows(rowsFromDb) as Array>
+ if (rows.length === 0) return []
+ const defaultConsultCategoryFactor =
+ consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
+
+ return rows.map(row => {
+ const parsedMajorId = parseScopedMajorId(row.id)
+ const resolvedMajorId = majorById.has(parsedMajorId) ? parsedMajorId : (majorIdAliasMap.get(parsedMajorId) || parsedMajorId)
+ const hasConsultCategoryFactor = hasOwn(row, 'consultCategoryFactor')
+ const hasMajorFactor = hasOwn(row, 'majorFactor')
+ const hasWorkStageFactor = hasOwn(row, 'workStageFactor')
+ const hasWorkRatio = hasOwn(row, 'workRatio')
+ return {
+ id: resolvedMajorId,
+ amount: toFiniteNumberOrNull(row.amount),
+ landArea: toFiniteNumberOrNull(row.landArea),
+ benchmarkBudgetBasicChecked:
+ typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
+ benchmarkBudgetOptionalChecked:
+ typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true,
+ consultCategoryFactor:
+ toFiniteNumberOrNull(row.consultCategoryFactor) ??
+ (hasConsultCategoryFactor ? null : defaultConsultCategoryFactor),
+ majorFactor:
+ toFiniteNumberOrNull(row.majorFactor) ??
+ (hasMajorFactor ? null : (majorFactorMap?.get(resolvedMajorId) ?? getDefaultMajorFactorById(resolvedMajorId))),
+ workStageFactor:
+ toFiniteNumberOrNull(row.workStageFactor) ??
+ (hasWorkStageFactor ? null : 1),
+ workRatio:
+ toFiniteNumberOrNull(row.workRatio) ??
+ (hasWorkRatio ? null : 100)
+ }
+ })
+}
+
+// 统一生成某合同下某个咨询服务四种计费方式的存储键。
+// 优先复用 Pinia store 当前约定的 key,避免与旧版 fallback key 脱节。
+export const getPricingMethodDetailDbKeys = (
+ contractId: string,
+ serviceId: string | number
+): PricingMethodDetailDbKeys => {
+ const normalizedServiceId = String(serviceId)
+ const store = getZxFwStoreSafely()
+ if (store) {
+ return {
+ investScale: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'investScale'),
+ landScale: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'landScale'),
+ workload: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'workload'),
+ hourly: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'hourly')
+ }
+ }
+ return {
+ investScale: `tzGMF-${contractId}-${normalizedServiceId}`,
+ landScale: `ydGMF-${contractId}-${normalizedServiceId}`,
+ workload: `gzlF-${contractId}-${normalizedServiceId}`,
+ hourly: `hourlyPricing-${contractId}-${normalizedServiceId}`
+ }
+}
+
+const loadPricingMethodDefaultBuildContext = async (
+ contractId: string,
+ options?: PricingMethodTotalsOptions
+): Promise => {
+ const htDbKey = `ht-info-v3-${contractId}`
+ const consultFactorDbKey = `ht-consult-category-factor-v1-${contractId}`
+ const majorFactorDbKey = `ht-major-factor-v1-${contractId}`
+ const baseInfoDbKey = 'xm-base-info-v1'
+
+ const [htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([
+ kvGetItem(htDbKey),
+ kvGetItem(consultFactorDbKey),
+ kvGetItem(majorFactorDbKey),
+ kvGetItem(baseInfoDbKey)
+ ])
+
+ return {
+ htData,
+ consultCategoryFactorMap: buildConsultCategoryFactorMap(consultFactorData),
+ majorFactorMap: buildMajorFactorMap(majorFactorData),
+ industryId: typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '',
+ excludeInvestmentCostAndAreaRows: options?.excludeInvestmentCostAndAreaRows === true
+ }
+}
+
+const buildDefaultPricingMethodDetailRows = (
+ serviceId: string,
+ context: PricingMethodDefaultBuildContext
+): PricingMethodDefaultDetailRows => {
+ const investScaleSingleTotal = usesInvestScaleSingleTotal(serviceId)
+ const scaleRows = resolveScaleRows(
+ serviceId,
+ null,
+ context.htData,
+ context.consultCategoryFactorMap,
+ context.majorFactorMap
+ )
+
+ const investScale = investScaleSingleTotal
+ ? buildInvestScaleSingleTotalDetailRows(
+ serviceId,
+ context.htData?.detailRows as Array> | undefined,
+ context.consultCategoryFactorMap,
+ context.majorFactorMap,
+ context.industryId,
+ context.htData?.totalAmount ?? null
+ )
+ : scaleRows.filter(row => {
+ if (!isCostMajorById(row.id)) return false
+ if (context.excludeInvestmentCostAndAreaRows && isDualScaleMajorById(row.id)) return false
+ return true
+ })
+ const landScale = scaleRows.filter(row => isAreaMajorById(row.id))
+
+ return {
+ investScale,
+ landScale,
+ workload: buildDefaultWorkloadRows(serviceId, context.consultCategoryFactorMap),
+ hourly: buildDefaultHourlyRows()
+ }
+}
+
+// 强制为一组服务重建并落库默认明细行。
+// 这个方法会同时写入 Pinia 内存态和底层 KV 存储,适合“重置为默认值”场景。
+export const persistDefaultPricingMethodDetailRowsForServices = async (params: {
+ contractId: string
+ serviceIds: Array
+ options?: PricingMethodTotalsOptions
+}) => {
+ const uniqueServiceIds = Array.from(new Set(params.serviceIds.map(serviceId => String(serviceId))))
+ if (uniqueServiceIds.length === 0) return
+
+ const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options)
+ const store = getZxFwStoreSafely()
+
+ await Promise.all(
+ uniqueServiceIds.map(async serviceId => {
+ const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId)
+ const defaultRows = buildDefaultPricingMethodDetailRows(serviceId, context)
+ if (store) {
+ for (const method of SERVICE_PRICING_METHODS) {
+ store.setServicePricingMethodState(params.contractId, serviceId, method, {
+ detailRows: defaultRows[method]
+ }, { force: true })
+ }
+ }
+ await Promise.all([
+ kvSetItem(dbKeys.investScale, { detailRows: defaultRows.investScale }),
+ kvSetItem(dbKeys.landScale, { detailRows: defaultRows.landScale }),
+ kvSetItem(dbKeys.workload, { detailRows: defaultRows.workload }),
+ kvSetItem(dbKeys.hourly, { detailRows: defaultRows.hourly })
+ ])
+ })
+ )
+}
+
+// 汇总单个服务的四类计费方式金额。
+// 数据读取顺序是:优先读当前 Pinia 中已加载的计费页数据,缺失时再回退到 KV 存储和合同段默认信息。
+export const getPricingMethodTotalsForService = async (params: {
+ contractId: string
+ serviceId: string | number
+ options?: PricingMethodTotalsOptions
+}): Promise => {
+ const serviceId = String(params.serviceId)
+ const htDbKey = `ht-info-v3-${params.contractId}`
+ const consultFactorDbKey = `ht-consult-category-factor-v1-${params.contractId}`
+ const majorFactorDbKey = `ht-major-factor-v1-${params.contractId}`
+ const baseInfoDbKey = 'xm-base-info-v1'
+ const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId)
+ const store = getZxFwStoreSafely()
+
+ const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'hourly') || Promise.resolve(null),
+ kvGetItem(htDbKey),
+ kvGetItem(consultFactorDbKey),
+ kvGetItem(majorFactorDbKey),
+ kvGetItem(baseInfoDbKey)
+ ])
+
+ const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([
+ storeInvestData ? Promise.resolve(null) : kvGetItem(dbKeys.investScale),
+ storeLandData ? Promise.resolve(null) : kvGetItem(dbKeys.landScale),
+ storeWorkloadData ? Promise.resolve(null) : kvGetItem(dbKeys.workload),
+ storeHourlyData ? Promise.resolve(null) : kvGetItem(dbKeys.hourly)
+ ])
+
+ const investData = toStoredDetailRowsState(storeInvestData) || investDataFallback
+ const landData = toStoredDetailRowsState(storeLandData) || landDataFallback
+ const workloadData = toStoredDetailRowsState(storeWorkloadData) || workloadDataFallback
+ const hourlyData = toStoredDetailRowsState(storeHourlyData) || hourlyDataFallback
+
+ const consultCategoryFactorMap = buildConsultCategoryFactorMap(consultFactorData)
+ const majorFactorMap = buildMajorFactorMap(majorFactorData)
+ const investScaleSingleTotal = usesInvestScaleSingleTotal(serviceId)
+ const industryId = typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
+
+ // 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。
+ const excludeInvestmentCostAndAreaRows = params.options?.excludeInvestmentCostAndAreaRows === true
+ const investScaleRowsSource = stripGroupScaleRows(investData?.detailRows as Array> | undefined)
+ const landScaleRowsSource = stripGroupScaleRows(landData?.detailRows as Array> | undefined)
+ const scopedInvestRows = hasScopedScaleRows(investScaleRowsSource)
+ ? normalizeScopedScaleRows(serviceId, investScaleRowsSource, consultCategoryFactorMap, majorFactorMap)
+ : null
+ const scopedLandRows = hasScopedScaleRows(landScaleRowsSource)
+ ? normalizeScopedScaleRows(serviceId, landScaleRowsSource, consultCategoryFactorMap, majorFactorMap)
+ : null
+ const investScale = investScaleSingleTotal
+ ? getInvestScaleSingleTotalBudgetFee(
+ serviceId,
+ (investData?.detailRows as Array> | undefined) ||
+ (htData?.detailRows as Array> | undefined),
+ consultCategoryFactorMap,
+ majorFactorMap,
+ industryId,
+ htData?.totalAmount ?? null
+ )
+ : (() => {
+ const investRows = scopedInvestRows || resolveScaleRows(
+ serviceId,
+ investData,
+ htData,
+ consultCategoryFactorMap,
+ majorFactorMap
+ )
+ return sumByNumberNullable(investRows, row => {
+ if (!isCostMajorById(row.id)) return null
+ if (excludeInvestmentCostAndAreaRows && isDualScaleMajorById(row.id)) return null
+ return getInvestmentBudgetFee(row)
+ })
+ })()
+
+ const landRows = scopedLandRows || resolveScaleRows(
+ serviceId,
+ landData,
+ htData,
+ consultCategoryFactorMap,
+ majorFactorMap
+ )
+ const landScale = sumByNumberNullable(landRows, row => (isAreaMajorById(row.id) ? getLandBudgetFee(row) : null))
+
+ const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap)
+ const workload =
+ defaultWorkloadRows.length === 0
+ ? null
+ : sumByNumberNullable(
+ workloadData?.detailRows != null
+ ? mergeWorkloadRows(serviceId, workloadData.detailRows as any, consultCategoryFactorMap)
+ : defaultWorkloadRows,
+ row => calcWorkloadServiceFee(row)
+ )
+
+ const hourlyRows =
+ hourlyData?.detailRows != null
+ ? mergeHourlyRows(hourlyData.detailRows as any)
+ : buildDefaultHourlyRows()
+ const hourly = sumByNumberNullable(hourlyRows, row => calcHourlyServiceBudget(row))
+
+ return {
+ investScale,
+ landScale,
+ workload,
+ hourly
+ }
+}
+
+// 为一组服务补齐缺失的计费明细行,但不会覆盖已有用户数据。
+// 适合在首次进入计费页或新增服务后做“按需初始化”。
+export const ensurePricingMethodDetailRowsForServices = async (params: {
+ contractId: string
+ serviceIds: Array
+ options?: PricingMethodTotalsOptions
+}) => {
+ const uniqueServiceIds = Array.from(new Set(params.serviceIds.map(serviceId => String(serviceId))))
+ if (uniqueServiceIds.length === 0) return
+
+ const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options)
+ const store = getZxFwStoreSafely()
+
+ await Promise.all(
+ uniqueServiceIds.map(async serviceId => {
+ const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId)
+ const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData] = await Promise.all([
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
+ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'hourly') || Promise.resolve(null)
+ ])
+ const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([
+ storeInvestData ? Promise.resolve(null) : kvGetItem(dbKeys.investScale),
+ storeLandData ? Promise.resolve(null) : kvGetItem(dbKeys.landScale),
+ storeWorkloadData ? Promise.resolve(null) : kvGetItem(dbKeys.workload),
+ storeHourlyData ? Promise.resolve(null) : kvGetItem(dbKeys.hourly)
+ ])
+ const investData = toStoredDetailRowsState(storeInvestData) || investDataFallback
+ const landData = toStoredDetailRowsState(storeLandData) || landDataFallback
+ const workloadData = toStoredDetailRowsState(storeWorkloadData) || workloadDataFallback
+ const hourlyData = toStoredDetailRowsState(storeHourlyData) || hourlyDataFallback
+
+ const shouldInitInvest = !Array.isArray(investData?.detailRows) || investData!.detailRows!.length === 0
+ const shouldInitLand = !Array.isArray(landData?.detailRows) || landData!.detailRows!.length === 0
+ const shouldInitWorkload = !Array.isArray(workloadData?.detailRows) || workloadData!.detailRows!.length === 0
+ const shouldInitHourly = !Array.isArray(hourlyData?.detailRows) || hourlyData!.detailRows!.length === 0
+
+ const writeTasks: Promise[] = []
+ let defaultRows: PricingMethodDefaultDetailRows | null = null
+ const getDefaultRows = () => {
+ if (!defaultRows) {
+ defaultRows = buildDefaultPricingMethodDetailRows(serviceId, context)
+ }
+ return defaultRows
+ }
+
+ if (shouldInitInvest) {
+ if (store) {
+ store.setServicePricingMethodState(params.contractId, serviceId, 'investScale', {
+ detailRows: getDefaultRows().investScale
+ }, { force: true })
+ }
+ writeTasks.push(kvSetItem(dbKeys.investScale, { detailRows: getDefaultRows().investScale }))
+ }
+
+ if (shouldInitLand) {
+ if (store) {
+ store.setServicePricingMethodState(params.contractId, serviceId, 'landScale', {
+ detailRows: getDefaultRows().landScale
+ }, { force: true })
+ }
+ writeTasks.push(kvSetItem(dbKeys.landScale, { detailRows: getDefaultRows().landScale }))
+ }
+
+ if (shouldInitWorkload) {
+ if (store) {
+ store.setServicePricingMethodState(params.contractId, serviceId, 'workload', {
+ detailRows: getDefaultRows().workload
+ }, { force: true })
+ }
+ writeTasks.push(kvSetItem(dbKeys.workload, { detailRows: getDefaultRows().workload }))
+ }
+
+ if (shouldInitHourly) {
+ if (store) {
+ store.setServicePricingMethodState(params.contractId, serviceId, 'hourly', {
+ detailRows: getDefaultRows().hourly
+ }, { force: true })
+ }
+ writeTasks.push(kvSetItem(dbKeys.hourly, { detailRows: getDefaultRows().hourly }))
+ }
+
+ if (writeTasks.length > 0) {
+ await Promise.all(writeTasks)
+ }
+ })
+ )
+}
+
+// 并行汇总多个服务的计费结果,返回以 serviceId 为 key 的 Map。
+export const getPricingMethodTotalsForServices = async (params: {
+ contractId: string
+ serviceIds: Array
+ options?: PricingMethodTotalsOptions
+}) => {
+ const result = new Map()
+ await Promise.all(
+ params.serviceIds.map(async serviceId => {
+ const totals = await getPricingMethodTotalsForService({
+ contractId: params.contractId,
+ serviceId,
+ options: params.options
+ })
+ result.set(String(serviceId), totals)
+ })
+ )
+ return result
+}
+
diff --git a/src/lib/pricingPersistControl.ts b/src/lib/pricingPersistControl.ts
new file mode 100644
index 0000000..eaf140e
--- /dev/null
+++ b/src/lib/pricingPersistControl.ts
@@ -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))
+}
diff --git a/src/lib/pricingPinnedRows.ts b/src/lib/pricingPinnedRows.ts
new file mode 100644
index 0000000..7a347ab
--- /dev/null
+++ b/src/lib/pricingPinnedRows.ts
@@ -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 = (
+ overrides: Partial & Pick
+) => {
+ 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 = (row: TRow): TRow[] => [row]
diff --git a/src/lib/pricingScaleCalc.ts b/src/lib/pricingScaleCalc.ts
new file mode 100644
index 0000000..5c760de
--- /dev/null
+++ b/src/lib/pricingScaleCalc.ts
@@ -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(serviceId)]
+ return toFiniteNumberOrNull(service?.defCoe)
+}
+
+/** 判断是否为仅投资规模服务 */
+export const isInvestScaleSingleTotalByService = (serviceId: string | number): boolean => {
+ const service = (getServiceDictById() as Record)[String(serviceId)]
+ return isInvestScaleSingleTotalService(service)
+}
+
+/* ----------------------------------------------------------------
+ * 行构建与合并
+ * ---------------------------------------------------------------- */
+
+/** 判断是否为分组汇总行(AG Grid tree 用) */
+export const isGroupScaleRow = (row: unknown): boolean =>
+ Boolean(row && typeof row === 'object' && (row as Record).isGroupRow === true)
+
+/** 过滤掉分组汇总行 */
+export const stripGroupScaleRows = (rows: TRow[] | undefined): TRow[] =>
+ (rows || []).filter(row => !isGroupScaleRow(row))
+
+/** 构建规模法默认行(全部专业叶子) */
+export const buildDefaultScaleRows = (
+ serviceId: string | number,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map
+): 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: 100
+ }))
+}
+
+const hasOwn = (obj: unknown, key: string) =>
+ Object.prototype.hasOwnProperty.call(obj || {}, key)
+
+const toRowMap = (rows?: TRow[]) => {
+ const map = new Map()
+ for (const row of rows || []) map.set(String(row.id), row)
+ return map
+}
+
+/** 合并持久化行与默认行(保留用户编辑值,补全缺失字段) */
+export const mergeScaleRows = (
+ serviceId: string | number,
+ rowsFromDb: Array & Pick> | undefined,
+ consultCategoryFactorMap?: Map,
+ majorFactorMap?: Map
+): 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 = (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
+}
+
diff --git a/src/lib/pricingScaleColumns.ts b/src/lib/pricingScaleColumns.ts
new file mode 100644
index 0000000..aec4047
--- /dev/null
+++ b/src/lib/pricingScaleColumns.ts
@@ -0,0 +1,298 @@
+import type { ColDef, ColGroupDef } from 'ag-grid-community'
+import {
+ formatScaleEditableNumber,
+ formatScaleReadonlyMoney,
+ getScaleMergeColSpanBeforeTotal
+} from '@/lib/pricingScaleGrid'
+import { i18n } from '@/i18n'
+
+type ScaleColumnField = Extract | string
+const scaleT = (key: string, params?: Record) =>
+ params ? i18n.global.t(`pricingScale.${key}`, params) : i18n.global.t(`pricingScale.${key}`)
+
+export const createScaleValueColumn = (options: {
+ headerName: string
+ field: ScaleColumnField
+ headerTooltip: string
+ onReset: () => Promise | 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 => ({
+ 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 = (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
+}) : ColGroupDef => ({
+ 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 = (options: {
+ headerComponent: any
+ restoreConsultCategoryFactorColumnDefaults: () => Promise | void
+ restoreMajorFactorColumnDefaults: () => Promise | void
+ parseNumberOrNull: (value: any, options?: any) => any
+ getBudgetFee: (row: TRow | undefined) => number | null
+ aggFunc: any
+}) : ColGroupDef => ({
+ 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',
+ 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 = () : ColDef => ({
+ 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 = (options: {
+ totalLabel: string
+ idLabelMap: Map
+ parseProjectIndexFromPathKey: (key: string) => number | null
+}) : ColDef => ({
+ headerName: scaleT('columns.majorGroup'),
+ minWidth: 250,
+ flex: 2,
+ wrapText: true,
+ autoHeight: true,
+ 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
+ }
+})
diff --git a/src/lib/pricingScaleDetail.ts b/src/lib/pricingScaleDetail.ts
new file mode 100644
index 0000000..8b5210e
--- /dev/null
+++ b/src/lib/pricingScaleDetail.ts
@@ -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 & {
+ total: number | null
+}
+
+const getScaleValueByMode = (
+ row: Pick | 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
+) => row?.benchmarkBudgetBasicChecked === false && row?.benchmarkBudgetOptionalChecked === false
+
+export const getBenchmarkBudgetRawSplitByRow = (
+ row: Pick | undefined,
+ mode: ScaleMode
+) => getBenchmarkBudgetSplitByScale(getScaleValueByMode(row, mode), mode)
+
+export const getCheckedBenchmarkBudgetSplitByRow = (
+ row?: Pick,
+ 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 = (
+ 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 = (
+ 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)
+}
diff --git a/src/lib/pricingScaleDict.ts b/src/lib/pricingScaleDict.ts
new file mode 100644
index 0000000..4e2e103
--- /dev/null
+++ b/src/lib/pricingScaleDict.ts
@@ -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()
+ 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()
+ 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 = (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
+}
diff --git a/src/lib/pricingScaleFee.ts b/src/lib/pricingScaleFee.ts
new file mode 100644
index 0000000..73c28e4
--- /dev/null
+++ b/src/lib/pricingScaleFee.ts
@@ -0,0 +1,143 @@
+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) : 100
+
+ 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)
+ .div(100)
+ 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) : 100
+
+ 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)
+ .div(100)
+ return roundTo(toDecimal(roundedBenchmarkBudget).mul(multiplier), 2)
+}
diff --git a/src/lib/pricingScaleGrid.ts b/src/lib/pricingScaleGrid.ts
new file mode 100644
index 0000000..7906e9e
--- /dev/null
+++ b/src/lib/pricingScaleGrid.ts
@@ -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 = (
+ 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 = >(
+ 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 = (
+ getRows: () => TRow[],
+ onAfterToggle: () => void
+) => (checkField: ScaleBudgetCheckField) =>
+ createScaleBudgetCellRendererWithCheck(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 (gridApi: GridApi | null | undefined) => {
+ await nextTick()
+ gridApi?.refreshHeader()
+ gridApi?.refreshCells({ force: true })
+}
+
+export const restoreScaleColumnDefaults = async (options: {
+ gridApi: GridApi | 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
+}) => {
+ 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
+}
diff --git a/src/lib/pricingScaleLink.ts b/src/lib/pricingScaleLink.ts
new file mode 100644
index 0000000..76a0791
--- /dev/null
+++ b/src/lib/pricingScaleLink.ts
@@ -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 = (rows: TRow[] | undefined) => {
+ const map = new Map()
+ 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 = (rows: TRow[] | undefined) => {
+ const map = new Map()
+ 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 = (
+ rows: TRow[] | undefined,
+ totalAmount?: unknown
+) => {
+ const map = new Map()
+
+ 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
+) => {
+ const projectIndex = resolveScaleRowProjectIndex(row)
+ return totalsMap.get(projectIndex)
+ || (projectIndex > 1 ? totalsMap.get(1) : undefined)
+ || { amount: null, landArea: null }
+}
+
+export const getContractScaleRowByMajor = (
+ row: ScaleLinkRow,
+ map: Map,
+ idMap?: Map
+) => {
+ 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) =>
+ 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)
+ )
diff --git a/src/lib/pricingScalePaneData.ts b/src/lib/pricingScalePaneData.ts
new file mode 100644
index 0000000..9aaa62d
--- /dev/null
+++ b/src/lib/pricingScalePaneData.ts
@@ -0,0 +1,147 @@
+type StoredPricingState = {
+ detailRows?: TRow[]
+ projectCount?: unknown
+} | null | undefined
+
+type AsyncVoid = () => Promise | void
+
+type ScalePaneIndustryOptions = {
+ readIndustryCode: () => Promise
+ setIndustryCode: (code: string) => void
+}
+
+const refreshIndustryCode = async (options?: ScalePaneIndustryOptions) => {
+ if (!options) return
+ const industryCode = await options.readIndustryCode()
+ options.setIndustryCode(industryCode)
+}
+
+export const loadPricingScalePaneRows = async (options: {
+ industry?: ScalePaneIndustryOptions
+ setProjectCount: (count: number) => void
+ ensureFactorDefaultsLoaded: AsyncVoid
+ shouldForceDefaultLoad: () => boolean
+ buildContractDefaultRows: (targetProjectCount: number) => Promise
+ loadStoredState: () => Promise>
+ 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 (options: {
+ industry?: ScalePaneIndustryOptions
+ getTargetProjectCount: () => number
+ buildContractDefaultRows: (targetProjectCount: number) => Promise
+ 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 (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 (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
+ getRowKey: (row: Partial | undefined) => string
+ getRowProjectIndex: (row: Partial) => 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()
+ 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()
+}
diff --git a/src/lib/pricingScalePaneLifecycle.ts b/src/lib/pricingScalePaneLifecycle.ts
new file mode 100644
index 0000000..e75f656
--- /dev/null
+++ b/src/lib/pricingScalePaneLifecycle.ts
@@ -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
+
+export const usePricingPaneLifecycle = (options: {
+ gridApi: Ref | null>
+ loadFromIndexedDB: () => Promise
+ syncLinkedFields: () => Promise
+ linkedSourceSignature: WatchSource
+ linkedSecondarySignature?: WatchSource
+ 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
diff --git a/src/lib/pricingScaleProject.ts b/src/lib/pricingScaleProject.ts
new file mode 100644
index 0000000..7e09fc0
--- /dev/null
+++ b/src/lib/pricingScaleProject.ts
@@ -0,0 +1,41 @@
+import {
+ makeProjectMajorKey,
+ resolveScaleRowMajorDictId,
+ resolveScaleRowProjectIndex
+} from '@/lib/pricingScaleLink'
+
+const PROJECT_PATH_PREFIX = 'project-'
+const PROJECT_ROW_ID_SEPARATOR = '::'
+
+export const normalizeScaleProjectCount = (value: unknown) => {
+ const parsed = Number(value)
+ if (!Number.isFinite(parsed)) return 1
+ return Math.max(1, Math.floor(parsed))
+}
+
+export const buildScaleProjectGroupPathKey = (projectIndex: number) => `${PROJECT_PATH_PREFIX}${projectIndex}`
+
+export const buildScopedScaleRowId = (
+ isMutipleService: boolean,
+ projectIndex: number,
+ majorId: string
+) => (isMutipleService ? `${projectIndex}${PROJECT_ROW_ID_SEPARATOR}${majorId}` : majorId)
+
+export const inferScaleProjectCountFromRows = (
+ rows: Array> | undefined,
+ isMutipleService: boolean
+) => {
+ if (!isMutipleService) return 1
+ let maxProjectIndex = 1
+ for (const row of rows || []) {
+ maxProjectIndex = Math.max(maxProjectIndex, resolveScaleRowProjectIndex(row))
+ }
+ return maxProjectIndex
+}
+
+export const getScaleProjectMajorKeyFromRow = (row: Partial | undefined) => {
+ if (!row) return ''
+ const majorDictId = resolveScaleRowMajorDictId(row)
+ if (!majorDictId) return ''
+ return makeProjectMajorKey(resolveScaleRowProjectIndex(row), majorDictId)
+}
diff --git a/src/lib/pricingScaleRowMap.ts b/src/lib/pricingScaleRowMap.ts
new file mode 100644
index 0000000..e46056d
--- /dev/null
+++ b/src/lib/pricingScaleRowMap.ts
@@ -0,0 +1,57 @@
+import { makeProjectMajorKey } from '@/lib/pricingScaleLink'
+
+export const buildScaleProjectMajorMap = (
+ rows: TRow[] | undefined,
+ resolveProjectIndex: (row: Partial) => number,
+ resolveMajorDictId: (row: Partial) => string | undefined
+) => {
+ const valueMap = new Map()
+ for (const row of rows || []) {
+ const majorDictId = resolveMajorDictId(row)
+ if (!majorDictId) continue
+ valueMap.set(makeProjectMajorKey(resolveProjectIndex(row), majorDictId), row)
+ }
+ return valueMap
+}
+
+export const getScaleProjectMajorMappedRow = (
+ valueMap: Map,
+ projectIndex: number,
+ majorDictId: string,
+ options?: { cloneFromProjectOne?: boolean }
+) => (
+ valueMap.get(makeProjectMajorKey(projectIndex, majorDictId))
+ || (
+ options?.cloneFromProjectOne && projectIndex > 1
+ ? valueMap.get(makeProjectMajorKey(1, majorDictId))
+ : undefined
+ )
+)
+
+export const mergeScaleRowsFromProjectMajorMap =