Compare commits

..
4 Commits
Author SHA1 Message Date
zwgczx 5734cfa534 fix more 2026-02-25 14:26:40 +08:00
zwgczx f121aa233e fix all 2026-02-24 17:14:01 +08:00
wintsa 6ba08da229 fix 2026-02-22 23:47:44 +08:00
wintsa d120622de1 first commit 2026-02-22 23:42:49 +08:00
131 changed files with 2687 additions and 41094 deletions

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
**
!dist/
!dist/**
!Dockerfile.dist
!docker/
!docker/dist-server/
!docker/dist-server/**
+1 -1
View File
@@ -11,7 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
*.exe
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
-18
View File
@@ -1,18 +0,0 @@
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"]
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+2 -240
View File
@@ -1,37 +1,25 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "my-vue-app", "name": "my-vue-app",
"dependencies": { "dependencies": {
"@ag-grid-community/locale": "^35.1.0", "@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", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"ag-grid-community": "^35.1.0",
"ag-grid-enterprise": "^35.1.0", "ag-grid-enterprise": "^35.1.0",
"ag-grid-vue3": "^35.1.0", "ag-grid-vue3": "^35.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"decimal.js": "^10.6.0",
"docxtemplater": "^3.68.5",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"motion-v": "^2.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"pizzip": "^3.2.0",
"reka-ui": "^2.8.0", "reka-ui": "^2.8.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-i18n": "^11.3.0",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
}, },
"devDependencies": { "devDependencies": {
@@ -63,10 +51,6 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "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/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/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
@@ -75,22 +59,10 @@
"@floating-ui/vue": ["@floating-ui/vue@1.1.10", "", { "dependencies": { "@floating-ui/dom": "^1.7.5", "@floating-ui/utils": "^0.2.10", "vue-demi": ">=0.13.0" } }, "sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg=="], "@floating-ui/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=="], "@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
"@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=="], "@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.4.6", "", { "dependencies": { "@intlify/devtools-types": "11.4.6", "@intlify/message-compiler": "11.4.6", "@intlify/shared": "11.4.6" } }, "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw=="],
"@intlify/devtools-types": ["@intlify/devtools-types@11.4.6", "", { "dependencies": { "@intlify/core-base": "11.4.6", "@intlify/shared": "11.4.6" } }, "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA=="],
"@intlify/message-compiler": ["@intlify/message-compiler@11.4.6", "", { "dependencies": { "@intlify/shared": "11.4.6", "source-map-js": "^1.0.2" } }, "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q=="],
"@intlify/shared": ["@intlify/shared@11.4.6", "", {}, "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA=="],
"@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/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/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -103,10 +75,6 @@
"@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=="], "@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.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.114.0", "", {}, "sha512-mVGQvr/uFJGQ3hsvgQ1sJfh79t5owyZZZtw+VaH+WhtvsmtgjT6imznB9sz2Q67Q0/4obM9mOOtQscU4aJteSg=="], "@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=="], "@oxc-project/types": ["@oxc-project/types@0.114.0", "", {}, "sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA=="],
@@ -225,8 +193,6 @@
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="], "@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
"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-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-core": ["ag-charts-core@13.1.0", "", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-mLHJZ8oU5CPeLRURescdISCtMsiiA/m4d1iBr6aQBEgiTVogRMGpFpsYNtQiYtoW2sRh+62I9sN8fhC3JQjX/g=="],
@@ -245,124 +211,44 @@
"alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"docxtemplater": ["docxtemplater@3.69.0", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10" } }, "sha512-l1zDGXj4CHdBCkGPvmVOsEzc4DDpMxLXgnNd1zllEck9gxCGkkV5vv1tOD5JhudaM73nTIgymy4wil2u9O/uhQ=="],
"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=="], "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=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "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=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-saver": ["file-saver@2.0.5", "", {}, "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="],
"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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="],
@@ -389,68 +275,22 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "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=="], "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -461,38 +301,20 @@
"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=="], "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=="],
"pizzip": ["pizzip@3.2.0", "", { "dependencies": { "pako": "^2.1.0" } }, "sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ=="],
"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=="], "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=="], "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=="], "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=="], "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=="], "sortablejs": ["sortablejs@1.14.0", "", {}, "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], "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=="], "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
@@ -501,14 +323,8 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "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=="], "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=="], "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=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
@@ -517,12 +333,6 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "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=="], "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=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
@@ -531,22 +341,10 @@
"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-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.4.6", "", { "dependencies": { "@intlify/core-base": "11.4.6", "@intlify/devtools-types": "11.4.6", "@intlify/shared": "11.4.6", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg=="],
"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=="], "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=="], "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/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/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -559,42 +357,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@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/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"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=="], "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=="],
} }
} }
-3
View File
@@ -1,3 +0,0 @@
module dist-server
go 1.24
-69
View File
@@ -1,69 +0,0 @@
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")
}
}
+2 -23
View File
@@ -2,33 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="./public/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>联众咨询</title> <title>造价计算工具</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<!-- <script>
//上线前添加访问版本号,强制刷新缓存
;(() => {
const makeVisitVersion = () => {
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
const bytes = new Uint32Array(2)
window.crypto.getRandomValues(bytes)
return `${Date.now().toString(36)}-${bytes[0].toString(36)}${bytes[1].toString(36)}`
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const url = new URL(window.location.href)
url.searchParams.set('v', makeVisitVersion())
const nextUrl = `${url.pathname}${url.search}${url.hash}`
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`
if (nextUrl !== currentUrl) {
window.history.replaceState(null, '', nextUrl)
}
})()
</script> -->
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+656 -1026
View File
File diff suppressed because it is too large. Load diff
+2 -13
View File
@@ -5,38 +5,27 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bunx --bun vite", "dev": "bunx --bun vite",
"build": " bunx --bun vite build", "build": "bunx vue-tsc -b && bunx --bun vite build",
"preview": "bunx --bun vite preview", "preview": "bunx --bun vite preview",
"type-check": "bunx vue-tsc --noEmit" "type-check": "bunx vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ag-grid-community/locale": "^35.1.0", "@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", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"ag-grid-community": "^35.1.0",
"ag-grid-enterprise": "^35.1.0", "ag-grid-enterprise": "^35.1.0",
"ag-grid-vue3": "^35.1.0", "ag-grid-vue3": "^35.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"decimal.js": "^10.6.0",
"docxtemplater": "^3.68.5",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"motion-v": "^2.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"pizzip": "^3.2.0",
"reka-ui": "^2.8.0", "reka-ui": "^2.8.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-i18n": "^11.3.0",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

-1
View File
@@ -1 +0,0 @@
iVBORw0KGgoAAAANSUhEUgAAAfQAAAH0CAYAAADl8HLGAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4SU
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

-45
View File
@@ -1,45 +0,0 @@
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}"
+5 -317
View File
@@ -1,323 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTabStore } from '@/pinia/tab'
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
import Tab from '@/layout/tab.vue' import Tab from '@/layout/tab.vue'
import { waitForHydration } from '@/pinia/Plugin/indexdb'
import localforage from 'localforage'
import {
buildProjectUrl,
DEFAULT_PROJECT_ID,
ensureProjectIdInUrl,
FORCE_HOME_QUERY_KEY,
getProjectDbName,
NEW_PROJECT_QUERY_KEY,
PROJECT_TAB_ID,
QUICK_PROJECT_ID
} from '@/lib/workspace'
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
import { listenProjectDeleted, listenResetAll } from '@/lib/projectEvents'
import { listProjects, type ProjectMeta } from '@/lib/projectRegistry'
import { closePage } from './lib/utils'
const tabStore = useTabStore()
const { t } = useI18n()
const isReady = ref(false)
const lockConflict = ref(false)
const currentProjectId = ref('')
const currentProjectName = ref('')
const conflictProjectList = ref<ProjectMeta[]>([])
const openedProjectIds = ref<string[]>([])
const closeCountdown = ref(10)
const isNewProjectRequest = ref(false)
const isForceHomeRequest = ref(false)
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
let releaseLock: (() => void) | null = null
let stopProjectDeletedListener: (() => void) | null = null
let stopResetAllListener: (() => void) | null = null
let isHandlingDeletedProject = false
let isHandlingGlobalReset = false
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
const handleImportComplete = () => {
tabStore.hasCompletedSetup = true
}
const initCurrentProjectLock = () => {
if (releaseLock) return
const projectId = String(currentProjectId.value || '').trim()
if (!projectId || projectId === QUICK_PROJECT_ID) {
lockConflict.value = false
return
}
const lock = initProjectSessionLock({
projectId,
onConflict: (next) => {
lockConflict.value = next
if (next) {
refreshConflictProjectList()
startCloseCountdown()
} else {
clearCloseCountdown()
}
}
})
releaseLock = lock.release
}
const refreshConflictProjectList = () => {
void (async () => {
const projects = listProjects()
const enriched = await Promise.all(
projects.map(async (project) => {
try {
const kvStoreInstance = localforage.createInstance({
name: getProjectDbName(project.id),
storeName: 'pinia-kv'
})
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
const projectInfo = entries?.['xm-base-info-v1']
const projectName =
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
? projectInfo.projectName.trim()
: ''
return {
...project,
name: projectName || project.name
}
} catch {
return project
}
})
)
conflictProjectList.value = enriched
const hit = enriched.find(item => item.id === currentProjectId.value)
currentProjectName.value = hit?.name || currentProjectId.value
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(enriched.map(item => item.id)))
})()
}
const clearCloseCountdown = () => {
if (!closeCountdownTimer) return
clearInterval(closeCountdownTimer)
closeCountdownTimer = null
}
const startCloseCountdown = () => {
clearCloseCountdown()
closeCountdown.value = 10
closeCountdownTimer = setInterval(() => {
closeCountdown.value -= 1
if (closeCountdown.value <= 0) {
clearCloseCountdown()
try {
closePage()
} catch {
// 部分浏览器会阻止关闭,保留阻断页。
}
}
}, 1000)
}
const isConflictProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean }) => {
if (isConflictProjectOpen(projectId)) return
const href = buildProjectUrl(projectId, options)
window.open(href, '_blank', 'noopener')
}
const createProjectAndOpen = () => {
refreshConflictProjectList()
openProjectInNewTab(DEFAULT_PROJECT_ID, { newProject: true })
}
const syncRouteRequestFlags = () => {
try {
const url = new URL(window.location.href)
isNewProjectRequest.value = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
isForceHomeRequest.value = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1'
} catch {
isNewProjectRequest.value = false
isForceHomeRequest.value = false
}
}
const formatProjectEditedTime = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (num: number) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const handleReleaseProjectLock = () => {
if (!releaseLock) return
releaseLock()
releaseLock = null
lockConflict.value = false
}
const handleProjectDeleted = (deletedProjectId: string) => {
if (String(deletedProjectId || '').trim() !== currentProjectId.value) return
if (isHandlingDeletedProject) return
isHandlingDeletedProject = true
handleReleaseProjectLock()
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
try {
window.close()
} catch {
// ignore and fallback to redirect
}
window.setTimeout(() => {
window.location.href = href
}, 120)
}
const handleResetAll = () => {
if (isHandlingGlobalReset) return
isHandlingGlobalReset = true
handleReleaseProjectLock()
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
try {
window.close()
} catch {
// ignore and fallback to redirect
}
window.setTimeout(() => {
window.location.href = href
}, 120)
}
onMounted(() => {
currentProjectId.value = ensureProjectIdInUrl()
syncRouteRequestFlags()
refreshConflictProjectList()
if (!isForceHomeRequest.value && currentProjectId.value !== QUICK_PROJECT_ID && currentProjectId.value !== DEFAULT_PROJECT_ID) {
initCurrentProjectLock()
}
window.addEventListener('home-import-selected', handleImportComplete)
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted)
stopResetAllListener = listenResetAll(handleResetAll)
waitForHydration('tabs').then(() => {
if (isForceHomeRequest.value) {
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
}
if (!tabStore.hasCompletedSetup && !isNewProjectRequest.value && !isForceHomeRequest.value) {
const hasProjects = listProjects().length > 0
if (hasProjects && currentProjectId.value !== DEFAULT_PROJECT_ID) {
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
tabStore.hasCompletedSetup = true
} else {
tabStore.enterWorkspace({
id: PROJECT_TAB_ID,
title: t('home.cards.projectBudget'),
componentName: 'ProjectCalcView'
})
tabStore.hasCompletedSetup = true
}
}
}
if (tabStore.hasCompletedSetup && Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
const activeId = typeof tabStore.activeTabId === 'string' ? tabStore.activeTabId : ''
const hasActive = Boolean(activeId) && tabStore.tabs.some(tab => tab.id === activeId)
if (!hasActive) {
tabStore.activeTabId = tabStore.tabs[0]?.id
}
}
if (!releaseLock) {
lockConflict.value = false
clearCloseCountdown()
}
isReady.value = true
})
})
onBeforeUnmount(() => {
clearCloseCountdown()
window.removeEventListener('home-import-selected', handleImportComplete)
window.removeEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
if (stopProjectDeletedListener) {
stopProjectDeletedListener()
stopProjectDeletedListener = null
}
if (stopResetAllListener) {
stopResetAllListener()
stopResetAllListener = null
}
if (releaseLock) {
releaseLock()
releaseLock = null
}
})
</script> </script>
<template> <template>
<template v-if="isReady"> <tab></tab>
<div
v-if="lockConflict"
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
>
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">{{ t('app.projectConflict.title') }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-600">
{{ t('app.projectConflict.desc', { name: currentProjectName }) }}
</p>
<p class="mt-2 text-xs text-slate-500">
{{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
</p>
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
<button
v-for="project in conflictProjectList"
:key="project.id"
type="button"
class="flex w-full items-center justify-between rounded-md border border-transparent bg-white px-3 py-2 text-left text-sm transition"
:class="isConflictProjectOpen(project.id) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-200 hover:bg-slate-100'"
:disabled="isConflictProjectOpen(project.id)"
@click="openProjectInNewTab(project.id)"
>
<span class="font-medium text-slate-700">
{{ project.name }}
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">{{ t('app.projectConflict.opened') }}</span>
</span>
<span class="text-xs text-slate-500">{{ t('app.projectConflict.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</span>
</button>
</div>
<div class="mt-4 flex items-center gap-2">
<button
type="button"
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100"
@click="createProjectAndOpen"
>
{{ t('app.projectConflict.createAndOpen') }}
</button>
<button
type="button"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
:class="isConflictProjectOpen('default') ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-100'"
:disabled="isConflictProjectOpen('default')"
@click="openProjectInNewTab('default')"
>
{{ t('app.projectConflict.openDefault') }}
</button>
</div>
</div>
</div>
<template v-else>
<HomeEntryView v-if="showHomeEntry" />
<Tab v-else />
</template>
</template>
</template> </template>
<style scoped>
</style>
+1 -7
View File
@@ -23,7 +23,7 @@ const delegatedProps = reactiveOmit(props, "class")
> >
<ScrollAreaViewport <ScrollAreaViewport
data-slot="scroll-area-viewport" data-slot="scroll-area-viewport"
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 scrollArea-full" class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
> >
<slot /> <slot />
</ScrollAreaViewport> </ScrollAreaViewport>
@@ -31,9 +31,3 @@ const delegatedProps = reactiveOmit(props, "class")
<ScrollAreaCorner /> <ScrollAreaCorner />
</ScrollAreaRoot> </ScrollAreaRoot>
</template> </template>
<style scoped>
:deep(.scrollArea-full > *) {
height: 100%;
}
</style>
@@ -1,33 +0,0 @@
<script setup lang="ts">
import type { TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent as RekaTooltipContent, TooltipPortal } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 6
}
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TooltipPortal>
<RekaTooltipContent
v-bind="delegatedProps"
:class="
cn(
'z-[90] rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md',
props.class
)
"
>
<slot />
<TooltipArrow class="fill-popover" />
</RekaTooltipContent>
</TooltipPortal>
</template>
-2
View File
@@ -1,2 +0,0 @@
export { default as TooltipContent } from './TooltipContent.vue'
export { TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
@@ -0,0 +1,60 @@
<template>
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
<TypeLine
scene="ht-tab"
:title="`合同段:${contractName}`"
storage-key="project-active-cat"
default-category="info"
:categories="xmCategories"
/>
</template>
<script setup lang="ts">
import { markRaw, defineAsyncComponent, defineComponent, h, type Component } from 'vue';
import TypeLine from '@/layout/typeLine.vue';
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
const props = defineProps<{
contractId: string; // 合同ID(必传)
contractName: string; // 合同名称(必传)
}>();
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
interface XmCategoryItem {
key: string;
label: string;
component: Component; // 标记为 Vue 组件类型
}
// 3. 优化异步组件导入(添加加载失败兜底 + 类型标注)
const htView = markRaw(
defineComponent({
name: 'HtInfoWithProps',
setup() {
const AsyncHtInfo = defineAsyncComponent({
loader: () => import('@/components/views/htInfo.vue'),
onError: (err) => {
console.error('加载 htInfo 组件失败:', err);
}
});
return () => h(AsyncHtInfo, { contractId: props.contractId });
}
})
);
const zxfwView = markRaw(
defineAsyncComponent({
loader: () => import('@/components/views/zxFw.vue'),
// 可选:加载失败时的兜底组件
onError: (err) => {
console.error('加载 Ht 组件失败:', err);
}
})
);
// 4. 给分类数组添加严格类型标注
const xmCategories: XmCategoryItem[] = [
{ key: 'info', label: '规模信息', component: htView },
{ key: 'contract', label: '咨询服务', component: zxfwView }
];
</script>
+292
View File
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import draggable from 'vuedraggable'
import localforage from 'localforage'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab'
import { Edit3, Plus, Trash2, X } from 'lucide-vue-next'
interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
const STORAGE_KEY = 'ht-card-v1'
const tabStore = useTabStore()
const contracts = ref<ContractItem[]>([])
const showCreateModal = ref(false)
const contractNameInput = ref('')
const editingContractId = ref<string | null>(null)
const toastText = ref('')
const showToast = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
const modalOffset = ref({ x: 0, y: 0 })
let dragStartX = 0
let dragStartY = 0
let baseOffsetX = 0
let baseOffsetY = 0
const buildDefaultContracts = (): ContractItem[] => [
]
const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({
...item,
order: index,
createdAt: item.createdAt || new Date().toISOString()
}))
const formatDateTime = (value: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const notify = (text: string) => {
toastText.value = text
showToast.value = true
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
showToast.value = false
}, 1600)
}
const saveContracts = async () => {
try {
contracts.value = normalizeOrder(contracts.value)
await localforage.setItem(STORAGE_KEY, JSON.parse(JSON.stringify(contracts.value)))
} catch (error) {
console.error('save contracts failed:', error)
}
}
const loadContracts = async () => {
try {
const saved = await localforage.getItem<ContractItem[]>(STORAGE_KEY)
if (!saved || saved.length === 0) {
contracts.value = buildDefaultContracts()
await saveContracts()
return
}
const sorted = [...saved].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
contracts.value = normalizeOrder(sorted)
} catch (error) {
console.error('load contracts failed:', error)
contracts.value = buildDefaultContracts()
}
}
const openCreateModal = () => {
editingContractId.value = null
contractNameInput.value = ''
modalOffset.value = { x: 0, y: 0 }
showCreateModal.value = true
}
const openEditModal = (item: ContractItem) => {
editingContractId.value = item.id
contractNameInput.value = item.name
modalOffset.value = { x: 0, y: 0 }
showCreateModal.value = true
}
const closeCreateModal = () => {
showCreateModal.value = false
editingContractId.value = null
contractNameInput.value = ''
modalOffset.value = { x: 0, y: 0 }
}
const createContract = async () => {
const name = contractNameInput.value.trim()
if (!name) return
if (editingContractId.value) {
contracts.value = contracts.value.map(item =>
item.id === editingContractId.value ? { ...item, name } : item
)
await saveContracts()
notify('编辑成功')
closeCreateModal()
return
}
contracts.value = [
...contracts.value,
{
id: `ct-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name,
order: contracts.value.length,
createdAt: new Date().toISOString()
}
]
await saveContracts()
notify('新建成功')
closeCreateModal()
}
const deleteContract = async (id: string) => {
contracts.value = contracts.value.filter(item => item.id !== id)
await saveContracts()
notify('删除成功')
}
const handleDragEnd = async () => {
await saveContracts()
notify('排序完成')
}
const handleCardClick = (item: ContractItem) => {
tabStore.openTab({
id: `contract-${item.id}`,
title: `合同段${item.name}`,
componentName: 'ContractDetailView',
props: { contractId: item.id, contractName: item.name }
})
}
const onDragMove = (event: MouseEvent) => {
modalOffset.value = {
x: baseOffsetX + (event.clientX - dragStartX),
y: baseOffsetY + (event.clientY - dragStartY)
}
}
const stopDrag = () => {
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('mouseup', stopDrag)
}
const startDrag = (event: MouseEvent) => {
dragStartX = event.clientX
dragStartY = event.clientY
baseOffsetX = modalOffset.value.x
baseOffsetY = modalOffset.value.y
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', stopDrag)
}
onMounted(async () => {
await loadContracts()
})
onBeforeUnmount(() => {
stopDrag()
if (toastTimer) clearTimeout(toastTimer)
void saveContracts()
})
</script>
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h3 class="text-lg font-bold">合同段列表</h3>
<Button @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" />
添加合同段
</Button>
</div>
<draggable
v-model="contracts"
item-key="id"
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
animation="200"
@end="handleDragEnd"
>
<template #item="{ element }">
<Card
class="group cursor-pointer transition-colors hover:border-primary"
@click="handleCardClick(element)"
>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
<span class="ml-2">{{ element.name }}</span>
</CardTitle>
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click.stop="openEditModal(element)"
>
<Edit3 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click.stop="deleteContract(element.id)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</CardHeader>
<div class="px-6 pb-4 text-xs text-muted-foreground">
创建时间:{{ formatDateTime(element.createdAt) }}
</div>
</Card>
</template>
</draggable>
<div
v-if="showToast"
class="fixed left-1/2 top-6 z-[60] -translate-x-1/2 rounded-full border border-sky-100 bg-white/95 px-4 py-2 text-sm text-slate-700 shadow-md backdrop-blur-sm"
>
{{ toastText }}
</div>
<div
v-if="showCreateModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
>
<div
class="w-full max-w-md rounded-lg border bg-background shadow-xl"
:style="{ transform: `translate(${modalOffset.x}px, ${modalOffset.y}px)` }"
>
<div
class="flex items-center justify-between border-b px-5 py-4 cursor-move select-none"
@mousedown.prevent="startDrag"
>
<h4 class="text-base font-semibold">
{{ editingContractId ? '编辑合同段' : '新增合同段' }}
</h4>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-2 px-5 py-4">
<label class="block text-sm font-medium text-foreground">合同段名称</label>
<input
v-model="contractNameInput"
type="text"
placeholder="请输入合同段名称"
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
@keydown.enter="createContract"
/>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
<Button variant="outline" @click="closeCreateModal">取消</Button>
<Button :disabled="!contractNameInput.trim()" @click="createContract">
{{ editingContractId ? '保存' : '确定' }}
</Button>
</div>
</div>
</div>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<template>
<TypeLine
scene="xm-tab"
title="项目配置"
storage-key="project-active-cat"
default-category="info"
:categories="xmCategories"
/>
</template>
<script setup lang="ts">
import { markRaw, defineAsyncComponent } from 'vue'
import TypeLine from '@/layout/typeLine.vue'
const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
const xmCategories = [
{ key: 'info', label: '基础信息', component: xmView },
{ key: 'contract', label: '合同段管理', component: htView }
]
</script>
+428
View File
@@ -0,0 +1,428 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import 'ag-grid-enterprise'
import {
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
code: string
name: string
}
interface DictGroup {
code: string
name: string
children: DictLeaf[]
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
amount: number | null
landArea: number | null
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const detailRows = ref<DetailRow[]>([])
const detailDict: DictGroup[] = [
{
code: 'E1',
name: '交通运输工程通用专业',
children: [
{ code: 'E1-1', name: '征地(用海)补偿' },
{ code: 'E1-2', name: '拆迁补偿' },
{ code: 'E1-3', name: '迁改工程' },
{ code: 'E1-4', name: '工程建设其他费' }
]
},
{
code: 'E2',
name: '公路工程专业',
children: [
{ code: 'E2-1', name: '临时工程' },
{ code: 'E2-2', name: '路基工程' },
{ code: 'E2-3', name: '路面工程' },
{ code: 'E2-4', name: '桥涵工程' },
{ code: 'E2-5', name: '隧道工程' },
{ code: 'E2-6', name: '交叉工程' },
{ code: 'E2-7', name: '机电工程' },
{ code: 'E2-8', name: '交通安全设施工程' },
{ code: 'E2-9', name: '绿化及环境保护工程' },
{ code: 'E2-10', name: '房建工程' }
]
},
{
code: 'E3',
name: '铁路工程专业',
children: [
{ code: 'E3-1', name: '大型临时设施和过渡工程' },
{ code: 'E3-2', name: '路基工程' },
{ code: 'E3-3', name: '桥涵工程' },
{ code: 'E3-4', name: '隧道及明洞工程' },
{ code: 'E3-5', name: '轨道工程' },
{ code: 'E3-6', name: '通信、信号、信息及灾害监测工程' },
{ code: 'E3-7', name: '电力及电力牵引供电工程' },
{ code: 'E3-8', name: '房建工程(房屋建筑及附属工程)' },
{ code: 'E3-9', name: '装饰装修工程' }
]
},
{
code: 'E4',
name: '水运工程专业',
children: [
{ code: 'E4-1', name: '临时工程' },
{ code: 'E4-2', name: '土建工程' },
{ code: 'E4-3', name: '机电与金属结构工程' },
{ code: 'E4-4', name: '设备工程' },
{ code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)' }
]
}
]
const codeNameMap = new Map<string, string>()
for (const group of detailDict) {
codeNameMap.set(group.code, group.name)
for (const child of group.children) {
codeNameMap.set(child.code, child.name)
}
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const child of group.children) {
rows.push({
id: `row-${child.code}`,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
path: [group.code, child.code]
})
}
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.majorCode, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.majorCode)
if (!fromDb) return row
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const code = String(params.value || '')
const name = codeNameMap.get(code) || ''
return name ? `${code} ${name}` : code
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(DB_KEY.value, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
// const handleBeforeUnload = () => {
// void saveToIndexedDB()
// }
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
// window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
// window.removeEventListener('beforeunload', handleBeforeUnload)
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
} catch (e) {
// 解析失败时返回原始值,无需额外处理
}
return params.value;
};
</script>
<template>
<div class="space-y-6">
<div class="rounded-lg border bg-card xmMx">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
</div>
</div>
</div>
</template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>
+439
View File
@@ -0,0 +1,439 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import 'ag-grid-enterprise'
import {
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
code: string
name: string
}
interface DictGroup {
code: string
name: string
children: DictLeaf[]
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
amount: number | null
landArea: number | null
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const DB_KEY = 'xm-info-v3'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const projectName = ref(DEFAULT_PROJECT_NAME)
const detailRows = ref<DetailRow[]>([])
const detailDict: DictGroup[] = [
{
code: 'E1',
name: '交通运输工程通用专业',
children: [
{ code: 'E1-1', name: '征地(用海)补偿' },
{ code: 'E1-2', name: '拆迁补偿' },
{ code: 'E1-3', name: '迁改工程' },
{ code: 'E1-4', name: '工程建设其他费' }
]
},
{
code: 'E2',
name: '公路工程专业',
children: [
{ code: 'E2-1', name: '临时工程' },
{ code: 'E2-2', name: '路基工程' },
{ code: 'E2-3', name: '路面工程' },
{ code: 'E2-4', name: '桥涵工程' },
{ code: 'E2-5', name: '隧道工程' },
{ code: 'E2-6', name: '交叉工程' },
{ code: 'E2-7', name: '机电工程' },
{ code: 'E2-8', name: '交通安全设施工程' },
{ code: 'E2-9', name: '绿化及环境保护工程' },
{ code: 'E2-10', name: '房建工程' }
]
},
{
code: 'E3',
name: '铁路工程专业',
children: [
{ code: 'E3-1', name: '大型临时设施和过渡工程' },
{ code: 'E3-2', name: '路基工程' },
{ code: 'E3-3', name: '桥涵工程' },
{ code: 'E3-4', name: '隧道及明洞工程' },
{ code: 'E3-5', name: '轨道工程' },
{ code: 'E3-6', name: '通信、信号、信息及灾害监测工程' },
{ code: 'E3-7', name: '电力及电力牵引供电工程' },
{ code: 'E3-8', name: '房建工程(房屋建筑及附属工程)' },
{ code: 'E3-9', name: '装饰装修工程' }
]
},
{
code: 'E4',
name: '水运工程专业',
children: [
{ code: 'E4-1', name: '临时工程' },
{ code: 'E4-2', name: '土建工程' },
{ code: 'E4-3', name: '机电与金属结构工程' },
{ code: 'E4-4', name: '设备工程' },
{ code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)' }
]
}
]
const codeNameMap = new Map<string, string>()
for (const group of detailDict) {
codeNameMap.set(group.code, group.name)
for (const child of group.children) {
codeNameMap.set(child.code, child.name)
}
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const child of group.children) {
rows.push({
id: `row-${child.code}`,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
path: [group.code, child.code]
})
}
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.majorCode, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.majorCode)
if (!fromDb) return row
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const code = String(params.value || '')
const name = codeNameMap.get(code) || ''
return name ? `${code} ${name}` : code
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
try {
const payload: XmInfoState = {
projectName: projectName.value,
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(DB_KEY, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY)
console.log(data)
if (data) {
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
// const handleBeforeUnload = () => {
// void saveToIndexedDB()
// }
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 1000)
}
watch(projectName, schedulePersist)
onMounted(async () => {
await loadFromIndexedDB()
// window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
// window.removeEventListener('beforeunload', handleBeforeUnload)
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
} catch (e) {
// 解析失败时返回原始值,无需额外处理
}
return params.value;
};
</script>
<template>
<div class="space-y-6">
<div class="rounded-lg border bg-card p-4 shadow-sm">
<label class="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<input
v-model="projectName"
type="text"
placeholder="请输入项目名称"
class="h-10 w-full max-w-xl rounded-md border bg-background px-3 text-sm outline-none ring-offset-background transition focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div class="rounded-lg border bg-card xmMx">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">项目明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
</div>
</div>
</div>
</template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import 'ag-grid-enterprise'
import {
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
code: string
name: string
}
interface DictGroup {
code: string
name: string
children: DictLeaf[]
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
amount: number | null
landArea: number | null
path: string[]
}
interface XmInfoState {
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `zxFW-${props.contractId}`)
const detailRows = ref<DetailRow[]>([])
const detailDict: DictGroup[] =[]
const codeNameMap = new Map<string, string>()
for (const group of detailDict) {
codeNameMap.set(group.code, group.name)
for (const child of group.children) {
codeNameMap.set(child.code, child.name)
}
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const child of group.children) {
rows.push({
id: `row-${child.code}`,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
path: [group.code, child.code]
})
}
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.majorCode, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.majorCode)
if (!fromDb) return row
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const code = String(params.value || '')
const name = codeNameMap.get(code) || ''
return name ? `${code} ${name}` : code
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
try {
const payload: XmInfoState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(DB_KEY.value, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
// const handleBeforeUnload = () => {
// void saveToIndexedDB()
// }
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
// window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
// window.removeEventListener('beforeunload', handleBeforeUnload)
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
} catch (e) {
// 解析失败时返回原始值,无需额外处理
}
return params.value;
};
</script>
<template>
<div class="space-y-6">
<div class="rounded-lg border bg-card p-4 shadow-sm">
<label class="mb-2 block text-sm font-medium text-foreground">选择服务</label>
</div>
<div class="rounded-lg border bg-card xmMx">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">咨询服务</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
</div>
</div>
</div>
</template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,27 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { getAdditionalWorkListEntries } from '@/sql'
const props = defineProps<{
contractId: string
contractName?: string
}>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
const additionalWorkNames = computed(() =>
getAdditionalWorkListEntries(locale.value).map(item => ({ id: item?.id, name: item?.name }))
)
</script>
<template>
<HtFeeMethodGrid
:title="t('htFee.additionalTitle')"
:storageKey="STORAGE_KEY"
:contract-id="props.contractId"
:contract-name="props.contractName"
:fixed-names="additionalWorkNames"
/>
</template>
-88
View File
@@ -1,88 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface HtBaseInfoState {
quality: string
duration: string
}
const { t } = useI18n()
const DEFAULT_QUALITY = t('htBaseInfo.defaultQuality')
const DEFAULT_DURATION = ''
const props = defineProps<{
contractId: string
}>()
const zxFwPricingStore = useZxFwPricingStore()
const storageKey = () => `ht-base-info-${props.contractId}`
const quality = ref(DEFAULT_QUALITY)
const duration = ref('')
const lastSavedSnapshot = ref('')
const saveForm = (force = false) => {
const payload: HtBaseInfoState = {
quality: quality.value,
duration: duration.value
}
const snapshot = JSON.stringify(payload)
if (!force && snapshot === lastSavedSnapshot.value) return
zxFwPricingStore.setKeyState(storageKey(), payload)
lastSavedSnapshot.value = snapshot
}
const loadForm = async () => {
const data = await zxFwPricingStore.loadKeyState<HtBaseInfoState>(storageKey())
const hasStoredValue = Boolean(
data && (Object.prototype.hasOwnProperty.call(data, 'quality') || Object.prototype.hasOwnProperty.call(data, 'duration'))
)
quality.value = typeof data?.quality === 'string' && data.quality ? data.quality : DEFAULT_QUALITY
duration.value = typeof data?.duration === 'string' ? data.duration : (hasStoredValue ? '' : DEFAULT_DURATION)
lastSavedSnapshot.value = JSON.stringify({ quality: quality.value, duration: duration.value })
if (!hasStoredValue) saveForm(true)
}
watch([quality, duration], () => {
saveForm()
})
onMounted(() => {
void loadForm()
})
onBeforeUnmount(() => {
saveForm(true)
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card p-5">
<div class="mb-4 border-b pb-3">
<h3 class="text-sm font-semibold text-foreground">{{ t('htBaseInfo.title') }}</h3>
</div>
<div class="grid grid-cols-1 gap-5">
<label class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.qualityLabel') }}</div>
<textarea
v-model="quality"
rows="3"
:placeholder="t('htBaseInfo.qualityPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</label>
<label class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.durationLabel') }}</div>
<textarea
v-model="duration"
rows="3"
:placeholder="t('htBaseInfo.durationPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</label>
</div>
</div>
</div>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
const props = defineProps<{
contractId: string
projectInfoKey?: string
parentStorageKey?: string
}>()
interface XmBaseInfoState {
projectIndustry?: string
}
type ServiceItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('')
const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
projectIndustry.value = ''
}
}
const filteredServiceDict = computed<Record<string, ServiceItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = getServiceDictEntries()
.filter(({ item }) => isIndustryEnabledByType(item, getIndustryTypeValue(industry)))
.map(({ id, item }) => [id, item as ServiceItem] as const)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<XmFactorGrid
:title="t('htFactors.consultCategoryTitle')"
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'"
:dict="filteredServiceDict"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true"
/>
</template>
@@ -1,598 +0,0 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowDataUpdatedEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { additionalWorkList, reserveList } from '@/sql'
type SummaryRowType = 'service' | 'additional' | 'reserve' | 'total'
interface SummaryRow {
id: string
rowType: SummaryRowType
code: string | { richText?: Array<{ text?: string; font?: { italic?: boolean; vertAlign?: string } }> }
name: string
investScale: number | null
landScale: number | null
workload: number | null
hourly: number | null
subtotal: number | null
finalFee: number | null
}
interface RateMethodStateLike {
rate?: unknown
budgetFee?: unknown
}
interface HourlyMethodRowLike {
serviceBudget?: unknown
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
}
interface HourlyMethodStateLike {
detailRows?: HourlyMethodRowLike[]
}
interface QuantityMethodRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface QuantityMethodStateLike {
detailRows?: QuantityMethodRowLike[]
}
const props = defineProps<{
contractId: string
}>()
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
const gridApi = shallowRef<GridApi<SummaryRow> | null>(null)
const rowData = ref<SummaryRow[]>([])
const explanationText = ref('')
let reloadTimer: ReturnType<typeof setTimeout> | null = null
/**
* 对数值数组求和(保留3位小数)
* @param values 数值数组(可包含 null/undefined)
* @returns 求和结果(无有效值时返回 null)
*/
const sum3 = (values: Array<number | null | undefined>) => {
const valid = values.filter((v): v is number => toFiniteNumberOrNull(v) != null)
if (valid.length === 0) return null
return roundTo(valid.reduce((a, b) => a + b, 0), 3)
}
/**
* 计算时工法的总费用
* 遍历时工法明细行,优先使用 serviceBudget,否则通过 adoptedBudgetUnitPrice × personnelCount × workdayCount 计算
* @param state 时工法状态对象
* @returns 总费用(保留3位小数,无有效值时返回 null)
*/
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
const rowBudget = toFiniteNumberOrNull(row?.serviceBudget)
if (rowBudget != null) {
total += rowBudget
hasValid = true
continue
}
const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteNumberOrNull(row?.personnelCount)
const workday = toFiniteNumberOrNull(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? roundTo(total, 3) : null
}
/**
* 计算工程量清单法的总费用
* 遍历工程量清单明细行,优先使用 budgetFee,否则通过 quantity × unitPrice 计算
* 跳过固定小计行(fee-subtotal-fixed)
* @param state 工程量清单法状态对象
* @returns 总费用(保留3位小数,无有效值时返回 null)
*/
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteNumberOrNull(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteNumberOrNull(row?.quantity)
const unitPrice = toFiniteNumberOrNull(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? roundTo(total, 3) : null
}
/**
* 加载合同段某行的三种计费方式(费率法、时工法、工程量清单法)并汇总
* @param mainStorageKey 主存储键(如 htExtraFee-{contractId}-additional-work)
* @param rowId 行ID
* @returns 包含subtotal(小计)、m0(费率法详情)、m4(时工法详情)、m5(工程量清单法详情)的对象
*/
const loadHtMethodSummaryByRow = async (mainStorageKey: string, rowId: string): Promise<{
subtotal: number | null
m0: { coe: string; fee: number } | null
m4: { fee: number } | null
m5: { fee: number } | null
}> => {
const [rateState, hourlyState, quantityState] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
])
const rateFee = toFiniteNumberOrNull(rateState?.budgetFee)
const rateValue = toFiniteNumberOrNull(rateState?.rate)
const hourlyFee = sumHourlyMethodFee(hourlyState)
const quantityFee = sumQuantityMethodFee(quantityState)
const subtotal = sum3([rateFee, hourlyFee, quantityFee])
return {
subtotal,
m0: rateFee != null
? {
coe: rateValue == null ? '--' : String(rateValue),
fee: roundTo(rateFee, 2)
}
: null,
m4: hourlyFee != null ? { fee: roundTo(hourlyFee, 2) } : null,
m5: quantityFee != null ? { fee: roundTo(quantityFee, 2) } : null
}
}
/**
* 构建附加工作或预备费的汇总行数据
* @param rowType 行类型('additional' 附加工作 或 'reserve' 预备费)
* @param list 费用项目列表(包含 id、name、code)
* @returns 包含 rows(汇总行数组)和 explainLines(说明文本数组)的对象
*/
const buildFeeRows = async (
rowType: 'additional' | 'reserve',
list: Array<{ id: string | number; name: string; code: unknown }>
): Promise<{ rows: SummaryRow[]; explainLines: string[] }> => {
const mainStorageKey = `htExtraFee-${props.contractId}-${rowType === 'additional' ? 'additional-work' : 'reserve'}`
await zxFwPricingStore.loadHtFeeMainState(mainStorageKey)
const tuples = await Promise.all(
list.map(async item => {
const summary = await loadHtMethodSummaryByRow(mainStorageKey, String(item.id))
const lineParts: string[] = []
if (summary.m0) {
lineParts.push(t('htSummary.explainByRate', { rate: summary.m0.coe, fee: summary.m0.fee }))
}
if (summary.m4) {
lineParts.push(t('htSummary.explainByHourly', { fee: summary.m4.fee }))
}
if (summary.m5) {
lineParts.push(t('htSummary.explainByQuantity', { fee: summary.m5.fee }))
}
const linePrefix = rowType === 'additional' ? t('htSummary.additionalPrefix') : t('htSummary.reservePrefix')
const explainLine = lineParts.length > 0 ? `${linePrefix}-${item.name}:${lineParts.join(';')}` : ''
const row: SummaryRow = {
id: `${rowType}-${item.id}`,
rowType,
code: item.code as SummaryRow['code'],
name: item.name,
investScale: null,
landScale: null,
workload: null,
hourly: null,
subtotal: summary.subtotal,
finalFee: summary.subtotal
}
return { row, explainLine }
})
)
const rows = tuples.map(item => item.row).filter(row => row.subtotal != null)
const explainLines = tuples
.filter(item => item.row.subtotal != null && item.explainLine)
.map(item => item.explainLine)
return { rows, explainLines }
}
/**
* 构建咨询服务汇总行数据
* 从合同状态中筛选已选中的服务项,转换为 SummaryRow 格式
* @returns 咨询服务汇总行数组
*/
const buildServiceRows = (): SummaryRow[] => {
const contractState = zxFwPricingStore.getContractState(props.contractId)
const selectedSet = new Set((contractState?.selectedIds || []).map(id => String(id)))
const rows = Array.isArray(contractState?.detailRows) ? contractState!.detailRows : []
return rows
.filter(row => String(row.id) !== 'fixed-budget-c' && selectedSet.has(String(row.id)))
.map(row => ({
id: `service-${row.id}`,
rowType: 'service' as const,
code: row.code || '',
name: row.name || '',
investScale: toFiniteNumberOrNull(row.investScale),
landScale: toFiniteNumberOrNull(row.landScale),
workload: toFiniteNumberOrNull(row.workload),
hourly: toFiniteNumberOrNull(row.hourly),
subtotal: toFiniteNumberOrNull(row.subtotal),
finalFee: toFiniteNumberOrNull((row as { finalFee?: unknown }).finalFee) ?? toFiniteNumberOrNull(row.subtotal)
}))
}
/**
* 重新加载所有汇总行数据
* 包括:咨询服务、附加工作、预备费
* 同时生成说明文本
*/
const reloadRows = async () => {
await zxFwPricingStore.loadContract(props.contractId)
const [additionalResult, reserveResult] = await Promise.all([
buildFeeRows(
'additional',
additionalWorkList.map(item => ({ id: item.id, name: item.name, code: item.code }))
),
buildFeeRows(
'reserve',
reserveList.map(item => ({ id: item.id, name: item.name, code: item.code }))
)
])
rowData.value = [...buildServiceRows(), ...additionalResult.rows, ...reserveResult.rows]
const lines = [...additionalResult.explainLines, ...reserveResult.explainLines]
explanationText.value = lines.join('\n')
}
/**
* 延迟重新加载汇总数据(防抖 80ms)
* 避免频繁触发数据加载
*/
const scheduleReload = () => {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
void reloadRows()
}, 80)
}
/**
* 刷新签名(用于 watch 监听数据变化)
* 监听合同数据、附加工作、预备费的主状态和方法状态变化
*/
const refreshSignature = computed(() => {
const additionalKey = `htExtraFee-${props.contractId}-additional-work`
const reserveKey = `htExtraFee-${props.contractId}-reserve`
return JSON.stringify({
contract: zxFwPricingStore.contracts[props.contractId] || null,
addMain: zxFwPricingStore.htFeeMainStates[additionalKey] || null,
reserveMain: zxFwPricingStore.htFeeMainStates[reserveKey] || null,
addMethods: zxFwPricingStore.htFeeMethodStates[additionalKey] || null,
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveKey] || null
})
})
/**
* 计算总计行数据
* 对所有汇总行的各字段进行求和
*/
const totalRow = computed<SummaryRow>(() => {
const sumField = (pick: (row: SummaryRow) => number | null | undefined) =>
sum3(rowData.value.map(pick))
return {
id: 'summary-total-row',
rowType: 'total',
code: '',
name: t('htSummary.total'),
investScale: sumField(row => row.investScale),
landScale: sumField(row => row.landScale),
workload: sumField(row => row.workload),
hourly: sumField(row => row.hourly),
subtotal: null,
finalFee: sumField(row => row.finalFee)
}
})
const RichCodeRenderer = defineComponent({
name: 'RichCodeRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<SummaryRow>>,
required: true
}
},
setup(props) {
return () => {
if (props.params.data?.rowType === 'total') {
return h('span', props.params.data.name || t('htSummary.total'))
}
const value = props.params.value as SummaryRow['code']
if (!value || typeof value === 'string') {
return h('span', value || '')
}
const runs = Array.isArray(value.richText) ? value.richText : []
return h(
'span',
{ class: 'inline-flex items-baseline gap-[1px]' },
runs.map((run, idx) =>
h(
'span',
{
key: `${idx}-${run.text || ''}`,
style: {
fontStyle: run?.font?.italic ? 'italic' : 'normal',
verticalAlign: run?.font?.vertAlign === 'subscript' ? 'sub' : run?.font?.vertAlign === 'superscript' ? 'super' : 'baseline',
fontSize: run?.font?.vertAlign ? '0.85em' : '1em'
}
},
run?.text || ''
)
)
)
}
}
})
const columnDefs: ColDef<SummaryRow>[] = [
{
headerName: t('htSummary.columns.code'),
field: 'code',
minWidth: 90,
maxWidth: 140,
colSpan: params => (params.data?.rowType === 'total' ? 2 : 1),
valueFormatter: params => {
if (params.data?.rowType === 'total') return params.data.name || t('htSummary.total')
return typeof params.value === 'string' ? params.value : ''
},
cellRenderer: RichCodeRenderer
},
{
headerName: t('htSummary.columns.name'),
field: 'name',
minWidth: 220,
flex: 2,
wrapText: true,
autoHeight: true,
cellStyle: { 'line-height': 1.6 }
},
{
headerName: t('htSummary.columns.investScale'),
field: 'investScale',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
colSpan: params => {
if (!params.data) return 1
if (params.data.rowType === 'total') return 5
if (params.data.rowType === 'additional' || params.data.rowType === 'reserve') return 5
return 1
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.landScale'),
field: 'landScale',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.workload'),
field: 'workload',
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.hourly'),
field: 'hourly',
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
}
},
{
headerName: t('htSummary.columns.subtotal'),
field: 'subtotal',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
headerName: t('htSummary.columns.finalFee'),
field: 'finalFee',
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const summaryGridOptions: GridOptions<SummaryRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
domLayout: 'autoHeight',
suppressNoRowsOverlay: true,
rowSelection: {
mode: 'singleRow',
checkboxes: false,
enableClickSelection: false
},
getRowId: params => params.data.id,
getRowClass: params => (params.data?.rowType === 'additional' || params.data?.rowType === 'reserve' ? 'ht-summary-fee-row' : '')
}
/**
* AG Grid 准备就绪回调
* 初始化 gridApi 并同步自动行高
* @param event Grid 准备就绪事件
*/
const onGridReady = (event: GridReadyEvent<SummaryRow>) => {
gridApi.value = event.api
void syncAutoRowHeights()
}
/**
* 检查 Grid API 是否可用
* @param api Grid API 实例
* @returns API 是否有效
*/
const isGridApiAlive = (api: GridApi<SummaryRow> | null | undefined): api is GridApi<SummaryRow> =>
Boolean(api && !api.isDestroyed?.())
/**
* 同步自动行高
* 触发 AG Grid 重新计算行高并刷新单元格
*/
const syncAutoRowHeights = async () => {
await nextTick()
const api = gridApi.value
if (!isGridApiAlive(api)) return
api.onRowHeightChanged()
api.refreshCells({ force: true })
}
/**
* 首次数据渲染完成回调
* 同步自动行高以确保显示正确
* @param _event 首次数据渲染事件
*/
const onFirstDataRendered = (_event: FirstDataRenderedEvent<SummaryRow>) => {
void syncAutoRowHeights()
}
/**
* 行数据更新回调
* 同步自动行高以适应新数据
* @param _event 行数据更新事件
*/
const onRowDataUpdated = (_event: RowDataUpdatedEvent<SummaryRow>) => {
void syncAutoRowHeights()
}
watch(refreshSignature, (next, prev) => {
if (next === prev) return
scheduleReload()
})
watch(
() => rowData.value.length,
() => {
void syncAutoRowHeights()
}
)
onMounted(() => {
void reloadRows()
})
onActivated(() => {
void reloadRows()
})
onBeforeUnmount(() => {
if (isGridApiAlive(gridApi.value)) {
gridApi.value.stopEditing()
}
gridApi.value = null
})
</script>
<template>
<div class="flex flex-col gap-3">
<div class="rounded-lg border bg-card xmMx flex flex-col overflow-hidden">
<div class="flex items-center justify-between border-b px-3 py-2">
<h3 class="text-xs font-semibold text-foreground leading-none">
{{ t('htSummary.title') }}
</h3>
</div>
<div class="ag-theme-quartz w-full">
<AgGridVue
:style="{ width: '100%' }"
:rowData="rowData"
:pinnedBottomRowData="[totalRow]"
:columnDefs="gridColumnDefs"
:gridOptions="summaryGridOptions"
:theme="myTheme"
:animateRows="true"
:localeText="AG_GRID_LOCALE_CN"
@grid-ready="onGridReady"
@first-data-rendered="onFirstDataRendered"
@row-data-updated="onRowDataUpdated"
/>
</div>
</div>
<form class="rounded-lg border bg-card p-3 space-y-2">
<div class="text-xs font-semibold text-foreground">{{ t('htSummary.remark') }}</div>
<textarea
:value="explanationText"
rows="3"
:placeholder="t('htSummary.placeholder')"
readonly
class="w-full rounded-md border bg-muted/40 px-3 py-2 text-sm text-foreground outline-none"
/>
</form>
</div>
</template>
<style scoped>
:deep(.ag-layout-auto-height .ag-center-cols-viewport),
:deep(.ag-layout-auto-height .ag-center-cols-container) {
min-height: 0 !important;
}
:deep(.ht-summary-fee-row .ag-cell) {
background: color-mix(in oklab, var(--muted) 45%, transparent);
}
</style>
@@ -1,242 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { roundTo } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface RateMethodState {
rate: number | null
budgetFee?: number | null
remark: string
}
const props = defineProps<{
storageKey: string
contractId?: string
htMainStorageKey?: string
htRowId?: string
htMethodType?: 'rate-fee'
}>()
const zxFwPricingStore = useZxFwPricingStore()
const { t } = useI18n()
const rate = ref<number | null>(null)
const remark = ref('')
const rateInput = ref('')
const baseValue = ref<number | null>(null)
const lastSavedSnapshot = ref('')
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const contractIdText = computed(() => {
const contractId = String(props.contractId || '').trim()
return contractId
})
const isReserveFee = computed(() => {
const mainKey = String(props.htMainStorageKey || '').trim()
if (mainKey) return mainKey.endsWith('-reserve')
return String(props.storageKey || '').includes('-reserve')
})
const contractStateSignature = computed(() => {
const contractId = contractIdText.value
if (!contractId) return ''
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
})
const additionalWorkStateSignature = computed(() => {
if (!isReserveFee.value) return ''
const contractId = contractIdText.value
if (!contractId) return ''
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
})
const baseLabel = computed(() =>
isReserveFee.value ? t('htFeeRate.reserveBaseLabel') : t('htFeeRate.baseLabel')
)
const budgetFee = computed<number | null>(() => {
if (baseValue.value == null || rate.value == null) return null
return roundTo(baseValue.value * rate.value / 100, 2)
})
const formatAmount = (value: number | null) =>
value == null ? '' : formatThousandsFlexible(value, 3)
const ensureContractLoaded = async () => {
const contractId = contractIdText.value
if (!contractId) return
try {
await zxFwPricingStore.loadContract(contractId)
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
if (!isReserveFee.value) {
baseValue.value = serviceBase == null ? null : roundTo(serviceBase, 3)
return
}
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
const additionalState = await zxFwPricingStore.loadHtFeeMainState<{
rateFee?: unknown
hourlyFee?: unknown
quantityUnitPriceFee?: unknown
}>(additionalStorageKey)
const additionalTotal = (additionalState?.detailRows || []).reduce((total, row) => {
const rateFee = Number(row?.rateFee)
const hourlyFee = Number(row?.hourlyFee)
const quantityFee = Number(row?.quantityUnitPriceFee)
const safeRateFee = Number.isFinite(rateFee) ? rateFee : 0
const safeHourlyFee = Number.isFinite(hourlyFee) ? hourlyFee : 0
const safeQuantityFee = Number.isFinite(quantityFee) ? quantityFee : 0
return total + safeRateFee + safeHourlyFee + safeQuantityFee
}, 0)
const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0
const hasAny = (serviceBase != null) || additionalTotal !== 0
baseValue.value = hasAny ? roundTo(serviceBaseSafe + additionalTotal, 3) : null
} catch (error) {
console.error('load contract for rate base failed:', error)
baseValue.value = null
}
}
const loadForm = async () => {
try {
const data = useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<RateMethodState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<RateMethodState>(props.storageKey)
rate.value = typeof data?.rate === 'number' ? data.rate : null
remark.value = typeof data?.remark === 'string' ? data.remark : ''
rateInput.value = rate.value == null ? '' : String(rate.value)
const snapshot: RateMethodState = {
rate: rate.value,
budgetFee: budgetFee.value,
remark: remark.value
}
lastSavedSnapshot.value = JSON.stringify(snapshot)
} catch (error) {
console.error('load rate form failed:', error)
rate.value = null
remark.value = ''
rateInput.value = ''
lastSavedSnapshot.value = ''
}
}
const saveForm = async (force = false) => {
try {
const payload: RateMethodState = {
rate: rate.value,
budgetFee: budgetFee.value,
remark: remark.value
}
const snapshot = JSON.stringify(payload)
if (!force && snapshot === lastSavedSnapshot.value) return
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload,
{ force }
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload, { force })
}
lastSavedSnapshot.value = snapshot
} catch (error) {
console.error('save rate form failed:', error)
}
}
const applyRateInput = () => {
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 2 })
rate.value = next
rateInput.value = next == null ? '' : String(next)
}
watch([rate, remark, budgetFee], () => {
void saveForm()
})
watch(
() => props.storageKey,
() => {
void loadForm()
}
)
watch(
() => contractIdText.value,
() => {
void ensureContractLoaded()
}
)
watch(
() => [props.storageKey, props.htMainStorageKey, props.htRowId, props.htMethodType],
() => {
void ensureContractLoaded()
}
)
watch([contractStateSignature, additionalWorkStateSignature], ([nextContractSig, nextAdditionalSig], [prevContractSig, prevAdditionalSig]) => {
if (nextContractSig === prevContractSig && nextAdditionalSig === prevAdditionalSig) return
void ensureContractLoaded()
})
onMounted(async () => {
await Promise.all([ensureContractLoaded(), loadForm()])
})
onActivated(async () => {
await Promise.all([ensureContractLoaded(), loadForm()])
})
onBeforeUnmount(() => {
void saveForm(true)
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">{{ baseLabel }}</div>
<input type="text" :value="baseValue" readonly disabled tabindex="-1"
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
</label>
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.rateLabel') }}</div>
<input v-model="rateInput" type="text" inputmode="decimal" :placeholder="t('htFeeRate.ratePlaceholder')"
class="rate-input h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
@blur="applyRateInput"
@keydown.enter.prevent="applyRateInput" />
</label>
<label class="space-y-1.5">
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.budgetFeeLabel') }}</div>
<input type="text" :value="formatAmount(budgetFee)" readonly disabled tabindex="-1"
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
</label>
<label class="space-y-1.5 md:col-span-2">
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.remarkLabel') }}</div>
<textarea v-model="remark" rows="4" :placeholder="t('htFeeRate.remarkPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" />
</label>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.rate-input) {
text-align: left !important;
}
</style>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState {
projectIndustry?: string
}
type MajorItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const props = defineProps<{
contractId: string
projectInfoKey?: string
parentStorageKey?: string
}>()
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('')
const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
projectIndustry.value = ''
}
}
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = getMajorDictEntries()
.filter(({ id }) => isMajorIdInIndustryScope(id, industry))
.map(({ id, item }) => [id, item as MajorItem] as const)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<XmFactorGrid
:title="t('htFactors.majorTitle')"
:storage-key="`ht-major-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'"
:dict="filteredMajorDict"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true"
/>
</template>
@@ -1,27 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { getReserveListEntries } from '@/sql'
const props = defineProps<{
contractId: string
contractName?: string
}>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
const reserveFeeNames = computed(() =>
getReserveListEntries(locale.value).map(item => ({ name: item.name, id: item.id }))
)
</script>
<template>
<HtFeeMethodGrid
:title="t('htFee.reserveTitle')"
:storageKey="STORAGE_KEY"
:contract-id="props.contractId"
:contract-name="props.contractName"
:fixed-names="reserveFeeNames"
/>
</template>
-370
View File
@@ -1,370 +0,0 @@
<template>
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
<TypeLine
scene="ht-tab"
:title="t('htCard.title', { name: contractName })"
:subtitle="t('htCard.subtitle', { id: contractId })"
:meta-text="t('htCard.metaBudget', { amount: formatBudgetAmount(contractBudget) })"
:copy-text="contractId"
:storage-key="typeLineStorageKey"
default-category="base-info"
:categories="xmCategories"
/>
</template>
<script setup lang="ts">
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue';
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import {useDataStore} from '@/pinia/zx'
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
const props = defineProps<{
contractId: string; // 合同ID(必传)
contractName: string; // 合同名称(必传)
projectInfoKey?: string; // 工作区基础信息键
projectScaleKey?: string | null; // 工作区规模信息键
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
projectMajorFactorKey?: string; // 工作区工程专业系数键
}>();
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
interface HtFeeMainRowLike {
id?: unknown
}
interface RateMethodStateLike {
budgetFee?: unknown
}
interface HourlyMethodRowLike {
serviceBudget?: unknown
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
}
interface HourlyMethodStateLike {
detailRows?: HourlyMethodRowLike[]
}
interface QuantityMethodRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface QuantityMethodStateLike {
detailRows?: QuantityMethodRowLike[]
}
const contractBudget = ref<number | null>(null)
const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId}`)
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
const formatBudgetAmount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value)
? `${formatThousands(value, 2)} ${t('htCard.currencySuffix')}`
: '--'
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let hasValid = false
let total = 0
for (const row of rows) {
const serviceBudget = toFiniteNumber(row?.serviceBudget)
if (serviceBudget != null) {
total += serviceBudget
hasValid = true
continue
}
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteNumber(row?.personnelCount)
const workday = toFiniteNumber(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
if (subtotal != null) return roundTo(subtotal, 2)
let hasValid = false
let total = 0
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteNumber(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteNumber(row?.quantity)
const unitPrice = toFiniteNumber(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
const [rateState, hourlyState, quantityState] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
])
const parts = [
toFiniteNumber(rateState?.budgetFee),
sumHourlyMethodFee(hourlyState),
sumQuantityMethodFee(quantityState)
]
const total = sumNullableNumbers(parts)
return total == null ? null : roundTo(total, 2)
}
const loadHtMainTotalFee = async (mainStorageKey: string) => {
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
if (rowIds.length === 0) return null
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
const total = sumNullableNumbers(rowTotals)
return total == null ? null : roundTo(total, 2)
}
const refreshContractBudget = async () => {
await zxFwPricingStore.loadContract(props.contractId)
const serviceFee = zxFwPricingStore.getBaseSubtotal(props.contractId)
const [additionalFee, reserveFee] = await Promise.all([
loadHtMainTotalFee(`htExtraFee-${props.contractId}-additional-work`),
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
])
const parts = [serviceFee, additionalFee, reserveFee]
const total = sumNullableNumbers(parts)
const zxRows = await useDataStore().query([
{ field: 'type', value: `${props.contractId}-zxFw`, operator: 'eq' }
])
const totalFinalFee = zxRows.reduce((sum, row) => {
return sum + (Number(row.finalFee) || 0)
}, 0)
contractBudget.value = totalFinalFee == null ? null : roundTo(totalFinalFee, 2)
}
const budgetRefreshSignature = computed(() => {
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
return JSON.stringify({
contractState: zxFwPricingStore.contracts[props.contractId] || null,
addMain: zxFwPricingStore.htFeeMainStates[additionalMainKey] || null,
reserveMain: zxFwPricingStore.htFeeMainStates[reserveMainKey] || null,
addMethods: zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null,
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
})
})
const scheduleRefreshContractBudget = () => {
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
budgetRefreshTimer = setTimeout(() => {
void refreshContractBudget()
}, 80)
}
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
interface XmCategoryItem {
key:
| 'info'
| 'base-info'
| 'consult-category-factor'
| 'major-factor'
| 'work-grid'
| 'contract'
| 'additional-work-fee'
| 'reserve-fee'
| 'all';
label: string;
component: Component; // 标记为 Vue 组件类型
}
// 3. 优化异步组件导入(添加加载失败兜底 + 类型标注)
const htView = markRaw(
defineComponent({
name: 'HtInfoWithProps',
setup() {
const AsyncHtInfo = defineAsyncComponent({
loader: () => import('@/features/ht/components/htInfo.vue'),
onError: (err) => {
console.error('加载 htInfo 组件失败:', err);
}
});
return () => h(AsyncHtInfo, {
contractId: props.contractId,
projectScaleKey: props.projectScaleKey,
projectInfoKey: props.projectInfoKey
});
}
})
);
const zxfwView = markRaw(
defineComponent({
name: 'ZxFwWithProps',
setup() {
const AsyncZxFw = defineAsyncComponent({
loader: () => import('@/features/ht/components/zxFw.vue'),
onError: (err) => {
console.error('加载 zxFw 组件失败:', err);
}
});
return () => h(AsyncZxFw, {
contractId: props.contractId,
contractName: props.contractName,
projectInfoKey: props.projectInfoKey
});
}
})
);
const consultCategoryFactorView = markRaw(
defineComponent({
name: 'HtConsultCategoryFactorWithProps',
setup() {
const AsyncHtConsultCategoryFactor = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtConsultCategoryFactor.vue'),
onError: (err) => {
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
}
});
return () => h(AsyncHtConsultCategoryFactor, {
contractId: props.contractId,
projectInfoKey: props.projectInfoKey,
parentStorageKey: props.projectConsultCategoryFactorKey
});
}
})
);
const majorFactorView = markRaw(
defineComponent({
name: 'HtMajorFactorWithProps',
setup() {
const AsyncHtMajorFactor = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtMajorFactor.vue'),
onError: (err) => {
console.error('加载 HtMajorFactor 组件失败:', err);
}
});
return () => h(AsyncHtMajorFactor, {
contractId: props.contractId,
projectInfoKey: props.projectInfoKey,
parentStorageKey: props.projectMajorFactorKey
});
}
})
);
const htBaseInfoView = markRaw(
defineComponent({
name: 'HtBaseInfoWithProps',
setup() {
const AsyncHtBaseInfo = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtBaseInfo.vue'),
onError: (err) => {
console.error('加载 HtBaseInfo 组件失败:', err)
}
})
return () => h(AsyncHtBaseInfo, { contractId: props.contractId })
}
})
)
const additionalWorkFeeView = markRaw(
defineComponent({
name: 'HtAdditionalWorkFeeWithProps',
setup() {
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtAdditionalWorkFee.vue'),
onError: (err) => {
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
}
});
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId, contractName: props.contractName });
}
})
);
const reserveFeeView = markRaw(
defineComponent({
name: 'HtReserveFeeWithProps',
setup() {
const AsyncHtReserveFee = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtReserveFee.vue'),
onError: (err) => {
console.error('加载 HtReserveFee 组件失败:', err);
}
});
return () => h(AsyncHtReserveFee, { contractId: props.contractId, contractName: props.contractName });
}
})
);
const summaryView = markRaw(
defineComponent({
name: 'HtContractSummaryWithProps',
setup() {
const AsyncSummary = defineAsyncComponent({
loader: () => import('@/features/ht/components/HtContractSummary.vue'),
onError: (err) => {
console.error('加载 HtContractSummary 组件失败:', err)
}
})
return () => h(AsyncSummary, { contractId: props.contractId })
}
})
)
// 4. 给分类数组添加严格类型标注
const xmCategories = computed<XmCategoryItem[]>(() => [
{ key: 'base-info', label: t('htCard.categories.baseInfo'), component: htBaseInfoView },
{ key: 'info', label: t('htCard.categories.scaleInfo'), component: htView },
{ key: 'contract', label: t('htCard.categories.services'), component: zxfwView },
// { key: 'consult-category-factor', label: t('htCard.categories.consultFactor'), component: consultCategoryFactorView },
// { key: 'major-factor', label: t('htCard.categories.majorFactor'), component: majorFactorView },
// { key: 'additional-work-fee', label: t('htCard.categories.additionalFee'), component: additionalWorkFeeView },
// { key: 'reserve-fee', label: t('htCard.categories.reserveFee'), component: reserveFeeView },
// { key: 'all', label: t('htCard.categories.summary'), component: summaryView },
]);
watch(budgetRefreshSignature, (next, prev) => {
if (next === prev) return
scheduleRefreshContractBudget()
})
onMounted(() => {
void refreshContractBudget()
})
onActivated(() => {
void refreshContractBudget()
})
onBeforeUnmount(() => {
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
})
</script>
-25
View File
@@ -1,25 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
const props = defineProps<{
contractId: string
projectScaleKey?: string | null
projectInfoKey?: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const XM_DB_KEY = computed(() => {
if (props.projectScaleKey === null) return undefined
return props.projectScaleKey || 'xm-info-v3'
})
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const { t } = useI18n()
</script>
<template>
<CommonAgGrid :title="t('htInfo.scaleDetailTitle')" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/>
</template>
File diff suppressed because it is too large. Load diff
-50
View File
@@ -1,50 +0,0 @@
export interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
export const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({
...item,
order: index,
createdAt: item.createdAt || new Date().toISOString()
}))
export const formatDateTime = (value: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.map((item, index) => {
const row = item as Partial<ContractItem>
const name = typeof row.name === 'string' ? row.name.trim() : ''
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
return {
id,
name: name || `导入合同段-${index + 1}`,
order: index,
createdAt
}
})
}
export const isEntryRelatedToAnyContract = (
key: string,
contractIds: Set<string>,
matcher: (entryKey: string, contractId: string) => boolean
) => {
for (const contractId of contractIds) {
if (matcher(key, contractId)) return true
}
return false
}
-210
View File
@@ -1,210 +0,0 @@
.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;
}
}
-172
View File
@@ -1,172 +0,0 @@
import {
cloneJson,
isContractRelatedForageKey,
isContractRelatedKeyedStateKey,
isRecord,
SERVICE_PRICING_METHODS
} from '@/lib/contractSegment'
type AnyRecord = Record<string, unknown>
export interface KvStoreLike {
keys: () => Promise<string[]>
getItem: (key: string) => Promise<unknown>
}
export interface ZxFwPricingStoreLike {
contracts: Record<string, unknown>
servicePricingStates: Record<string, unknown>
htFeeMainStates: Record<string, unknown>
htFeeMethodStates: Record<string, unknown>
keyedStates: Record<string, unknown>
loadContract: (...args: any[]) => Promise<unknown>
getContractState: (...args: any[]) => unknown
setContractState: (...args: any[]) => Promise<unknown>
setServicePricingMethodState: (...args: any[]) => unknown
setHtFeeMainState: (...args: any[]) => unknown
setHtFeeMethodState: (...args: any[]) => unknown
setKeyState: (...args: any[]) => unknown
}
export const buildContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
const payload = {
contracts: {} as AnyRecord,
servicePricingStates: {} as AnyRecord,
htFeeMainStates: {} as AnyRecord,
htFeeMethodStates: {} as AnyRecord
}
if (idSet.size === 0) return payload
await Promise.all(Array.from(idSet).map(id => store.loadContract(id)))
for (const contractId of idSet) {
const contractState = store.getContractState(contractId)
if (contractState) {
payload.contracts[contractId] = cloneJson(contractState)
}
const servicePricingState = store.servicePricingStates[contractId]
if (isRecord(servicePricingState)) {
payload.servicePricingStates[contractId] = cloneJson(servicePricingState)
}
const mainPrefix = `htExtraFee-${contractId}-`
for (const [mainKey, mainState] of Object.entries(store.htFeeMainStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMainStates[mainKey] = cloneJson(mainState)
}
for (const [mainKey, methodState] of Object.entries(store.htFeeMethodStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMethodStates[mainKey] = cloneJson(methodState)
}
}
return payload
}
export const applyImportedContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
piniaPayload: unknown,
oldToNewIdMap: Map<string, string>
) => {
if (!isRecord(piniaPayload)) return
const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null
if (!zxFwPayload) return
const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {}
const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {}
const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {}
const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {}
for (const [oldId, newId] of oldToNewIdMap.entries()) {
const rawContractState = contractsMap[oldId]
if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) {
await store.setContractState(newId, rawContractState as any)
}
const rawServicePricingByService = servicePricingStatesMap[oldId]
if (isRecord(rawServicePricingByService)) {
for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) {
if (!isRecord(rawServiceMethods)) continue
for (const method of SERVICE_PRICING_METHODS) {
const methodState = rawServiceMethods[method]
if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue
store.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true })
}
}
}
const oldMainPrefix = `htExtraFee-${oldId}-`
const newMainPrefix = `htExtraFee-${newId}-`
for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
store.setHtFeeMainState(newMainKey, rawMainState as any, { force: true })
}
for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawByRow)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
for (const [rowId, rawByMethod] of Object.entries(rawByRow)) {
if (!isRecord(rawByMethod)) continue
const ratePayload = rawByMethod['rate-fee']
const hourlyPayload = rawByMethod['hourly-fee']
const quantityPayload = rawByMethod['quantity-unit-price-fee']
if (ratePayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true })
}
if (hourlyPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true })
}
if (quantityPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true })
}
}
}
}
}
export const readContractRelatedForageEntries = async (
kvStore: KvStoreLike,
contractIds: string[]
) => {
const keys = await kvStore.keys()
const idSet = new Set(contractIds)
const targetKeys = keys.filter(key => {
for (const id of idSet) {
if (isContractRelatedForageKey(key, id)) return true
}
return false
})
return Promise.all(
targetKeys.map(async key => ({
key,
value: await kvStore.getItem(key)
}))
)
}
export const readContractRelatedKeyedEntries = (
store: Pick<ZxFwPricingStoreLike, 'keyedStates'>,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
return Object.entries(store.keyedStates)
.filter(([key]) => {
for (const id of idSet) {
if (isContractRelatedKeyedStateKey(key, id)) return true
}
return false
})
.map(([key, value]) => ({
key,
value: cloneJson(value)
}))
}
-39
View File
@@ -1,39 +0,0 @@
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[]
}
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
const props = defineProps<{
contractId: string
serviceId: string | number
}>()
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
const { t } = useI18n()
</script>
<template>
<HourlyFeeGrid
:title="t('hourlyFeeGrid.title')"
:storage-key="DB_KEY"
:contract-id="props.contractId"
:service-id="props.serviceId"
:enable-zx-fw-sync="true"
sync-field="hourly"
/>
</template>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,219 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { createScaleAutoGroupColumn } from '@/lib/pricingScaleColumns'
import { buildScaleDetailDict, buildScaleIdLabelMap } from '@/lib/pricingScaleDict'
import { parseProjectIndexFromPathKey } from '@/lib/pricingScaleLink'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { getMajorDictEntries } from '@/sql'
import type { ScaleDetailRow } from '@/types/pricing'
const props = defineProps<{
contractId: string
serviceId: string | number
method: 'investScale' | 'landScale'
}>()
type MajorLite = {
code: string
name: string
defCoe: number | null
hasCost?: boolean
hasArea?: boolean
}
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
const gridApi = ref<GridApi<ScaleDetailRow> | null>(null)
let autoHeightTimer: ReturnType<typeof setTimeout> | null = null
const isInvestmentFormula = computed(() => props.method === 'investScale')
const methodLabel = computed(() =>
isInvestmentFormula.value
? t('zxFwView.categories.investmentScaleFormula')
: t('zxFwView.categories.landScaleFormula')
)
const ensureMethodStateLoaded = async () => {
await zxFwPricingStore.loadServicePricingMethodState<ScaleDetailRow>(
props.contractId,
props.serviceId,
props.method
)
}
const methodState = computed(() =>
zxFwPricingStore.getServicePricingMethodState<ScaleDetailRow>(
props.contractId,
props.serviceId,
props.method
)
)
const rowData = computed<ScaleDetailRow[]>(() => {
const rows = methodState.value?.detailRows
return Array.isArray(rows) ? rows : []
})
const idLabelMap = computed(() => {
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
const detailDict = buildScaleDetailDict(
majorEntries,
isInvestmentFormula.value
? ({ hasCost, hasArea }) => hasCost && !hasArea
: ({ hasArea }) => hasArea
)
return buildScaleIdLabelMap(detailDict)
})
const numberFormatter = (params: { value?: unknown }) =>
typeof params.value === 'number' && Number.isFinite(params.value)
? formatThousandsFlexible(params.value, 3)
: ''
const scaleValueColumnField = computed<keyof ScaleDetailRow>(() =>
isInvestmentFormula.value ? 'amount' : 'landArea'
)
const scaleValueColumnHeader = computed(() =>
isInvestmentFormula.value
? t('pricingScale.columns.investAmount')
: t('pricingScale.columns.landArea')
)
const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() =>
withReadonlyAutoHeight<ScaleDetailRow>([
{
headerName: scaleValueColumnHeader.value,
field: scaleValueColumnField.value,
minWidth: 130,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: numberFormatter
},
{
headerName: t('pricingScale.columns.basicWork'),
field: 'benchmarkBudgetBasic',
minWidth: 130,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: numberFormatter
},
{
headerName: t('zxFwView.formulaColumns.basicFormula'),
field: 'basicFormula',
minWidth: 260,
flex: 2
},
{
headerName: t('pricingScale.columns.optionalWork'),
field: 'benchmarkBudgetOptional',
minWidth: 130,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: numberFormatter
},
{
headerName: t('zxFwView.formulaColumns.optionalFormula'),
field: 'optionalFormula',
minWidth: 260,
flex: 2
}
]) as ColDef<ScaleDetailRow>[]
)
const autoGroupColumnDef = computed<ColDef<ScaleDetailRow>>(() =>
createScaleAutoGroupColumn<ScaleDetailRow>({
totalLabel: methodLabel.value,
idLabelMap: idLabelMap.value,
parseProjectIndexFromPathKey
})
)
const detailGridOptions: GridOptions<ScaleDetailRow> = {
...gridOptions
}
const syncAutoRowHeights = async () => {
await nextTick()
const api = gridApi.value
if (!api || api.isDestroyed?.()) return
api.resetRowHeights()
api.onRowHeightChanged()
api.refreshCells({ force: true })
}
const scheduleAutoRowHeights = () => {
if (autoHeightTimer) clearTimeout(autoHeightTimer)
autoHeightTimer = setTimeout(() => {
autoHeightTimer = null
void syncAutoRowHeights()
}, 0)
}
const onGridReady = (event: GridReadyEvent<ScaleDetailRow>) => {
gridApi.value = event.api
scheduleAutoRowHeights()
}
watch(rowData, () => {
scheduleAutoRowHeights()
}, { deep: true })
watch(() => props.method, () => {
void ensureMethodStateLoaded()
scheduleAutoRowHeights()
})
onMounted(() => {
void ensureMethodStateLoaded()
})
onActivated(() => {
void ensureMethodStateLoaded()
scheduleAutoRowHeights()
})
onBeforeUnmount(() => {
if (autoHeightTimer) clearTimeout(autoHeightTimer)
})
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-foreground">{{ methodLabel }}</h3>
<p class="text-xs text-muted-foreground">{{ t('zxFwView.formulaColumns.subtitle') }}</p>
</div>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:theme="myTheme"
:style="agGridStyle"
:row-data="rowData"
:column-defs="columnDefs"
:auto-group-column-def="autoGroupColumnDef"
:grid-options="detailGridOptions"
:locale-text="AG_GRID_LOCALE_CN"
@grid-ready="onGridReady"
/>
</div>
</div>
</template>
@@ -1,995 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { taskList } from '@/sql'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
import { sumNullableBy } from '@/lib/pricingScaleCalc'
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
import {useDataStore} from "@/pinia/zx";
interface DetailRow {
id: string
taskCode: string
taskName: string
unit: string
conversion: number | null
workload: number | null
basicFee: number | null
budgetBase: string
budgetReferenceUnitPrice: string | number | null
budgetAdoptedUnitPrice: number | null
consultCategoryFactor: number | null
cLow: number | null
cMid: number | null
cHigh: number | null
serviceFee: number | null
remark: string
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string,
contractName: string,
serviceId: string | number
}>()
const zxFwPricingStore = useZxFwPricingStore()
const { t, locale } = useI18n()
const kvStore = useKvStore()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const gridApi = ref<GridApi<DetailRow> | null>(null)
/**
* 获取服务对应的咨询服务分类因子
* @returns 咨询服务分类因子值,如果没有则返回 null
*/
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
/**
* 确保咨询服务分类因子默认值已加载
* 从合同段的咨询因子配置中加载,避免重复加载
*/
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
factorDefaultsLoaded = true
}
/**
* 检查是否应该跳过数据持久化
* 用于在清空操作后的短时间内避免旧数据回填
* @returns 是否应该跳过持久化
*/
const shouldSkipPersist = () => {
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
/**
* 检查是否应该强制加载默认数据
* 用于在恢复默认操作后强制使用初始值
* @returns 是否应该强制加载默认数据
*/
const shouldForceDefaultLoad = () => {
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
/**
* 获取当前服务工作量法的状态
* @returns 工作量法状态对象,包含明细行数据
*/
const getMethodState = () =>
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'serviceFee')
const detailRows = computed<DetailRow[]>({
get: () => {
const rows = getMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'serviceFee', {
detailRows: rows
})
}
})
type taskLite = {
serviceID: number
code?: string
ref?: string
name: string
basicParam: string
unit: string
conversion: number | null
maxPrice: number | null
minPrice: number | null
defPrice: number | null
midPrice: number | null
highPrice: number | null
desc: string | null
}
/**
* 获取任务显示名称
* 根据当前语言环境返回中文或英文名称
* @param task 任务对象
* @returns 任务显示名称
*/
const getTaskDisplayName = (task: taskLite | undefined) => {
if (!task) return ''
return String(locale.value).toLowerCase().startsWith('en')
? (task as taskLite & { nameEn?: string }).nameEn || task.name
: task.name
}
/**
* 格式化任务参考单价
* 根据任务的最小/最大价格生成价格区间字符串
* @param task 任务对象
* @returns 格式化后的价格区间字符串(如 "100元-200元")
*/
const formatTaskReferenceUnitPrice = (task: taskLite) => {
const unit = task.unit || ''
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
const hasMax = typeof task.maxPrice === 'number' && Number.isFinite(task.maxPrice)
if (hasMin && hasMax) return `${task.minPrice}${unit}-${task.maxPrice}${unit}`
if (hasMin) return `${task.minPrice}${unit}`
if (hasMax) return `${task.maxPrice}${unit}`
return ''
}
/**
* 获取当前服务对应的工作任务 ID 列表
* 从 taskList 中筛选出属于当前服务的任务,并按 ID 排序
* @returns 任务 ID 数组
*/
const getSourceTaskIds = () => {
// 从 serviceId 中提取最后的数字部分,例如:ct-1776069559338-f621-zx-9 -> 9
const serviceIdStr = String(props.serviceId)
const match = serviceIdStr.match(/-(\d+)$/)
const currentServiceId = match ? Number(match[1]) : NaN
return Object.entries(taskList as Record<string, taskLite>)
.filter(([, task]) => Number(task.serviceID) === currentServiceId)
.map(([key]) => Number(key))
.filter(Number.isFinite)
.sort((a, b) => a - b)
}
const isWorkloadMethodApplicable = computed(() => getSourceTaskIds().length > 0)
/**
* 构建默认明细行数据
* 根据当前服务的任务列表生成初始行数据
* @returns 默认明细行数组
*/
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
const sourceTaskIds = getSourceTaskIds()
for (const [order, taskId] of sourceTaskIds.entries()) {
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
const taskCode = task?.code || task?.ref || ''
if (!taskCode || !task?.name) continue
const contractId = props.contractId;
const rowId = `${contractId}-task-${taskId}`
rows.push({
id: rowId,
taskCode,
taskName: getTaskDisplayName(task),
unit: task.unit || '',
conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null,
workload: typeof task.midPrice === 'number' && Number.isFinite(task.midPrice) ? task.midPrice : null,
basicFee: null,
budgetBase: task.basicParam || '',
budgetReferenceUnitPrice: null,
budgetAdoptedUnitPrice:
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
consultCategoryFactor: typeof task.highPrice === 'number' && Number.isFinite(task.highPrice) ? task.highPrice : null,
serviceFee: null,
remark: task.desc|| '',
type: 'task',
path: [rowId]
})
}
return rows
}
const isNoTaskRow = (row: DetailRow | undefined) => row?.id?.startsWith('task-none-') ?? false
/**
* 合并数据库中的行数据与默认行数据
* 保留用户编辑的值(工作量、单价、因子等),缺失时使用默认值
* @param rowsFromDb 从数据库加载的行数据
* @returns 合并后的明细行数组
*/
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
// ✅ 计算 cLow 值
let cLowValue: number | null = null
let cMidValue: number | null = null
let cHighValue: number | null = null
const refPrice = fromDb.budgetReferenceUnitPrice
const lowPrice = row.budgetAdoptedUnitPrice
const midPrice = row.workload
const highPrice = row.consultCategoryFactor
cLowValue = refPrice * lowPrice
cMidValue = refPrice * midPrice
cHighValue = refPrice * highPrice
let serviceFee = cMidValue
if (fromDb.serviceFee != null && fromDb.serviceFee != 0) {
serviceFee = fromDb.serviceFee
}
return {
...row,
workload: typeof fromDb.workload === 'number' ? fromDb.workload : null,
basicFee: typeof fromDb.basicFee === 'number' ? fromDb.basicFee : null,
budgetReferenceUnitPrice: fromDb.budgetReferenceUnitPrice ?? row.budgetReferenceUnitPrice,
budgetAdoptedUnitPrice: 1, // 固定为 1
cLow: cLowValue,
cMid: cMidValue,
cHigh: cHighValue,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: getDefaultConsultCategoryFactor(),
serviceFee: serviceFee,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
}
})
}
const parseSanitizedNumberOrNull = (value: unknown) =>
parseNumberOrNull(value, { sanitize: true, precision: 3 })
const parseSanitizedAdoptedPriceOrNull = (value: unknown) =>
parseNumberOrNull(value, { sanitize: true, precision: 6 })
/**
* 计算基础费用
* 公式:基础费用 = 采用单价 × 换算系数 × 工作量
* @param row 明细行数据
* @returns 基础费用(保留2位小数),如果数据不完整则返回 null
*/
const calcBasicFee = (row: DetailRow | undefined) => {
if (!row || isNoTaskRow(row)) return null
const price = row.budgetAdoptedUnitPrice
const conversion = row.conversion
const workload = row.workload
if (
typeof price !== 'number' ||
!Number.isFinite(price) ||
typeof conversion !== 'number' ||
!Number.isFinite(conversion) ||
typeof workload !== 'number' ||
!Number.isFinite(workload)
) {
return null
}
return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
}
/**
* 计算服务费用
* 公式:服务费用 = 基础费用 × 咨询服务分类因子
* @param row 明细行数据
* @returns 服务费用(保留2位小数),如果数据不完整则返回 null
*/
const calcServiceFee = (row: DetailRow | undefined) => {
if (!row || isNoTaskRow(row)) return null
const factor = row.consultCategoryFactor
const basicFee = calcBasicFee(row)
if (
basicFee == null ||
typeof factor !== 'number' ||
!Number.isFinite(factor)
) {
return null
}
return roundTo(toDecimal(basicFee).mul(factor), 2)
}
/**
* 格式化可编辑数字单元格
* 根据行类型和值状态返回相应的显示文本
* @param params AG Grid 单元格参数
* @returns 格式化后的显示文本
*/
const formatEditableNumber = (params: any) => {
if (isNoTaskRow(params.data)) return t('workloadPricing.none')
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return t('workloadPricing.clickToInput')
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
/**
* 判断两行是否应该合并显示(按任务名称和预算基数)
* 用于 AG Grid 的 spanRows 功能,相同任务和预算基数的行合并显示
* @param params AG Grid 行比较参数
* @returns 是否应该合并
*/
const spanRowsByTaskName = (params: any) => {
const rowA = params?.nodeA?.data as DetailRow | undefined
const rowB = params?.nodeB?.data as DetailRow | undefined
if (!rowA || !rowB) return false
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
return Boolean(rowA.taskName) && Boolean(rowA.budgetBase) && rowA.taskName === rowB.taskName && rowA.budgetBase === rowB.budgetBase
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: t('workloadPricing.columns.code'),
field: 'taskCode',
minWidth: 100,
width: 120,
pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? t('workloadPricing.total') : params.value || '')
},
{
headerName: t('workloadPricing.columns.name'),
field: 'taskName',
minWidth: 150,
width: 220,
pinned: 'left',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
spanRows: true,
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: t('workloadPricing.columns.budgetBase'),
field: 'budgetBase',
minWidth: 150,
autoHeight: true,
width: 180,
colSpan: params => (params.node?.rowPinned ? 3 : 1),
spanRows: spanRowsByTaskName,
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: t('workloadPricing.columns.budgetReferenceUnitPrice'),
field: 'budgetReferenceUnitPrice',
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
valueFormatter: formatEditableNumber
},
/*{
headerName: t('workloadPricing.columns.budgetAdoptedUnitPrice'),
field: 'budgetAdoptedUnitPrice',
headerClass: 'ag-right-aligned-header',
minWidth: 170,
flex: 1,
// editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
valueParser: params => parseSanitizedAdoptedPriceOrNull(params.newValue),
valueFormatter: params => {
if (isNoTaskRow(params.data)) return t('workloadPricing.none')
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return t('workloadPricing.clickToInput')
}
if (params.value == null) return ''
const unit = params.data?.unit || ''
return `${formatThousandsFlexible(params.value, 6)}${unit}`
}
},*/
{
headerName: t('workloadPricing.columns.budgetAdoptedUnitPrice'),
field: 'budgetAdoptedUnitPrice',
headerClass: 'ag-right-aligned-header',
minWidth: 170,
flex: 1,
// ❌ 移除 editable 或设置为 false
editable: false,
// ✅ 添加单元格样式
cellClass: 'ag-right-aligned-cell',
// ✅ 移除 cellClassRules 中的 editable-cell 相关规则
cellClassRules: {
'ag-right-aligned-cell': () => true
},
// ✅ 固定显示值为 1
valueFormatter: params => {
const unit = params.data?.unit || ''
return `1`
// return `1${unit}`
},
// ✅ 阻止任何编辑尝试
valueParser: () => 1,
// ✅ 禁用单元格编辑
cellEditor: undefined
},
{
headerName: t('workloadPricing.columns.workload'),
field: 'workload',
headerClass: 'ag-right-aligned-header',
minWidth: 170,
flex: 1,
// ❌ 移除 editable 或设置为 false
editable: false,
// ✅ 添加单元格样式
cellClass: 'ag-right-aligned-cell',
// ✅ 移除 cellClassRules 中的 editable-cell 相关规则
cellClassRules: {
'ag-right-aligned-cell': () => true
},
// ✅ 固定显示值为 1
valueFormatter: params => {
const unit = params.data?.unit || ''
return `2`
// return `1${unit}`
},
// ✅ 阻止任何编辑尝试
valueParser: () => 1,
// ✅ 禁用单元格编辑
cellEditor: undefined
},
/*{
headerName: t('workloadPricing.columns.workload'),
field: 'workload',
minWidth: 140,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},*/
{
headerName: t('workloadPricing.columns.consultCategoryFactor'),
field: 'consultCategoryFactor',
headerClass: 'ag-right-aligned-header',
minWidth: 170,
flex: 1,
// ❌ 移除 editable 或设置为 false
editable: false,
// ✅ 添加单元格样式
cellClass: 'ag-right-aligned-cell',
// ✅ 移除 cellClassRules 中的 editable-cell 相关规则
cellClassRules: {
'ag-right-aligned-cell': () => true
},
// ✅ 固定显示值为 1
valueFormatter: params => {
const unit = params.data?.unit || ''
return `3`
// return `1${unit}`
},
// ✅ 阻止任何编辑尝试
valueParser: () => 1,
// ✅ 禁用单元格编辑
cellEditor: undefined
},
/*{
headerName: t('workloadPricing.columns.consultCategoryFactor'),
field: 'consultCategoryFactor',
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},*/
{
headerName: t('workloadPricing.columns.cLow'),
field: 'cLow',
width: 100,
minWidth: 70,
maxWidth: 120,
// ✅ 设置为不可编辑
editable: false,
// ✅ 只读样式
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
}/*,
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber*/
},
{
headerName: t('workloadPricing.columns.cMid'),
field: 'cMid',
width: 100,
minWidth: 70,
maxWidth: 120,
// ✅ 设置为不可编辑
editable: false,
// ✅ 只读样式
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
}/*,
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber*/
},
{
headerName: t('workloadPricing.columns.cHigh'),
field: 'cHigh',
width: 100,
minWidth: 70,
maxWidth: 120,
// ✅ 设置为不可编辑
editable: false,
// ✅ 只读样式
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
}/*,
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber*/
},
{
headerName: t('workloadPricing.columns.serviceFee'),
field: 'serviceFee',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: t('workloadPricing.columns.remark'),
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: false,
valueFormatter: params => {
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
}
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const totalWorkload = computed(() => {
return 1;
})
const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row)))
const totalServiceFee = computed(() => sumNullableBy(detailRows.value, row => calcServiceFee(row)))
const pinnedTopRowData = computed(() =>
createPinnedTopRowData({
id: 'pinned-total-row',
taskCode: t('workloadPricing.total'),
taskName: '',
unit: '',
conversion: null,
workload: totalWorkload.value,
basicFee: totalBasicFee.value,
budgetBase: '',
budgetReferenceUnitPrice: '',
budgetAdoptedUnitPrice: null,
consultCategoryFactor: null,
serviceFee: totalServiceFee.value,
remark: '',
path: ['TOTAL']
})
)
/**
* 构建用于持久化的明细行数据
* 计算并附加基础费用和服务费用字段
* @returns 包含计算字段的明细行数组
*/
const buildPersistDetailRows = () =>
detailRows.value.map(row => ({
...row,
basicFee: calcBasicFee(row),
serviceFee: row.serviceFee
}))
/**
* 保存数据到 IndexedDB 并同步到 zxFw store
* 保存时机:单元格编辑、批量操作结束、组件失活/卸载时
* 同步工作量法的总服务费用到咨询服务汇总表
*/
const saveToIndexedDB = async () => {
try {
const rows = detailRows.value.map(row => ({ ...row, type: `${props.contractId}-task`}))
const stats = await useDataStore().upsertBatch(rows)
console.log('💾 数据保存成功:', stats)
/*zxFwPricingStore.setServicePricingMethodState(
props.contractId,
props.serviceId,
'serviceFee',
payload,
{ force: true }
)*/
// const synced = await syncPricingTotalToZxFw({
// contractId: props.contractId,
// serviceId: props.serviceId,
// field: 'serviceFee',
// value: totalWorkload.value
// })
// if (!synced) return
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
/**
* 判断两个可空数值是否相等(考虑精度)
* @param left 第一个数值
* @param right 第二个数值
* @returns 是否相等
*/
const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
if (left == null && right == null) return true
if (left == null || right == null) return false
return roundTo(left, 6) === roundTo(right, 6)
}
/**
* 从合同段同步咨询服务分类因子
* 当合同段的咨询因子配置变化时,更新当前工作量的因子默认值
*/
const syncLinkedConsultFactorFromHt = async () => {
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
factorDefaultsLoaded = true
const nextDefaultFactor = getDefaultConsultCategoryFactor()
let changed = false
detailRows.value = detailRows.value.map(row => {
if (isSameNullableNumber(row.consultCategoryFactor, nextDefaultFactor)) return row
changed = true
return {
...row,
consultCategoryFactor: nextDefaultFactor
}
})
if (!changed) return
await saveToIndexedDB()
}
const linkedConsultFactorSignature = computed(() => JSON.stringify({
consultFactor:
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
?? null
}))
/**
* 从 IndexedDB 加载工作量法数据
* 加载时机:组件挂载、激活、storageKey 变化时
* 优先加载历史数据,没有则使用默认数据
*/
const loadFromIndexedDB = async () => {
try {
if (!isWorkloadMethodApplicable.value) {
detailRows.value = []
return
}
const taskRows = await useDataStore().query([
{ field: 'type', value: `${props.contractId}-task`, operator: 'eq' }
])
/*await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
}*/
// const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'serviceFee')
if (taskRows) {
detailRows.value = mergeWithDictRows(taskRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
/**
* 根据任务词典更新行标签
* 当语言切换或任务词典更新时,同步更新任务名称、单位等信息
*/
const relabelRowsFromTaskDict = async () => {
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
let changed = false
detailRows.value = detailRows.value.map(row => {
if (isNoTaskRow(row)) return row
const match = String(row.id || '').match(/^task-(\d+)-\d+$/)
if (!match) return row
const task = (taskList as Record<string, taskLite | undefined>)[match[1]]
if (!task) return row
const nextTaskName = getTaskDisplayName(task)
const nextUnit = task.unit || ''
const nextBudgetBase = task.basicParam || ''
const nextBudgetReferenceUnitPrice = formatTaskReferenceUnitPrice(task)
if (
row.taskName === nextTaskName &&
row.unit === nextUnit &&
row.budgetBase === nextBudgetBase &&
row.budgetReferenceUnitPrice === nextBudgetReferenceUnitPrice
) {
return row
}
changed = true
return {
...row,
taskName: nextTaskName,
unit: nextUnit,
budgetBase: nextBudgetBase,
budgetReferenceUnitPrice: nextBudgetReferenceUnitPrice
}
})
gridApi.value?.refreshCells({ force: true })
if (!changed) return
await saveToIndexedDB()
}
let isBulkClipboardMutation = false
/**
* 提交网格变更并保存
* 在单元格编辑完成后调用,触发数据持久化
*/
const commitGridChanges = () => {
void saveToIndexedDB()
}
/**
* 处理单元格值变化事件
* 触发时机:用户编辑完单元格后
* 批量操作期间跳过,避免重复保存
*/
const handleCellValueChanged = () => {
if (isBulkClipboardMutation) return
commitGridChanges()
}
/**
* 处理批量粘贴/填充操作开始
* 设置标志位,避免在批量操作过程中频繁保存
*/
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
}
/**
* 处理批量粘贴/填充操作结束
* 清除标志位并保存所有变更
*/
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
commitGridChanges()
}
/**
* AG Grid 初始化完成回调
* 记录 gridApi 实例,用于后续刷新单元格等操作
* @param event AG Grid 初始化事件
*/
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
}
usePricingPaneLifecycle({
gridApi,
loadFromIndexedDB,
syncLinkedFields: syncLinkedConsultFactorFromHt,
linkedSourceSignature: linkedConsultFactorSignature,
saveToIndexedDB
})
watch(
() => locale.value,
() => {
void relabelRowsFromTaskDict()
}
)
watch(
() => useDataStore().items,
async (newItems) => {
if (Object.keys(newItems).length > 0) {
loadFromIndexedDB();
}
},
{ immediate: true, deep: true }
)
/**
* 处理复制到剪贴板的单元格数据
* 将数组类型的数据转换为 JSON 字符串
* @param params AG Grid 剪贴板参数
* @returns 处理后的单元格值
*/
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
/**
* 处理从剪贴板粘贴的单元格数据
* 根据字段类型解析数值(单价、工作量、因子等)
* @param params AG Grid 剪贴板参数
* @returns 解析后的单元格值
*/
const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'budgetAdoptedUnitPrice') {
return parseSanitizedAdoptedPriceOrNull(params.value)
}
if (field === 'workload' || field === 'consultCategoryFactor') {
return parseSanitizedNumberOrNull(params.value)
}
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
} catch (e) {
// 解析失败时返回原始值,无需额外处理
}
return params.value;
};
const mydiyTheme = myTheme.withParams({
rowBorder: {
style: "solid",
width: 0.8,
color: "var(--border)"
},
columnBorder: {
style: "solid",
width: 0.8,
color: "var(--border)"
}
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ t('workloadPricing.title') }}</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div v-if="isWorkloadMethodApplicable" :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows"
:columnDefs="gridColumnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
:animateRows="true"
:enableCellSpan="true"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
<MethodUnavailableNotice
v-else
:title="t('workloadPricing.unavailableTitle')"
:message="t('workloadPricing.unavailableMessage')"
/>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -1,546 +0,0 @@
<script setup lang="ts">
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toDecimal } from '@/lib/decimal'
import { Button } from '@/components/ui/button'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeRow {
id: string
feeItem: string
unit: string
quantity: number | null
unitPrice: number | null
budgetFee: number | null
remark: string
actions?: unknown
}
interface FeeGridState {
detailRows: FeeRow[]
}
const props = defineProps<{
title: string
storageKey: string
htMainStorageKey?: string
htRowId?: string
htMethodType?: 'quantity-unit-price-fee'
}>()
const zxFwPricingStore = useZxFwPricingStore()
const { t } = useI18n()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
const createDefaultRow = (): FeeRow => ({
id: createRowId(),
feeItem: '',
unit: '',
quantity: null,
unitPrice: null,
budgetFee: null,
remark: ''
})
const createSubtotalRow = (): FeeRow => ({
id: SUBTOTAL_ROW_ID,
feeItem: t('htFeeDetail.subtotal'),
unit: '',
quantity: null,
unitPrice: null,
budgetFee: 0,
remark: ''
})
const isSubtotalRow = (row?: FeeRow | null) => row?.id === SUBTOTAL_ROW_ID
const ensureSubtotalRow = (rows: FeeRow[]) => {
const normalRows = rows.filter(row => !isSubtotalRow(row))
if (normalRows.length === 0) return []
return [...normalRows, createSubtotalRow()]
}
const fallbackDetailRows = ref<FeeRow[]>([])
const gridApi = ref<GridApi<FeeRow> | null>(null)
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const detailRows = computed<FeeRow[]>({
get: () => {
if (!useHtMethodState.value) return fallbackDetailRows.value
const state = zxFwPricingStore.getHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
const rows = state?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
if (!useHtMethodState.value) {
fallbackDetailRows.value = rows
return
}
zxFwPricingStore.setHtFeeMethodState(props.htMainStorageKey!, props.htRowId!, props.htMethodType!, {
detailRows: rows
})
}
})
const addRow = () => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
detailRows.value = ensureSubtotalRow([...normalRows, createDefaultRow()])
syncComputedValuesToRows()
void saveToIndexedDB()
}
const deleteRow = (id: string) => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row) && row.id !== id)
detailRows.value = ensureSubtotalRow(normalRows)
syncComputedValuesToRows()
void saveToIndexedDB()
}
const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || t('htFeeDetail.currentRow')
deleteConfirmOpen.value = true
}
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const confirmDeleteRow = () => {
const id = pendingDeleteRowId.value
if (!id) return
deleteRow(id)
deleteConfirmOpen.value = false
pendingDeleteRowId.value = null
pendingDeleteRowName.value = ''
}
const formatEditableText = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return String(params.value)
}
const formatEditableQuantity = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return formatThousandsFlexible(params.value, 3)
}
const formatEditableUnitPrice = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return formatThousandsFlexible(params.value, 3)
}
const formatReadonlyBudgetFee = (params: any) => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(params.value, 3)
}
const syncComputedValuesToRows = () => {
let totalBudgetFee = 0
let hasValidRow = false
for (const row of detailRows.value) {
if (isSubtotalRow(row)) continue
const hasFeeItem = typeof row.feeItem === 'string' && row.feeItem.trim() !== ''
const hasUnit = typeof row.unit === 'string' && row.unit.trim() !== ''
const quantity = typeof row.quantity === 'number' && Number.isFinite(row.quantity) ? row.quantity : null
const unitPrice = typeof row.unitPrice === 'number' && Number.isFinite(row.unitPrice) ? row.unitPrice : null
if (!hasFeeItem || !hasUnit || quantity == null || unitPrice == null) {
row.budgetFee = null
continue
}
row.budgetFee = roundTo(toDecimal(quantity).mul(unitPrice), 2)
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
hasValidRow = true
totalBudgetFee = roundTo(toDecimal(totalBudgetFee).add(row.budgetFee), 2)
}
}
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
if (subtotalRow) {
subtotalRow.feeItem = t('htFeeDetail.subtotal')
subtotalRow.unit = ''
subtotalRow.quantity = null
subtotalRow.unitPrice = null
subtotalRow.budgetFee = hasValidRow ? totalBudgetFee : null
subtotalRow.remark = ''
}
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
return []
}
const rows: FeeRow[] = rowsFromDb.map(item => {
const row = item as Partial<FeeRow>
return {
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
feeItem: typeof row.feeItem === 'string' ? row.feeItem : '',
unit: typeof row.unit === 'string' ? row.unit : '',
quantity: typeof row.quantity === 'number' ? row.quantity : null,
unitPrice: typeof row.unitPrice === 'number' ? row.unitPrice : null,
budgetFee: typeof row.budgetFee === 'number' ? row.budgetFee : null,
remark: typeof row.remark === 'string' ? row.remark : ''
}
})
return ensureSubtotalRow(rows)
}
const buildPersistDetailRows = () => {
syncComputedValuesToRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => {
try {
const payload: FeeGridState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload,
{ force: true }
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<FeeGridState>(props.storageKey)
detailRows.value = mergeWithStoredRows(data?.detailRows)
syncComputedValuesToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = []
syncComputedValuesToRows()
}
}
const columnDefs: ColDef<FeeRow>[] = [
{
headerName: t('htFeeDetail.columns.no'),
colId: 'rowNo',
minWidth: 68,
maxWidth: 80,
flex: 0.6,
editable: false,
sortable: false,
filter: false,
cellStyle: { textAlign: 'center' },
valueGetter: params =>
params.node?.rowPinned
? ''
: isSubtotalRow(params.data)
? t('htFeeDetail.subtotal')
: typeof params.node?.rowIndex === 'number'
? params.node.rowIndex + 1
: '',
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
},
{
headerName: t('htFeeDetail.columns.feeItem'),
field: 'feeItem',
minWidth: 140,
flex: 1.4,
editable: params => !isSubtotalRow(params.data),
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
valueFormatter: formatEditableText,
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeDetail.columns.unit'),
field: 'unit',
minWidth: 90,
flex: 0.9,
editable: params => !isSubtotalRow(params.data),
valueFormatter: formatEditableText,
cellClass: 'editable-cell-line',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeDetail.columns.quantity'),
field: 'quantity',
minWidth: 100,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableQuantity,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeDetail.columns.unitPrice'),
field: 'unitPrice',
minWidth: 120,
flex: 1.1,
headerClass: 'ag-right-aligned-header',
cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeDetail.columns.budgetFee'),
field: 'budgetFee',
minWidth: 130,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: formatReadonlyBudgetFee
},
{
headerName: t('htFeeDetail.columns.remark'),
field: 'remark',
minWidth: 170,
flex: 2,
editable: params => !isSubtotalRow(params.data),
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
valueFormatter: formatEditableText,
cellClass: ' remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeDetail.columns.actions'),
field: 'actions',
minWidth: 92,
maxWidth: 110,
flex: 0.8,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: defineComponent({
name: 'HtFeeGridActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<FeeRow>>,
required: true
}
},
setup(rendererProps) {
return () => {
const row = rendererProps.params.data
if (!row || isSubtotalRow(row)) return null
const onDelete = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
requestDeleteRow(row.id, row.feeItem)
}
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete
},
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', t('common.delete'))]
)
}
}
})
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<FeeRow> = {
...gridOptions,
treeData: false
}
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
gridApi.value = event.api
}
let isBulkClipboardMutation = false
const commitGridChanges = () => {
syncComputedValuesToRows()
gridApi.value?.refreshCells({ force: true })
void saveToIndexedDB()
}
const handleCellValueChanged = () => {
if (isBulkClipboardMutation) return
commitGridChanges()
}
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
}
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
commitGridChanges()
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value)
}
return params.value
}
const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'quantity') {
return parseNumberOrNull(params.value, { precision: 3 })
}
if (field === 'unitPrice') {
return parseNumberOrNull(params.value, { precision: 2 })
}
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (_error) {
return params.value
}
return params.value
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
watch(
() => props.storageKey,
() => {
void loadFromIndexedDB()
}
)
onBeforeUnmount(() => {
gridApi.value?.stopEditing()
gridApi.value = null
void saveToIndexedDB()
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeDetail.addRow') }}</Button>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="detailRows"
:columnDefs="gridColumnDefs"
:gridOptions="detailGridOptions"
:theme="myTheme"
:animateRows="true"
:treeData="false"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
/>
</div>
</div>
</div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ t('htFeeDetail.dialog.deleteTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('htFeeDetail.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>
@@ -1,769 +0,0 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { roundTo, sumNullableNumbers, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { Pencil, Eraser } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeMethodRow {
id: string
name: string
rateFee: number | null
hourlyFee: number | null
quantityUnitPriceFee: number | null
subtotal?: number | null
actions?: unknown
}
interface FeeMethodState {
detailRows: FeeMethodRow[]
}
interface MethodRateState {
rate?: unknown
budgetFee?: unknown
}
interface MethodHourlyRowLike {
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
}
interface MethodHourlyState {
detailRows?: MethodHourlyRowLike[]
}
interface MethodQuantityRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface MethodQuantityState {
detailRows?: MethodQuantityRowLike[]
}
interface LegacyFeeRow {
id?: string
feeItem?: string
budgetFee?: number | null
quantity?: number | null
unitPrice?: number | null
}
const props = defineProps<{
title: string
storageKey: string
contractId?: string
contractName?: string
fixedNames?: any[]
}>()
const { t } = useI18n()
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const createDefaultRow = (name = ''): FeeMethodRow => ({
id: createRowId(),
name,
rateFee: null,
hourlyFee: null,
quantityUnitPriceFee: null
})
const SUMMARY_ROW_ID = 'fee-method-summary'
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
const total = sumNullableNumbers(rows.map(row => pick(row)))
return total == null ? null : roundTo(total, 3)
}
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
if (!row) return null
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
const total = sumNullableNumbers(values)
if (total == null) return null
return roundTo(total, 3)
}
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
if (!Array.isArray(rows) || rows.length === 0) return null
return sumNullableField(rows, row => getRowSubtotal(row))
}
const loadAdditionalWorkFeeTotal = async (contractId: string): Promise<number | null> => {
try {
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
const additionalState = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(additionalStorageKey)
return sumMainStateSubtotal(additionalState?.detailRows)
} catch (error) {
console.error('loadAdditionalWorkFeeTotal failed:', error)
return null
}
}
const loadContractServiceFeeBase = async (): Promise<number | null> => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return null
try {
await zxFwPricingStore.loadContract(contractId)
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
if (!isReserveStorageKey(props.storageKey)) {
return serviceBase == null ? null : roundTo(serviceBase, 3)
}
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
if (!hasAnyBase) return null
return roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalFeeTotal), 3)
} catch (error) {
console.error('loadContractServiceFeeBase failed:', error)
return null
}
}
const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.serviceBudget!== null):[]
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
const rowBudget = toFiniteNumber(row?.serviceBudget)
if (rowBudget != null) {
total += rowBudget
hasValid = true
continue
}
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteNumber(row?.personnelCount)
const workday = toFiniteNumber(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? roundTo(total, 3) : null
}
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.budgetFee!== null):[]
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteNumber(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteNumber(row?.quantity)
const unitPrice = toFiniteNumber(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? roundTo(total, 3) : null
}
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
if (!Array.isArray(rows) || rows.length === 0) return rows
const contractBase = await loadContractServiceFeeBase()
const hydratedRows = await Promise.all(
rows.map(async row => {
if (!row?.id) return row
const [rateData, hourlyData, quantityData] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<MethodRateState>(props.storageKey, row.id, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodHourlyState>(props.storageKey, row.id, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
])
const storedRateFee = toFiniteNumber(rateData?.budgetFee)
const rateValue = toFiniteNumber(rateData?.rate)
const rateFee =
contractBase != null && rateValue != null
? roundTo(contractBase * rateValue / 100, 2)
: storedRateFee != null
? roundTo(storedRateFee, 2)
: null
const hourlyFee = sumHourlyMethodFee(hourlyData)
const quantityUnitPriceFee = sumQuantityMethodFee(quantityData)
return {
...row,
rateFee,
hourlyFee,
quantityUnitPriceFee
}
})
)
return hydratedRows
}
const fixedNames = computed(() =>
Array.isArray(props.fixedNames)
? props.fixedNames.map(item => ({name:item.name,id:item.id}))
: []
)
const hasFixedNames = computed(() => fixedNames.value.length > 0)
const fixedNamesSignature = computed(() =>
JSON.stringify(
fixedNames.value.map(item => ({
id: String(item?.id || ''),
name: String(item?.name || '')
}))
)
)
const detailRows = computed<FeeMethodRow[]>({
get: () => {
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setHtFeeMainState(props.storageKey, {
detailRows: rows
})
}
})
const summaryRow = computed<FeeMethodRow>(() => {
const rateFee = sumNullableField(detailRows.value, row => row.rateFee)
const hourlyFee = sumNullableField(detailRows.value, row => row.hourlyFee)
const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee)
const result: FeeMethodRow = {
id: SUMMARY_ROW_ID,
name: t('htFeeGrid.subtotal'),
rateFee,
hourlyFee,
quantityUnitPriceFee
}
result.subtotal = getRowSubtotal(result)
return result
})
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
const clearConfirmOpen = ref(false)
const pendingClearRowId = ref<string | null>(null)
const pendingClearRowName = ref('')
const lastSavedSnapshot = ref('')
const requestClearRow = (id: string, name?: string) => {
pendingClearRowId.value = id
pendingClearRowName.value = String(name || '').trim() || t('htFeeGrid.currentRow')
clearConfirmOpen.value = true
}
const handleClearConfirmOpenChange = (open: boolean) => {
clearConfirmOpen.value = open
}
const confirmClearRow = async () => {
const id = pendingClearRowId.value
if (!id) return
await clearRow(id)
clearConfirmOpen.value = false
pendingClearRowId.value = null
pendingClearRowName.value = ''
}
const formatEditableText = (params: any) => {
if (params.value == null || params.value === '') {
if (isSummaryRow(params.data)) return ''
return ''
}
return String(params.value)
}
const formatEditableNumber = (params: any) => {
if (params.value == null || params.value === '') {
if (isSummaryRow(params.data)) return ''
return ''
}
return formatThousandsFlexible(params.value, 3)
}
const numericParser = (newValue: any): number | null =>
parseNumberOrNull(newValue, { precision: 3 })
const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) return row.budgetFee
if (
typeof row.quantity === 'number' &&
Number.isFinite(row.quantity) &&
typeof row.unitPrice === 'number' &&
Number.isFinite(row.unitPrice)
) {
return roundTo(row.quantity * row.unitPrice, 2)
}
return null
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
)
const rows = sourceRows.map(item => {
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
return {
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
name:
typeof row.name === 'string'
? row.name
: (typeof row.feeItem === 'string' ? row.feeItem : ''),
rateFee: typeof row.rateFee === 'number' ? row.rateFee : null,
hourlyFee: typeof row.hourlyFee === 'number' ? row.hourlyFee : null,
quantityUnitPriceFee:
typeof row.quantityUnitPriceFee === 'number'
? row.quantityUnitPriceFee
: toLegacyQuantityUnitPriceFee(row)
} as FeeMethodRow
})
if (hasFixedNames.value) {
const byId = new Map(rows.map(row => [String(row.id || ''), row]))
const byName = new Map(rows.map(row => [row.name, row]))
return fixedNames.value.map((item, index) => {
const rowId = String(item?.id || `fee-method-fixed-${index}`)
const fromDb = byId.get(rowId) || byName.get(item.name)
return {
id: rowId,
name: item.name,
rateFee: fromDb?.rateFee ?? null,
hourlyFee: fromDb?.hourlyFee ?? null,
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
}
})
}
return rows.length > 0 ? rows : [createDefaultRow()]
}
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
const saveToIndexedDB = async (force = false) => {
try {
const payload: FeeMethodState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
const snapshot = JSON.stringify(payload.detailRows)
if (!force && snapshot === lastSavedSnapshot.value) return
zxFwPricingStore.setHtFeeMainState(props.storageKey, payload, { force })
lastSavedSnapshot.value = snapshot
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
const mergedRows = mergeWithStoredRows(data?.detailRows)
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB(true)
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
const mergedRows = mergeWithStoredRows([])
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB(true)
}
}
const addRow = () => {
detailRows.value = [...detailRows.value, createDefaultRow()]
void saveToIndexedDB()
}
const clearRow = async (id: string) => {
tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
await nextTick()
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'rate-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'hourly-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'quantity-unit-price-fee')
detailRows.value = detailRows.value.map(row =>
row.id !== id
? row
: {
...row,
rateFee: null,
hourlyFee: null,
quantityUnitPriceFee: null
}
)
await saveToIndexedDB()
}
const editRow = (id: string) => {
const row = detailRows.value.find(item => item.id === id)
if (!row) return
tabStore.openTab({
id: `ht-fee-edit-${props.storageKey}-${id}`,
title: t('htFeeGrid.editTabTitle', { name: row.name || t('htFeeGrid.unnamed') }),
componentName: 'HtFeeMethodTypeLineView',
props: {
sourceTitle: props.title,
storageKey: props.storageKey,
rowId: id,
rowName: row.name || '',
contractId: props.contractId,
contractName: props.contractName
}
})
}
const ActionCellRenderer = defineComponent({
name: 'HtFeeMethodActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<FeeMethodRow>>,
required: true
}
},
setup(props) {
return () => {
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const rowId = String(props.params.data?.id || '').trim()
if (!rowId) return
if (action === 'edit') {
props.params.context?.onActionEdit?.(rowId)
return
}
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
}
return h('div', { class: 'zxfw-action-wrap' }, [
h('div', { class: 'zxfw-action-group' }, [
h('button', {
class: 'zxfw-action-btn',
'data-action': 'edit',
type: 'button',
onClick: onActionClick('edit')
}, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', t('htFeeGrid.edit'))
]),
h('button', {
class: 'zxfw-action-btn zxfw-action-btn--danger',
'data-action': 'clear',
type: 'button',
onClick: onActionClick('clear')
}, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', t('htFeeGrid.clear'))
])
])
])
}
}
})
const columnDefs: ColDef<FeeMethodRow>[] = [
{
headerName: t('htFeeGrid.columns.name'),
field: 'name',
minWidth: 180,
flex: 1.8,
editable: false,
valueFormatter: formatEditableText,
cellClass: params =>
params.context?.fixedNames === true || isSummaryRow(params.data)
? ''
: 'editable-cell-line',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeGrid.columns.rateFee'),
field: 'rateFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeGrid.columns.hourlyFee'),
field: 'hourlyFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeGrid.columns.quantityUnitPriceFee'),
field: 'quantityUnitPriceFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeGrid.columns.subtotal'),
field: 'subtotal',
minWidth: 140,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => getRowSubtotal(params.data),
valueFormatter: formatEditableNumber
},
{
headerName: t('htFeeGrid.columns.actions'),
field: 'actions',
minWidth: 220,
flex: 1.6,
maxWidth: 260,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: ActionCellRenderer
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<FeeMethodRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
context: {
fixedNames: hasFixedNames.value,
onActionEdit: editRow,
onActionClear: clearRow,
onActionRequestClear: requestClearRow
},
onCellClicked: params => {
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
const target = params.event?.target as HTMLElement | null
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
const action = btn?.dataset.action
if (action === 'edit') {
editRow(params.data.id)
return
}
if (action === 'clear') {
requestClearRow(params.data.id, params.data.name)
return
}
}
}
let reloadTimer: ReturnType<typeof setTimeout> | null = null
const scheduleReloadFromStorage = () => {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
void loadFromIndexedDB()
}, 80)
}
const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
gridApi.value = event.api
}
let isBulkClipboardMutation = false
const commitGridChanges = () => {
void saveToIndexedDB()
}
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
if (isBulkClipboardMutation) return
if (isSummaryRow(event.data)) return
commitGridChanges()
}
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
}
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
commitGridChanges()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
scheduleReloadFromStorage()
})
const storageKeyRef = computed(() => props.storageKey)
const reserveAdditionalWorkStateSignature = computed(() => {
if (!isReserveStorageKey(props.storageKey)) return ''
const contractId = String(props.contractId || '').trim()
if (!contractId) return ''
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
})
watch(storageKeyRef, () => {
scheduleReloadFromStorage()
})
watch(
() => JSON.stringify(zxFwPricingStore.htFeeMethodStates[props.storageKey] || null),
(nextSignature, prevSignature) => {
if (!nextSignature && !prevSignature) return
if (nextSignature === prevSignature) return
scheduleReloadFromStorage()
}
)
watch(
() => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return ''
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
},
(nextSig, prevSig) => {
if (nextSig === prevSig) return
scheduleReloadFromStorage()
}
)
watch(
reserveAdditionalWorkStateSignature,
(nextSig, prevSig) => {
if (nextSig === prevSig) return
scheduleReloadFromStorage()
}
)
watch([hasFixedNames], () => {
if (!detailGridOptions.context) return
detailGridOptions.context.fixedNames = hasFixedNames.value
detailGridOptions.context.onActionEdit = editRow
detailGridOptions.context.onActionClear = clearRow
detailGridOptions.context.onActionRequestClear = requestClearRow
gridApi.value?.refreshCells({ force: true })
})
watch(
fixedNamesSignature,
async (nextSig, prevSig) => {
if (!hasFixedNames.value) return
if (!nextSig || nextSig === prevSig) return
const nextRows = mergeWithStoredRows(detailRows.value)
const changed = JSON.stringify(nextRows) !== JSON.stringify(detailRows.value)
if (!changed) return
detailRows.value = nextRows
await saveToIndexedDB(true)
gridApi.value?.refreshCells({ force: true })
}
)
onBeforeUnmount(() => {
if (reloadTimer) clearTimeout(reloadTimer)
gridApi.value = null
void saveToIndexedDB(true)
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeGrid.add') }}</Button>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="displayRows"
:columnDefs="gridColumnDefs"
:gridOptions="detailGridOptions"
:theme="myTheme"
:animateRows="true"
:treeData="false"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
/>
</div>
</div>
</div>
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ t('htFeeGrid.dialog.clearTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('htFeeGrid.dialog.clearDesc', { name: pendingClearRowName }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmClearRow">{{ t('htFeeGrid.dialog.confirmClear') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
title?: string
message: string
}>(),
{
title: undefined
}
)
</script>
<template>
<div
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
>
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
<p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title || t('methodUnavailable.defaultTitle') }}</p>
<p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p>
</div>
</div>
</template>
@@ -1,203 +0,0 @@
<!--
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
interface ServiceItem {
id: string
code: string
name: string
}
const props = defineProps<{
services: ServiceItem[]
modelValue: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const { t } = useI18n()
/** 互斥规则配置 */
const MUTUAL_EXCLUSION_RULES: Record<string, string[]> = {
'D1': ['D2-1', 'D2-2', 'D3-1', 'D3-2', 'D3-3', 'D3-4', 'D3-6-1', 'D3-7'],
'D2-1': ['D1', 'D2-2', 'D3-4', 'D3-6-1', 'D3-7'],
'D2-2': ['D2-1', 'D3-1', 'D3-2', 'D3-3']
}
/** 当前已选服务的 code 集合 */
const selectedCodes = computed(() => {
return props.services
.filter(s => selectedSet.value.has(s.id))
.map(s => s.code)
})
/** 获取某服务被哪些服务互斥 */
const getBlockedBy = (targetCode: string): string[] => {
const blockers: string[] = []
for (const [blockerCode, blockedCodes] of Object.entries(MUTUAL_EXCLUSION_RULES)) {
if (selectedCodes.value.includes(blockerCode) && blockedCodes.includes(targetCode)) {
blockers.push(blockerCode)
}
}
return blockers
}
/** 判断某服务是否被禁用 */
const isServiceDisabled = (item: ServiceItem): { disabled: boolean; reason: string } => {
const blockers = getBlockedBy(item.code)
if (blockers.length > 0) {
const blockerServices = blockers.map(bc => {
const s = props.services.find(s => s.code === bc)
return s ? `${s.code}${s.name}` : bc
})
return { disabled: true, reason: `与已选的${blockerServices.join('、')}互斥` }
}
return { disabled: false, reason: '' }
}
const selectedSet = computed(() => new Set(props.modelValue))
const toggleService = (item: ServiceItem, checked: boolean) => {
const { disabled } = isServiceDisabled(item)
// 如果尝试勾选被禁用的服务,直接忽略
if (checked && disabled) return
const next = new Set(props.modelValue)
if (checked) {
next.add(item.id)
// 勾选时,自动取消互斥的服务
const blockedCodes = MUTUAL_EXCLUSION_RULES[item.code] || []
for (const blockedCode of blockedCodes) {
const blockedService = props.services.find(s => s.code === blockedCode)
if (blockedService) {
next.delete(blockedService.id)
}
}
} else {
next.delete(item.id)
}
emit('update:modelValue', props.services.map(s => s.id).filter(id => next.has(id)))
}
const clearAll = () => {
emit('update:modelValue', [])
}
</script>
<template>
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
<div class="mb-1 flex items-center justify-between gap-2">
<label class="block text-[11px] font-medium text-foreground leading-none">{{ t('serviceSelector.title') }}</label>
<button
type="button"
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
@click="clearAll"
>
{{ t('serviceSelector.clear') }}
</button>
</div>
<div class="rounded-md border p-1.5">
<div class="flex flex-wrap items-start gap-1">
<label
v-for="item in props.services"
:key="item.id"
:class="[
'inline-flex w-fit max-w-full cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4',
isServiceDisabled(item).disabled
? 'opacity-50 cursor-not-allowed hover:bg-transparent'
: 'hover:bg-muted/60'
]"
:title="isServiceDisabled(item).reason"
>
<input
type="checkbox"
class="mt-0.5"
:checked="selectedSet.has(item.id)"
:disabled="isServiceDisabled(item).disabled"
@change="toggleService(item, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
<span class="text-foreground break-words">{{ item.name }}</span>
</label>
</div>
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
{{ t('serviceSelector.empty') }}
</div>
</div>
</div>
</template>
-->
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
interface ServiceItem {
id: string
code: string
name: string
}
const props = defineProps<{
services: ServiceItem[]
modelValue: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const { t } = useI18n()
const selectedSet = computed(() => new Set(props.modelValue))
const toggleService = (item: ServiceItem, checked: boolean) => {
const next = new Set(props.modelValue)
if (checked) {
next.add(item.id)
} else {
next.delete(item.id)
}
emit('update:modelValue', props.services.map(s => s.id).filter(id => next.has(id)))
}
const clearAll = () => {
emit('update:modelValue', [])
}
</script>
<template>
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
<div class="mb-1 flex items-center justify-between gap-2">
<label class="block text-[11px] font-medium text-foreground leading-none">{{ t('serviceSelector.title') }}</label>
<button
type="button"
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
@click="clearAll"
>
{{ t('serviceSelector.clear') }}
</button>
</div>
<div class="rounded-md border p-1.5">
<div class="flex flex-wrap items-start gap-1">
<label
v-for="item in props.services"
:key="item.id"
class="inline-flex w-fit max-w-full cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4 hover:bg-muted/60"
>
<input
type="checkbox"
class="mt-0.5"
:checked="selectedSet.has(item.id)"
@change="toggleService(item, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
<span class="text-foreground break-words">{{ item.name }}</span>
</label>
</div>
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
{{ t('serviceSelector.empty') }}
</div>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -1,498 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
interface DictItem {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
order?: number | null
}
interface FactorRow {
id: string
code: string
name: string
standardFactor: number | null
budgetValue: number | null
remark: string
path: string[]
}
interface GridState {
detailRows: FactorRow[]
}
interface FactorChangeState {
changedRowIds: string[]
updatedAt: number
}
type DictSource = Record<string, DictItem>
const props = defineProps<{
title: string
storageKey: string
parentStorageKey?: string
dict: DictSource
disableBudgetEditWhenStandardNull?: boolean
excludeNotshowByZxflxs?: boolean
initBudgetValueFromStandard?: boolean
}>()
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore()
const detailRows = ref<FactorRow[]>([])
const gridApi = ref<GridApi<FactorRow> | null>(null)
const CHANGE_STORAGE_KEY = computed(() => `${props.storageKey}-change`)
const formatReadonlyFactor = (value: unknown) => {
if (value == null || value === '') return ''
const parsed = parseNumberOrNull(value, { precision: 3 })
if (parsed == null) return ''
return String(Number(parsed))
}
const formatEditableFactor = (params: any) => {
if (params.value == null || params.value === '') return t('xmFactorGrid.clickToInput')
const parsed = parseNumberOrNull(params.value, { precision: 3 })
if (parsed == null) return ''
return String(Number(parsed))
}
const sortedDictEntries = () =>
Object.entries(props.dict)
.filter((entry): entry is [string, DictItem] => {
const item = entry[1]
if (!item?.code || !item?.name) return false
if (props.excludeNotshowByZxflxs && item.notshowByzxflxs === true) return false
return true
})
.sort((a, b) => {
const aOrder = Number(a[1]?.order)
const bOrder = Number(b[1]?.order)
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
if (Number.isFinite(aOrder) && !Number.isFinite(bOrder)) return -1
if (!Number.isFinite(aOrder) && Number.isFinite(bOrder)) return 1
return String(a[1]?.code || a[0]).localeCompare(String(b[1]?.code || b[0]))
})
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
const parts = code.split('-').filter(Boolean)
if (!parts.length) return [selfId]
const path: string[] = []
let currentCode = parts[0]
const firstId = codeIdMap.get(currentCode)
if (firstId) path.push(firstId)
for (let i = 1; i < parts.length; i += 1) {
currentCode = `${currentCode}-${parts[i]}`
const id = codeIdMap.get(currentCode)
if (id) path.push(id)
}
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
return path
}
const buildDefaultRows = (): FactorRow[] => {
const entries = sortedDictEntries()
const codeIdMap = new Map<string, string>()
for (const [id, item] of entries) {
codeIdMap.set(item.code, id)
}
return entries.map(([id, item]) => {
const standardFactor = typeof item.defCoe === 'number' && Number.isFinite(item.defCoe) ? item.defCoe : null
return {
id,
code: item.code,
name: item.name,
standardFactor,
budgetValue: props.initBudgetValueFromStandard ? standardFactor : null,
remark: '',
path: buildCodePath(item.code, id, codeIdMap)
}
})
}
type SourceRow = Pick<FactorRow, 'id'> & Partial<Pick<FactorRow, 'budgetValue' | 'remark'>>
const hasMeaningfulFactorValue = (rows: SourceRow[] | undefined) =>
Array.isArray(rows) &&
rows.some(row => {
const hasBudgetValue = typeof row?.budgetValue === 'number' && Number.isFinite(row.budgetValue)
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
return hasBudgetValue || hasRemark
})
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasBudgetValue = Object.prototype.hasOwnProperty.call(fromDb, 'budgetValue')
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
return {
...row,
budgetValue:
typeof fromDb.budgetValue === 'number'
? fromDb.budgetValue
: hasBudgetValue
? null
: row.budgetValue,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
}
})
}
const columnDefs: ColDef<FactorRow>[] = [
{
headerName: t('xmFactorGrid.columns.standardFactor'),
field: 'standardFactor',
type: 'numericColumn',
cellClass: 'ag-right-aligned-cell',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
flex: 0.9,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => formatReadonlyFactor(params.value)
},
{
headerName: t('xmFactorGrid.columns.budgetValue'),
field: 'budgetValue',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
cellClass: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return disabled ? '' : 'editable-cell-line'
},
flex: 0.9,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return !disabled && (params.value == null || params.value === '')
}
},
editable: params => {
if (!props.disableBudgetEditWhenStandardNull) return true
return params.data?.standardFactor != null
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
if (disabled && (params.value == null || params.value === '')) return ''
return formatEditableFactor(params)
}
},
{
headerName: t('xmFactorGrid.columns.remark'),
field: 'remark',
minWidth: 170,
flex: 2.4,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: true,
valueFormatter: params => params.value || t('xmFactorGrid.clickToInput'),
cellClass: ' remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef<FactorRow> = {
headerName: t('xmFactorGrid.columns.groupName'),
minWidth: 220,
flex: 2.2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.data?.code && params.data?.name) return `${params.data.code} ${params.data.name}`
const key = String(params.node?.key || '')
const dictItem = (props.dict as DictSource)[key]
return dictItem ? `${dictItem.code} ${dictItem.name}` : ''
}
}
const saveToIndexedDB = async () => {
try {
const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
zxFwPricingStore.setKeyState(props.storageKey, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const normalizeFactorBudgetValue = (value: unknown) => {
const parsed = parseNumberOrNull(value, { precision: 6 })
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null
}
const isSameNullableFactorNumber = (left: unknown, right: unknown) => {
const normalizedLeft = normalizeFactorBudgetValue(left)
const normalizedRight = normalizeFactorBudgetValue(right)
if (normalizedLeft == null && normalizedRight == null) return true
if (normalizedLeft == null || normalizedRight == null) return false
return normalizedLeft === normalizedRight
}
const parseContractFactorMeta = (storageKey: string) => {
const consultMatch = /^ht-consult-category-factor-v1-(.+)$/.exec(storageKey)
if (consultMatch) {
return {
factorType: 'consult' as const,
contractId: String(consultMatch[1] || '').trim()
}
}
const majorMatch = /^ht-major-factor-v1-(.+)$/.exec(storageKey)
if (majorMatch) {
return {
factorType: 'major' as const,
contractId: String(majorMatch[1] || '').trim()
}
}
return null
}
const saveFactorChangeState = async (changedRowIds: string[]) => {
if (changedRowIds.length === 0) return
const payload: FactorChangeState = {
changedRowIds: Array.from(new Set(changedRowIds.map(id => String(id || '').trim()).filter(Boolean))),
updatedAt: Date.now()
}
if (payload.changedRowIds.length === 0) return
zxFwPricingStore.setKeyState(CHANGE_STORAGE_KEY.value, payload, { force: true })
const contractMeta = parseContractFactorMeta(props.storageKey)
if (!contractMeta?.contractId) return
if (contractMeta.factorType === 'consult') {
await syncContractFactorsToPricing(contractMeta.contractId, {
consultChangedServiceIds: payload.changedRowIds
})
return
}
await syncContractFactorsToPricing(contractMeta.contractId, {
majorChangedRowIds: payload.changedRowIds
})
}
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
if (!storageKey) return null
const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
if (piniaData?.detailRows && Array.isArray(piniaData.detailRows)) return piniaData
// 兼容历史 kvStore 数据:命中后迁移到 pinia keyed state。
const legacyData = await kvStore.getItem<GridState>(storageKey)
if (!legacyData?.detailRows || !Array.isArray(legacyData.detailRows)) return null
zxFwPricingStore.setKeyState(storageKey, legacyData, { force: true })
return legacyData
}
const handleGridReady = (event: GridReadyEvent<FactorRow>) => {
gridApi.value = event.api
}
const loadFromIndexedDB = async () => {
try {
const data = await loadGridState(props.storageKey)
if (data && hasMeaningfulFactorValue(data.detailRows)) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
const parentStorageKey = props.parentStorageKey?.trim()
if (parentStorageKey) {
const parentData = await loadGridState(parentStorageKey)
if (parentData && hasMeaningfulFactorValue(parentData.detailRows)) {
detailRows.value = mergeWithDictRows(parentData.detailRows)
await saveToIndexedDB()
return
}
}
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
await saveToIndexedDB()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
let isBulkClipboardMutation = false
let bulkBudgetSnapshot: Map<string, number | null> | null = null
const pendingChangedRowIds = new Set<string>()
const queueChangedRowId = (rowId: unknown) => {
const normalizedId = String(rowId || '').trim()
if (!normalizedId) return
pendingChangedRowIds.add(normalizedId)
}
const flushGridPersist = async () => {
await saveToIndexedDB()
if (pendingChangedRowIds.size === 0) return
const changedRowIds = Array.from(pendingChangedRowIds)
pendingChangedRowIds.clear()
await saveFactorChangeState(changedRowIds)
}
const scheduleGridPersist = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void flushGridPersist()
}, 500)
}
const handleCellValueChanged = (event?: any) => {
if (isBulkClipboardMutation) return
const field = String(event?.colDef?.field || '')
if (field === 'budgetValue' && !isSameNullableFactorNumber(event?.oldValue, event?.newValue)) {
queueChangedRowId(event?.data?.id)
}
scheduleGridPersist()
}
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
bulkBudgetSnapshot = new Map(
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
)
}
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
if (bulkBudgetSnapshot) {
const nextBudgetMap = new Map(
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
)
const allRowIds = new Set<string>([...bulkBudgetSnapshot.keys(), ...nextBudgetMap.keys()])
for (const rowId of allRowIds) {
if (!isSameNullableFactorNumber(bulkBudgetSnapshot.get(rowId), nextBudgetMap.get(rowId))) {
queueChangedRowId(rowId)
}
}
bulkBudgetSnapshot = null
}
scheduleGridPersist()
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value)
}
return params.value
}
const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'budgetValue') {
return parseNumberOrNull(params.value, { precision: 3 })
}
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (_error) {
return params.value
}
return params.value
}
onMounted(async () => {
await loadFromIndexedDB()
})
watch(
() => props.dict,
() => {
void loadFromIndexedDB()
},
{ deep: true }
)
onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value?.stopEditing()
gridApi.value = null
void flushGridPersist()
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="detailRows"
:columnDefs="gridColumnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
:animateRows="true"
:treeData="true"
@cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
/>
</div>
</div>
</div>
</template>
@@ -1,893 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { getIndustryDisplayName, getMajorDictEntries, isMajorIdInIndustryScope, xmProjectConfig } from '@/sql'
import { syncContractScaleToPricing, type ContractScaleSyncResult } from '@/lib/zxFwPricingSync'
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { useKvStore } from '@/pinia/kv'
import {
ToastAction,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport
} from 'reka-ui'
import { Button } from '@/components/ui/button'
interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
id: string
code: string
name: string
children: DictLeaf[]
}
interface XmScaleState {
detailRows?: DetailRow[]
totalAmount: number
roughCalcEnabled: boolean
}
interface XmBaseInfoState {
projectIndustry?: string
}
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
const CONTRACT_SCALE_KEY_PREFIX = 'ht-info-v3-'
const CONTRACT_SCALE_CHANGE_KEY_PREFIX = 'ht-info-scale-change-v1-'
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
const kvStore = useKvStore()
const { t, locale } = useI18n()
const detailRows = ref<DetailRow[]>([])
const detailDict = ref<DictGroup[]>([])
const majorEntries = ref<Array<[string, MajorLite]>>([])
const majorIdAliasMap = ref<Map<string, string>>(new Map())
const refreshMajorDictCaches = () => {
const entries = getMajorDictEntries()
majorEntries.value = entries.map(({ id, item }) => [id, item] as [string, MajorLite])
majorIdAliasMap.value = new Map(entries.map(({ rawId, id }) => [rawId, id]))
}
refreshMajorDictCaches()
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, ...item }]))
for (const [key, item] of entries) {
const isGroup = !item.code.includes('-')
if (isGroup) {
if (!groupMap.has(item.code)) groupOrder.push(item.code)
groupMap.set(item.code, {
id: key,
code: item.code,
name: item.name,
children: []
})
continue
}
const parentCode = item.code.split('-')[0]
if (!groupMap.has(parentCode)) {
const parent = codeLookup.get(parentCode)
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: parent?.name || parentCode,
children: []
})
}
groupMap.get(parentCode)!.children.push({
id: key,
code: item.code,
name: item.name,
hasCost: item.hasCost !== false,
hasArea: item.hasArea !== false
})
}
const result = groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
return result
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
// 1. 首先添加所有分组行(即使没有子项)
for (const group of detailDict.value) {
if (group.code != 'C0') {
rows.push({
id: group.id,
groupCode: group.code,
groupName: group.name,
majorCode: group.code,
majorName: group.name,
hasCost: true,
hasArea: true,
amount: null,
landArea: null,
path: [`${group.code} ${group.name}`],
isGroupRow: true,
hide: false
})
}
}
for (const group of detailDict.value) {
for (const child of group.children) {
rows.push({
id: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
landArea: null,
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
})
}
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
if (row?.isGroupRow === true) continue
const rowId = String(row.id)
dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.value.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, row)
}
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
hide: fromDb.hide,
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null
}
})
}
const buildGroupRows = (rows: DetailRow[]): DetailRow[] => {
const rowById = new Map(rows.map(row => [String(row.id || ''), row] as const))
const groupRows: DetailRow[] = []
for (const group of detailDict.value) {
let amountTotal = 0
let hasAmount = false
let landAreaTotal = 0
let hasLandArea = false
for (const child of group.children) {
const leaf = rowById.get(String(child.id || ''))
const amount = leaf?.amount
if (typeof amount === 'number' && Number.isFinite(amount)) {
amountTotal += amount
hasAmount = true
}
const landArea = leaf?.landArea
if (typeof landArea === 'number' && Number.isFinite(landArea)) {
landAreaTotal += landArea
hasLandArea = true
}
}
groupRows.push({
id: group.id,
groupCode: group.code,
groupName: group.name,
majorCode: group.code,
majorName: group.name,
hasCost: true,
hasArea: true,
amount: hasAmount ? roundTo(amountTotal, 3) : null,
landArea: hasLandArea ? roundTo(landAreaTotal, 3) : null,
path: [`${group.code} ${group.name}`],
hide: false,
isGroupRow: true
})
}
return groupRows
}
const applyPinnedTotalAmount = (
api: GridApi<DetailRow> | null | undefined,
totalAmount: number | null | undefined
) => {
const normalized = typeof totalAmount === 'number' && Number.isFinite(totalAmount)
? roundTo(totalAmount, 2)
: null
pinnedTopRowData.value[0].amount = normalized
const pinnedTopNode = api?.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', normalized)
}
}
const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
try {
const [baseInfo, contractData] = await Promise.all([
kvStore.getItem<XmBaseInfoState>(props.baseInfoKey || 'xm-base-info-v1'),
kvStore.getItem<XmScaleState>(props.dbKey)
])
activeIndustryId.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
if (!activeIndustryId.value) {
detailDict.value = []
detailRows.value = []
lastPersistedLeafRows = []
roughCalcEnabled.value = false
applyPinnedTotalAmount(api, null)
return
}
const filteredEntries = majorEntries.value.filter(([id]) =>
isMajorIdInIndustryScope(id, activeIndustryId.value)
)
detailDict.value = buildDetailDict(filteredEntries)
roughCalcEnabled.value = Boolean(contractData?.roughCalcEnabled)
applyPinnedTotalAmount(api, contractData?.totalAmount)
const contractRows = Array.isArray(contractData?.detailRows) ? contractData.detailRows : []
const hasContractRows = contractRows.length > 0
const hasContractScaleValue = hasContractRows
? contractRows.some(row => {
const amount = row?.amount
const landArea = row?.landArea
return (
(typeof amount === 'number' && Number.isFinite(amount)) ||
(typeof landArea === 'number' && Number.isFinite(landArea))
)
})
: false
const isLegacyEmptyScaleRows =
hasContractRows &&
!hasContractScaleValue &&
!roughCalcEnabled.value &&
typeof contractData?.totalAmount === 'number' &&
Number.isFinite(contractData.totalAmount)
if (hasContractRows && !isLegacyEmptyScaleRows) {
detailRows.value = mergeWithDictRows(contractRows)
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
return
}
if (props.xmInfoKey) {
// 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐)
const xmData = await kvStore.getItem<XmScaleState>(props.xmInfoKey)
roughCalcEnabled.value = Boolean(xmData?.roughCalcEnabled)
applyPinnedTotalAmount(api, xmData?.totalAmount)
if (Array.isArray(xmData?.detailRows) && xmData.detailRows.length > 0) {
detailRows.value = mergeWithDictRows(xmData.detailRows)
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
return
}
}
detailRows.value = buildDefaultRows()
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
void saveToIndexedDB()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
activeIndustryId.value = ''
detailRows.value = []
lastPersistedLeafRows = []
roughCalcEnabled.value = false
applyPinnedTotalAmount(api, null)
}
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
path: string[]
hide?: boolean
isGroupRow?: boolean
}
interface XmBaseInfoState {
projectIndustry?: string
}
interface GridPersistState {
detailRows?: DetailRow[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
interface ContractScaleChangeState {
changedRowIds: string[]
updatedAt: number
}
const XM_PROJECT_PHASE_KEY = 'xm-project-phase-v1'
interface ProjectPhaseState {
feePhase?: string
feeStage?: string
}
const props = defineProps<{
title: string
dbKey: string
xmInfoKey?: string | null
baseInfoKey?: string
}>()
let persistTimer: ReturnType<typeof setTimeout> | null = null
const gridApi = ref<GridApi<DetailRow> | null>(null)
const activeIndustryId = ref('')
const totalLabel = ref(xmProjectConfig.pinnedTotalLabel)
const roughCalcEnabled = ref(false)
const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) })
const feePhaseOptions = xmProjectConfig.feePhaseOptions
const feeStageOptions = xmProjectConfig.feeStageOptions
const feePhase = ref('')
const feeStage = ref('')
const loadPhaseState = async () => {
try {
const state = await kvStore.getItem<ProjectPhaseState>(XM_PROJECT_PHASE_KEY)
if (state) {
feePhase.value = state.feePhase || ''
feeStage.value = state.feeStage || ''
}
} catch (error) {
console.error('load phase state failed:', error)
}
}
const savePhaseState = async () => {
try {
await kvStore.setItem<ProjectPhaseState>(XM_PROJECT_PHASE_KEY, {
feePhase: feePhase.value,
feeStage: feeStage.value
})
} catch (error) {
console.error('save phase state failed:', error)
}
}
watch([feePhase, feeStage], () => {
void savePhaseState()
})
void loadPhaseState()
const refreshPinnedTotalLabelCell = () => {
if (!gridApi.value) return
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
if (!pinnedTopNode) return
gridApi.value.refreshCells({
rowNodes: [pinnedTopNode],
force: true
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: t('pricingScale.columns.investAmount'),
field: 'amount',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => {
if (roughCalcEnabled.value) return Boolean(params.node?.rowPinned)
return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost)
},
cellClass: params =>
roughCalcEnabled.value && params.node?.rowPinned
? 'editable-cell-line'
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
roughCalcEnabled.value && params.node?.rowPinned
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
if (roughCalcEnabled.value) {
if (!params.node?.rowPinned) return ''
if (params.value == null || params.value === '') return t('pricingScale.clickToInput')
return formatThousandsFlexible(params.value, 3)
}
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return t('pricingScale.clickToInput')
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
},
{
headerName: '造价金额(元)',
field: 'amountYuan',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
editable: false,
aggFunc: decimalAggSum,
valueGetter: params => params.data?.amount,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (roughCalcEnabled.value) {
return ''
}
const amount = params.value
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
return ''
}
return formatThousandsFlexible(roundTo(amount * 10000, 0), 0)
}
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef = {
headerName: t('pricingScale.columns.majorGroup'),
minWidth: 200,
flex: 2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) return totalLabel.value
return String(params.value || '')
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return totalLabel.value
return String(params.value || '')
}
}
const pinnedTopRowData = ref<DetailRow[]>([
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
hasCost: false,
hasArea: false,
amount: null,
landArea: null,
path: ['TOTAL']
}
])
const syncToastOpen = ref(false)
const syncToastText = ref('')
let lastPersistedLeafRows: DetailRow[] | null = null
const cloneLeafRows = (rows: DetailRow[]) =>
rows.map(row => ({
...JSON.parse(JSON.stringify(row)),
hide: Boolean(row.hide),
isGroupRow: false
}))
const getChangedScaleRowIds = (previousLeafRows: DetailRow[], nextLeafRows: DetailRow[]) => {
const previousRowMap = new Map(previousLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
const nextRowMap = new Map(nextLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
return Array.from(new Set([
...previousRowMap.keys(),
...nextRowMap.keys()
])).filter(rowId => {
const prevRow = previousRowMap.get(rowId)
const nextRow = nextRowMap.get(rowId)
const prevAmount = typeof prevRow?.amount === 'number' && Number.isFinite(prevRow.amount) ? roundTo(prevRow.amount, 6) : null
const nextAmount = typeof nextRow?.amount === 'number' && Number.isFinite(nextRow.amount) ? roundTo(nextRow.amount, 6) : null
const prevLandArea = typeof prevRow?.landArea === 'number' && Number.isFinite(prevRow.landArea) ? roundTo(prevRow.landArea, 6) : null
const nextLandArea = typeof nextRow?.landArea === 'number' && Number.isFinite(nextRow.landArea) ? roundTo(nextRow.landArea, 6) : null
return prevAmount !== nextAmount || prevLandArea !== nextLandArea
})
}
const showScaleSyncToast = (result: ContractScaleSyncResult) => {
if (result.updatedMethodCount <= 0) return
syncToastText.value = t('xmScaleGrid.syncToastDesc', {
serviceCount: result.updatedServiceCount,
methodCount: result.updatedMethodCount,
rowCount: result.updatedRowCount
})
syncToastOpen.value = false
requestAnimationFrame(() => {
syncToastOpen.value = true
})
}
const saveToIndexedDB = async () => {
try {
const leafRows = cloneLeafRows(detailRows.value)
const totalAmountFromRows = (() => {
let hasValue = false
let total = 0
for (const row of leafRows) {
const amount = row?.amount
if (typeof amount !== 'number' || !Number.isFinite(amount)) continue
total += amount
hasValue = true
}
return hasValue ? roundTo(total, 2) : null
})()
const pinnedAmount = pinnedTopRowData.value[0].amount
const normalizedPinnedAmount =
typeof pinnedAmount === 'number' && Number.isFinite(pinnedAmount)
? roundTo(pinnedAmount, 2)
: null
const normalizedTotalAmount = roughCalcEnabled.value ? normalizedPinnedAmount : totalAmountFromRows
pinnedTopRowData.value[0].amount = normalizedTotalAmount
const payload: GridPersistState = {
detailRows: [...leafRows, ...buildGroupRows(leafRows)]
}
payload.roughCalcEnabled = roughCalcEnabled.value
payload.totalAmount = normalizedTotalAmount
await kvStore.setItem(props.dbKey, payload)
const previousLeafRows = lastPersistedLeafRows || leafRows
const changedRowIds = getChangedScaleRowIds(previousLeafRows, leafRows)
lastPersistedLeafRows = cloneLeafRows(leafRows)
if (props.dbKey.startsWith(CONTRACT_SCALE_KEY_PREFIX)) {
const contractId = props.dbKey.slice(CONTRACT_SCALE_KEY_PREFIX.length).trim()
if (contractId && changedRowIds.length > 0) {
await kvStore.setItem<ContractScaleChangeState>(`${CONTRACT_SCALE_CHANGE_KEY_PREFIX}${contractId}`, {
changedRowIds,
updatedAt: Date.now()
})
const syncResult = await syncContractScaleToPricing(contractId, { changedRowIds })
showScaleSyncToast(syncResult)
}
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 600)
}
const getRowId = (params: { data?: DetailRow }) => String(params.data?.id || '')
const detailGridOptions: GridOptions<DetailRow> = {
...gridOptions,
getRowId
}
const handleFlushPersistRequest = (event: Event) => {
const customEvent = event as CustomEvent<{ done?: () => void }>
const done = customEvent?.detail?.done
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
void saveToIndexedDB().finally(() => {
done?.()
})
}
const setDetailRowsHidden = (hidden: boolean) => {
for (const row of detailRows.value) {
row.hide = hidden
}
}
let oldValue:number|null
let isBulkClipboardMutation = false
const commitGridChanges = () => {
if (roughCalcEnabled.value) {
const rawAmount = pinnedTopRowData.value[0]?.amount
const parsed = typeof rawAmount === 'number' ? rawAmount : Number(rawAmount)
const nextAmount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
pinnedTopRowData.value[0].amount = nextAmount
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', nextAmount)
}
} else {
syncPinnedTotalForNormalMode()
}
schedulePersist()
}
const onRoughCalcSwitch = (checked: boolean) => {
gridApi.value?.stopEditing(true)
roughCalcEnabled.value = checked
setDetailRowsHidden(checked)
if (!checked) {
oldValue=pinnedTopRowData.value[0].amount
syncPinnedTotalForNormalMode()
} else {
pinnedTopRowData.value[0].amount = oldValue
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', oldValue)
}
}
schedulePersist()
}
const onCellValueChanged = (event: CellValueChangedEvent) => {
if (isBulkClipboardMutation) return
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
if (typeof event.newValue === 'number') {
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
} else {
const parsed = Number(event.newValue)
pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
}
} else if (!roughCalcEnabled.value) {
syncPinnedTotalForNormalMode()
}
schedulePersist()
}
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
}
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
commitGridChanges()
}
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
void loadFromIndexedDB(event.api)
void refreshPinnedTotalLabelCell()
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value)
}
return params.value
}
const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'amount' || field === 'landArea') {
return parseNumberOrNull(params.value, { precision: 3 })
}
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (error) {
// no-op
}
return params.value
}
const relabelRowsFromMajorDict = async () => {
refreshMajorDictCaches()
if (!activeIndustryId.value) return
const filteredEntries = majorEntries.value.filter(([id]) =>
isMajorIdInIndustryScope(id, activeIndustryId.value)
)
detailDict.value = buildDetailDict(filteredEntries)
if (detailRows.value.length === 0) {
gridApi.value?.refreshCells({ force: true })
return
}
const nextRows = mergeWithDictRows(detailRows.value)
const changed = nextRows.some((row, index) => {
const current = detailRows.value[index]
return (
current?.groupCode !== row.groupCode ||
current?.groupName !== row.groupName ||
current?.majorCode !== row.majorCode ||
current?.majorName !== row.majorName
)
})
detailRows.value = nextRows
gridApi.value?.refreshCells({ force: true })
void refreshPinnedTotalLabelCell()
if (!changed) return
await saveToIndexedDB()
}
watch(totalLabel, () => {
refreshPinnedTotalLabelCell()
})
watch(
() => locale.value,
() => {
void relabelRowsFromMajorDict()
}
)
const syncPinnedTotalForNormalMode = () => {
if (roughCalcEnabled.value) return
if (!gridApi.value) {
pinnedTopRowData.value[0].amount = sumByNumber(detailRows.value, row => row.amount)
return
}
let total = 0
let hasValue = false
detailRows.value.forEach(node => {
const amount = node.amount
if (typeof amount === 'number' && Number.isFinite(amount)) {
total += amount
hasValue = true
}
})
pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null)
}
}
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
window.removeEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
gridApi.value = null
void saveToIndexedDB()
})
onMounted(() => {
window.addEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
})
</script>
<template>
<ToastProvider>
<div class="h-full">
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
{{ props.title }}
</h3>
<!-- <div class="flex items-center gap-2">
<span class=" text-xs text-muted-foreground">简要计算</span>
<SwitchRoot
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
<SwitchThumb
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
</SwitchRoot>
</div> -->
</div>
<!-- Project Phase Selection -->
<div class="border-b px-4 py-3 space-y-3 bg-card">
<div class="flex items-center gap-4">
<span class="shrink-0 text-sm font-medium text-foreground w-28">项目费用阶段</span>
<div class="flex-1 flex flex-wrap gap-2">
<button
v-for="option in feePhaseOptions"
:key="option"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="feePhase === option
? 'bg-blue-600 border-blue-600 text-white font-medium'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'"
@click="feePhase = option"
>
{{ option }}
</button>
</div>
</div>
<div class="flex items-center gap-4">
<span class="shrink-0 text-sm font-medium text-foreground w-28">项目费用环节</span>
<div class="flex-1 flex flex-wrap gap-2">
<button
v-for="option in feeStageOptions"
:key="option"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="feeStage === option
? 'bg-blue-600 border-blue-600 text-white font-medium'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'"
@click="feeStage = option"
>
{{ option }}
</button>
</div>
</div>
</div>
<div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
:animateRows="true"
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
</div>
<ToastRoot
v-model:open="syncToastOpen"
class="group pointer-events-auto rounded-lg border bg-background px-4 py-3 text-sm shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=closed]:fade-out data-[state=open]:slide-in-from-bottom-2"
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<ToastTitle class="text-sm font-semibold text-foreground">{{ t('xmScaleGrid.syncToastTitle') }}</ToastTitle>
<ToastDescription class="text-xs text-muted-foreground">{{ syncToastText }}</ToastDescription>
</div>
<ToastAction as-child :alt-text="t('common.close')">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="syncToastOpen = false">
{{ t('common.close') }}
</Button>
</ToastAction>
</div>
</ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[420px] max-w-[92vw] flex-col gap-2 outline-none" />
</div>
</ToastProvider>
</template>
-134
View File
@@ -1,134 +0,0 @@
import type localforage from 'localforage'
import { i18n } from '@/i18n'
export interface DataEntry {
key: string
value: any
}
export interface ForageStoreSnapshot {
storeName: string
entries: DataEntry[]
}
export interface DataPackage {
version: number
packageType?: 'project-snapshot'
exportedAt: string
projectId?: string
localStorage: DataEntry[]
sessionStorage: DataEntry[]
localforageDefault: DataEntry[]
localforageStores?: ForageStoreSnapshot[]
}
export type ForageInstance = ReturnType<typeof localforage.createInstance>
export type ForageStore = Pick<ForageInstance, 'keys' | 'getItem' | 'setItem' | 'clear'>
type XmInfoLike = {
projectName?: unknown
}
export const readWebStorage = (storageObj: Storage): DataEntry[] => {
const entries: DataEntry[] = []
for (let i = 0; i < storageObj.length; i++) {
const key = storageObj.key(i)
if (!key) continue
const raw = storageObj.getItem(key)
let value: any = raw
if (raw != null) {
try {
value = JSON.parse(raw)
} catch {
value = raw
}
}
entries.push({ key, value })
}
return entries
}
export const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
storageObj.clear()
for (const entry of entries || []) {
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
storageObj.setItem(entry.key, value)
}
}
export const toPersistableValue = (value: unknown) => {
try {
return JSON.parse(JSON.stringify(value))
} catch (error) {
console.error('normalize persist value failed, fallback to null:', error)
return null
}
}
export const readForage = async (store: ForageStore): Promise<DataEntry[]> => {
const keys = await store.keys()
const values = await Promise.all(keys.map(key => store.getItem(key)))
return keys.map((key, index) => ({
key,
value: toPersistableValue(values[index])
}))
}
export const writeForage = async (store: ForageStore, entries: DataEntry[]) => {
await store.clear()
await Promise.all((entries || []).map(entry => store.setItem(entry.key, toPersistableValue(entry.value))))
}
export const normalizeEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
}
export const normalizeForageStoreSnapshots = (value: unknown): ForageStoreSnapshot[] => {
if (!Array.isArray(value)) return []
return value
.filter(item =>
item
&& typeof item === 'object'
&& typeof (item as any).storeName === 'string'
&& Array.isArray((item as any).entries)
)
.map(item => ({
storeName: String((item as any).storeName),
entries: normalizeEntries((item as any).entries)
}))
}
export const sanitizeFileNamePart = (value: string): string => {
const cleaned = value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned || i18n.global.t('tab.messages.defaultProjectName')
}
export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: string, legacyProjectDbKey: string) => {
const target =
entries.find(item => item.key === projectInfoDbKey) ||
entries.find(item => item.key === legacyProjectDbKey)
const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string'
? sanitizeFileNamePart(data.projectName)
: i18n.global.t('tab.messages.defaultProjectName')
}
export const isDataPackageLike = (value: unknown): value is DataPackage => {
if (!value || typeof value !== 'object') return false
const payload = value as Partial<DataPackage>
const hasRequiredArrays =
Array.isArray(payload.localStorage) &&
Array.isArray(payload.sessionStorage) &&
Array.isArray(payload.localforageDefault)
if (!hasRequiredArrays) return false
if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) return false
if (payload.packageType != null && payload.packageType !== 'project-snapshot') return false
if (payload.projectId != null && typeof payload.projectId !== 'string') return false
return true
}
-67
View File
@@ -1,67 +0,0 @@
.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);
}
/* Sidebar slide transitions */
.slide-in-left-enter-active,
.slide-in-left-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-in-left-enter-from,
.slide-in-left-leave-to {
transform: translateX(100%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
-342
View File
@@ -1,342 +0,0 @@
export interface UserGuideStep {
title: string
description: string
points: string[]
}
export type XmInfoLike = {
projectName?: unknown
preparedBy?: unknown
reviewedBy?: unknown
preparedDate?: unknown
projectIndustry?: unknown
preparedCompany?: unknown
overview?: unknown
desc?: unknown
}
export type HtBaseInfoLike = {
quality?: unknown
duration?: unknown
}
export interface ScaleRowLike {
id: string
amount: number | null
landArea: number | null
}
export interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[]
totalAmount?: number
roughCalcEnabled?: boolean
}
export interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
}
export interface ContractCardItem {
id: string
name?: string
order?: number
}
export interface ZxFwRowLike {
id: string
process?: unknown
subtotal?: unknown
finalFee?: unknown
investScale?: unknown
landScale?: unknown
workload?: unknown
hourly?: unknown
}
export interface WorkContentRowLike {
id?: unknown
content?: unknown
checked?: unknown
custom?: unknown
serviceGroup?: unknown
serviceid?: unknown
isAddTrigger?: unknown
}
export interface WorkContentStateLike {
detailRows?: WorkContentRowLike[]
}
export interface ZxFwStorageLike {
selectedIds?: string[]
selectedCodes?: string[]
detailRows?: ZxFwRowLike[]
}
export interface ScaleMethodRowLike extends ScaleRowLike {
benchmarkBudgetBasicChecked?: unknown
benchmarkBudgetOptionalChecked?: unknown
basicFormula?: unknown
optionalFormula?: unknown
budgetFee?: unknown
budgetFeeBasic?: unknown
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
workStageFactor?: unknown
workRatio?: unknown
remark?: unknown
}
export interface HtFeeMainRowLike {
id?: unknown
name?: unknown
}
export interface RateMethodRowLike {
rate?: unknown
budgetFee?: unknown
}
export interface QuantityMethodRowLike {
id?: unknown
feeItem?: unknown
unit?: unknown
quantity?: unknown
unitPrice?: unknown
budgetFee?: number | null
remark?: unknown
}
export interface WorkloadMethodRowLike {
id: string
budgetAdoptedUnitPrice?: unknown
workload?: unknown
basicFee?: unknown
consultCategoryFactor?: unknown
serviceFee?: unknown
remark?: unknown
}
export interface HourlyMethodRowLike {
id: string
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
remark?: unknown
}
export interface DetailRowsStorageLike<T> {
detailRows?: T[]
roughCalcEnabled?: boolean
totalAmount?: number
}
export interface FactorRowLike {
id: string
standardFactor?: unknown
budgetValue?: unknown
remark?: unknown
}
export interface ExportScaleRow {
major: number
cost: number | null
area: number | null
}
export interface ExportMethod1Detail {
proNum: number
major: number
cost: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
export interface ExportMethod1 {
proAmount: number
cost: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod1Detail[]
}
export interface ExportMethod2Detail {
proNum: number
major: number
area: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
export interface ExportMethod2 {
proAmount: number
area: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod2Detail[]
}
export interface ExportMethod3Detail {
task: number
price: number
amount: number
basicFee: number
serviceCoe: number
fee: number
remark: string
}
export interface ExportMethod3 {
basicFee: number
fee: number
det: ExportMethod3Detail[]
}
export interface ExportMethod4Detail {
expert: number
price: number
person_num: number
work_day: number
fee: number
remark: string
}
export interface ExportMethod4 {
person_num: number
work_day: number
fee: number
det: ExportMethod4Detail[]
}
export interface ExportService {
id: number
fee: number
finalFee: number
process: number
tasks: ExportTaskGroup[]
method1?: ExportMethod1
method2?: ExportMethod2
method3?: ExportMethod3
method4?: ExportMethod4
}
export interface ExportTaskGroup {
serviceid?: number
text: string[]
}
export interface ExportServiceCoe {
serviceid: number
coe: number
remark: string
}
export interface ExportMajorCoe {
majorid: number
coe: number
remark: string
}
export interface ExportContract {
name: string
serviceFee: number
addtionalFee: number
reserveFee: number
fee: number
quality: string
duration: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
services: ExportService[]
addtional: ExportAdditional | null
reserve: ExportReserve | null
}
export interface ExportMethod0 {
coe: number
fee: number
}
export interface ExportMethod5Detail {
name: string
unit: string
amount: number
price: number
fee: number
remark: string
}
export interface ExportMethod5 {
fee: number
det: ExportMethod5Detail[]
}
export interface ExportAdditionalDetail {
id: number | string
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
export interface ExportAdditional {
code?: unknown
name: string
fee: number
det: ExportAdditionalDetail[]
}
export interface ExportReserve {
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
export interface ExportReportPayload {
name: string
writer: string
reviewer: string
company: string
date: string
industry: number
fee: number
scaleCost: number
overview: string
desc: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
contracts: ExportContract[]
}
@@ -1,782 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab'
import { useKvStore } from '@/pinia/kv'
import { useUiPrefsStore } from '@/pinia/uiPrefs'
import {
BarChart3,
Calculator,
Check,
Languages,
X
} from 'lucide-vue-next'
import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { initializeProjectFactorStates, initializeProjectScaleState } from '@/lib/projectWorkspace'
import {
buildProjectUrl,
DEFAULT_PROJECT_ID,
FORCE_HOME_QUERY_KEY,
NEW_PROJECT_QUERY_KEY,
OPEN_PROJECT_DIALOG_QUERY_KEY,
PROJECT_TAB_ID,
QUICK_PROJECT_ID,
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
QUICK_CONTRACT_FALLBACK_NAME,
QUICK_CONTRACT_ID,
QUICK_CONTRACT_META_KEY,
QUICK_MAJOR_FACTOR_KEY,
QUICK_PROJECT_INFO_KEY,
readCurrentProjectId,
writeProjectIdToUrl,
setPendingHomeImportFile,
writeWorkspaceMode
} from '@/lib/workspace'
import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry'
import { createProjectKvAdapter } from '@/lib/projectKvStore'
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
interface QuickProjectInfoState {
projectIndustry?: string
projectName?: string
preparedBy?: string
reviewedBy?: string
preparedCompany?: string
preparedDate?: string
}
interface QuickContractMetaState {
id?: string
name?: string
updatedAt?: string
}
interface ProjectInfoState {
projectIndustry?: string
projectName?: string
preparedBy?: string
reviewedBy?: string
preparedCompany?: string
preparedDate?: string
}
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
const PROJECT_SCALE_KEY = 'xm-info-v3'
const getActiveProjectId = () => readCurrentProjectId()
const tabStore = useTabStore()
const kvStore = useKvStore()
const uiPrefsStore = useUiPrefsStore()
const { t, locale } = useI18n()
const projectDialogOpen = ref(false)
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
const projectSubmitting = ref(false)
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
const quickSubmitting = ref(false)
const homeImportInputRef = ref<HTMLInputElement | null>(null)
const homeImportConfirmOpen = ref(false)
const pendingHomeImportFile = ref<File | null>(null)
const pendingHomeImportFileName = ref('')
const existingProjectDialogOpen = ref(false)
const existingProjects = ref<Array<{ id: string; name: string; updatedAt: string }>>([])
const existingProjectLoading = ref(false)
const hasExistingProjects = ref(false)
const openedProjectIds = ref<string[]>([])
let existingProjectPollTimer: ReturnType<typeof setInterval> | null = null
const RELATED_FILES_URL = 'https://www.lianzhong.com.cn/file'
const localeBadge = computed(() => (locale.value === 'en-US' ? 'EN' : '中'))
const toggleLocale = () => {
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
uiPrefsStore.setLocale(next as 'zh-CN' | 'en-US')
}
const openRelatedFiles = () => {
window.open(RELATED_FILES_URL, '_blank', 'noopener')
}
const resolveProjectRegistryName = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim()
if (projectId !== DEFAULT_PROJECT_ID) return undefined
return t('xmInfo.defaultProjectName')
}
const writeCleanProjectUrl = (projectIdRaw: string) => {
try {
const href = buildProjectUrl(projectIdRaw, { forceHome: false, newProject: false })
window.history.replaceState({}, '', href)
} catch {
writeProjectIdToUrl(projectIdRaw)
}
}
const navigateToWorkspace = (projectIdRaw: string, mode: 'project' | 'quick') => {
const projectId = String(projectIdRaw || '').trim()
if (!projectId) return false
writeWorkspaceMode(mode)
const currentProjectId = getActiveProjectId()
const shouldReloadApp = mode === 'project' && currentProjectId !== projectId
if (shouldReloadApp) {
window.location.href = buildProjectUrl(projectId, { forceHome: false, newProject: false })
return false
}
writeCleanProjectUrl(projectId)
return true
}
const getTodayDateString = () => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const formatProjectEditedTime = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (num: number) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const enterProjectCalc = () => {
const projectId = getActiveProjectId()
upsertProject(projectId, resolveProjectRegistryName(projectId))
if (!navigateToWorkspace(projectId, 'project')) return
tabStore.enterWorkspace({
id: PROJECT_TAB_ID,
title: t('home.projectCalcTab'),
componentName: 'ProjectCalcView'
})
tabStore.hasCompletedSetup = true
}
const loadProjectDefaults = async () => {
const savedInfo = await kvStore.getItem<ProjectInfoState>(PROJECT_INFO_KEY)
projectIndustry.value =
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
? savedInfo.projectIndustry.trim()
: String(industryTypeList[0]?.id || '')
}
const openProjectCalc = async () => {
projectDialogOpen.value = true
}
const syncExistingProjectOpenedState = (projectIds: string[]) => {
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(projectIds))
}
const isExistingProjectOpened = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim()
return projectId ? openedProjectIds.value.includes(projectId) : false
}
const refreshExistingProjects = async (options?: { showLoading?: boolean }) => {
if (options?.showLoading !== false) {
existingProjectLoading.value = true
}
try {
const projects = listProjects()
.filter(item => item.id !== QUICK_PROJECT_ID)
.sort((a, b) => {
const left = new Date(a.updatedAt).getTime()
const right = new Date(b.updatedAt).getTime()
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0)
})
existingProjects.value = projects.map(project => ({
id: project.id,
name: project.name,
updatedAt: project.updatedAt
}))
hasExistingProjects.value = projects.length > 0
syncExistingProjectOpenedState(projects.map(project => project.id))
} finally {
if (options?.showLoading !== false) {
existingProjectLoading.value = false
}
}
}
const stopExistingProjectPolling = () => {
if (existingProjectPollTimer == null) return
clearInterval(existingProjectPollTimer)
existingProjectPollTimer = null
}
const startExistingProjectPolling = () => {
stopExistingProjectPolling()
existingProjectPollTimer = setInterval(() => {
if (!existingProjectDialogOpen.value) return
void refreshExistingProjects({ showLoading: false })
}, 3000)
}
const openExistingProjectDialog = async () => {
projectDialogOpen.value = false
existingProjectDialogOpen.value = true
await refreshExistingProjects()
startExistingProjectPolling()
}
const closeExistingProjectDialog = () => {
existingProjectDialogOpen.value = false
stopExistingProjectPolling()
}
const enterExistingProject = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim()
if (!projectId || isExistingProjectOpened(projectId)) return
upsertProject(projectId, resolveProjectRegistryName(projectId))
if (!navigateToWorkspace(projectId, 'project')) return
tabStore.enterWorkspace({
id: PROJECT_TAB_ID,
title: t('home.projectCalcTab'),
componentName: 'ProjectCalcView'
})
tabStore.hasCompletedSetup = true
closeExistingProjectDialog()
}
const closeProjectCalcDialog = () => {
projectDialogOpen.value = false
}
const confirmProjectCalc = async () => {
const industry = projectIndustry.value.trim()
if (!industry) return
projectSubmitting.value = true
try {
const project = createProject(t('xmInfo.defaultProjectName'))
const kvAdapter = createProjectKvAdapter(project.id)
await kvAdapter.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
projectIndustry: industry,
projectName: t('xmInfo.defaultProjectName'),
preparedBy: '',
reviewedBy: '',
preparedCompany: '',
preparedDate: getTodayDateString()
})
await initializeProjectFactorStates(
kvAdapter,
industry,
PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
PROJECT_MAJOR_FACTOR_KEY
)
await initializeProjectScaleState(kvAdapter, industry, PROJECT_SCALE_KEY)
writeWorkspaceMode('project')
window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false })
} finally {
projectSubmitting.value = false
projectDialogOpen.value = false
}
}
const loadQuickDefaults = async () => {
const [savedInfo, savedMeta] = await Promise.all([
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
])
quickIndustry.value =
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
? savedInfo.projectIndustry.trim()
: String(industryTypeList[0]?.id || '')
quickContractName.value =
typeof savedMeta?.name === 'string' && savedMeta.name.trim()
? savedMeta.name.trim()
: QUICK_CONTRACT_FALLBACK_NAME
}
const enterQuickCalc = (contractName: string) => {
if (!navigateToWorkspace(QUICK_PROJECT_ID, 'quick')) return
tabStore.enterWorkspace({
id: `contract-${QUICK_CONTRACT_ID}`,
title: t('home.quickCalcTab'),
componentName: 'QuickCalcWorkbenchView',
props: {
contractId: QUICK_CONTRACT_ID,
contractName,
projectInfoKey: QUICK_PROJECT_INFO_KEY,
projectScaleKey: null,
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
}
})
tabStore.hasCompletedSetup = true
}
const openQuickCalc = () => {
window.alert(t('home.cards.developing'))
}
const handleHomeImportChange = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
pendingHomeImportFile.value = file
pendingHomeImportFileName.value = file.name
homeImportConfirmOpen.value = true
input.value = ''
}
const openHomeImport = () => {
homeImportInputRef.value?.click()
}
const cancelHomeImportConfirm = () => {
homeImportConfirmOpen.value = false
pendingHomeImportFile.value = null
pendingHomeImportFileName.value = ''
}
const confirmHomeImport = async () => {
const file = pendingHomeImportFile.value
if (!file) return
await setPendingHomeImportFile(file, { skipWorkspaceConfirm: true })
const targetProject = createProject(t('xmInfo.defaultProjectName'))
writeWorkspaceMode('project')
window.location.href = buildProjectUrl(targetProject.id, { forceHome: false, newProject: false })
cancelHomeImportConfirm()
}
const handleHomeWindowFocus = () => {
if (!existingProjectDialogOpen.value) return
void refreshExistingProjects({ showLoading: false })
}
const handleHomeVisibilityChange = () => {
if (document.visibilityState !== 'visible') return
handleHomeWindowFocus()
}
onMounted(() => {
void refreshExistingProjects()
void loadProjectDefaults()
void loadQuickDefaults()
window.addEventListener('focus', handleHomeWindowFocus)
document.addEventListener('visibilitychange', handleHomeVisibilityChange)
try {
const url = new URL(window.location.href)
const isNewProject = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
if (isNewProject) {
const projectId = getActiveProjectId()
upsertProject(projectId, resolveProjectRegistryName(projectId))
const openProjectDialog = url.searchParams.get(OPEN_PROJECT_DIALOG_QUERY_KEY) !== '0'
if (openProjectDialog) {
void openProjectCalc()
}
url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
url.searchParams.delete(OPEN_PROJECT_DIALOG_QUERY_KEY)
url.searchParams.delete(FORCE_HOME_QUERY_KEY)
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
}
} catch {
// ignore url parsing errors
}
})
onBeforeUnmount(() => {
stopExistingProjectPolling()
window.removeEventListener('focus', handleHomeWindowFocus)
document.removeEventListener('visibilitychange', handleHomeVisibilityChange)
})
</script>
<template>
<input ref="homeImportInputRef" type="file" accept=".zw" class="sr-only" @change="handleHomeImportChange" />
<div class="home-entry relative flex min-h-full items-center justify-center overflow-hidden px-4 py-8 lg:py-10">
<div class="pointer-events-none absolute inset-0 bg-[url('/background.png')] bg-cover bg-center bg-no-repeat" />
<div class="pointer-events-none absolute inset-0 bg-white/78" />
<div
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(59,130,246,0.08),transparent_70%)]"
/>
<div class="relative w-full max-w-[1240px]">
<div class="absolute right-0 top-0 z-10">
<button
type="button"
class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-full border border-slate-200/80 bg-white/85 px-3 text-xs text-slate-600 shadow-sm backdrop-blur transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
@click="toggleLocale"
>
<Languages class="h-3.5 w-3.5" />
<span>{{ localeBadge }}</span>
</button>
</div>
<div class="home-title text-center">
<h1
class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl"
style="font-family: HarmonyOS_Sans_SC, 'Microsoft YaHei', sans-serif; font-weight: 300; white-space: pre-line;"
>
{{ t('home.title') }}
</h1>
<p class="mt-1.5 text-sm text-slate-500">{{ t('home.subtitle') }}</p>
</div>
<div class="mt-5 grid items-stretch gap-4 md:grid-cols-2 xl:grid-cols-4">
<section
class="home-card-base home-entry-item home-entry-item--1 relative overflow-hidden rounded-2xl bg-[#dc2626] p-7 text-white shadow-[0_24px_60px_rgba(153,27,27,0.35)]"
>
<div class="pointer-events-none absolute -right-20 -top-16 h-56 w-56 rounded-full bg-white/12 blur-2xl" />
<div class="pointer-events-none absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-white/8 blur-3xl" />
<div v-for="index in 10" :key="index" :class="`home-hero-meteor home-hero-meteor--${index}`" />
<div class="relative inline-flex h-11 w-11 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/35">
<BarChart3 class="h-5 w-5" />
</div>
<h2 class="relative mt-8 whitespace-pre-line text-xl font-semibold leading-tight tracking-tight">
{{ t('home.cards.heroTitle') }}
</h2>
<p class="relative mt-2 text-sm text-red-200/90">{{ t('home.cards.heroSubTitle') }}</p>
<div class="relative mt-6 h-px bg-white/20" />
<p class="relative mt-4 whitespace-pre-line text-xs leading-5 text-red-200/80">{{ t('home.cards.heroDesc') }}</p>
</section>
<article
role="button"
tabindex="0"
class="home-card-base home-entry-item home-entry-item--2 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
@click="openProjectCalc"
@keydown.enter.prevent="openProjectCalc"
@keydown.space.prevent="openProjectCalc"
>
<div>
<div class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-blue-100 bg-blue-50/80 text-blue-600 shadow-sm transition-transform duration-200 group-hover:scale-105">
<BarChart3 class="h-5 w-5" />
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.projectBudget') }}</h3>
<p class="mt-1.5 text-xs leading-5 text-slate-500">{{ t('home.cards.projectBudgetDesc') }}</p>
</div>
<div class="mt-4 flex flex-wrap items-center justify-end gap-2">
<button
v-if="hasExistingProjects"
type="button"
class="cursor-pointer rounded-md border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700"
@click.stop="openExistingProjectDialog"
>
{{ t('home.cards.pickExisting') }}
</button>
<button
type="button"
class="cursor-pointer rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-blue-700"
@click.stop="openProjectCalc"
>
<span class="flex items-center gap-1">
{{ t('home.cards.enter') }}
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</span>
</button>
</div>
</article>
<article
role="button"
tabindex="0"
class="home-card-base home-entry-item home-entry-item--3 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
@click="openQuickCalc"
@keydown.enter.prevent="openQuickCalc"
@keydown.space.prevent="openQuickCalc"
>
<div>
<div class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-amber-100 bg-amber-50/80 text-amber-600 shadow-sm transition-transform duration-200 group-hover:scale-105">
<Calculator class="h-5 w-5" />
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.quickCalc') }}</h3>
<p class="mt-1.5 text-xs leading-5 text-slate-500">{{ t('home.cards.quickCalcDesc') }}</p>
</div>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>{{ t('home.cards.developing') }}</span>
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</div>
</article>
<article
role="button"
tabindex="0"
class="home-card-base home-entry-item home-entry-item--4 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
@click="openRelatedFiles"
@keydown.enter.prevent="openRelatedFiles"
@keydown.space.prevent="openRelatedFiles"
>
<div>
<div class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-emerald-100 bg-emerald-50/80 shadow-sm transition-transform duration-200 group-hover:scale-105">
<svg
viewBox="0 0 1024 1024"
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M271.146667 128h-85.333334a64 64 0 0 0-64 64v640a64 64 0 0 0 64 64h85.333334a64 64 0 0 0 64-64V192a64 64 0 0 0-64-64z m21.333333 704a21.333333 21.333333 0 0 1-21.333333 21.333333h-85.333334a21.333333 21.333333 0 0 1-21.333333-21.333333V320h128z m0-554.666667h-128V192a21.333333 21.333333 0 0 1 21.333333-21.333333h85.333334a21.333333 21.333333 0 0 1 21.333333 21.333333zM901.12 808.533333L789.333333 178.346667a64 64 0 0 0-74.026666-51.84l-84.053334 14.72a64 64 0 0 0-44.16 31.146666A64 64 0 0 0 527.146667 128h-85.333334a64 64 0 0 0-64 64v640a64 64 0 0 0 64 64h85.333334a64 64 0 0 0 64-64V279.253333l99.84 566.4a64 64 0 0 0 74.24 51.84l84.053333-14.72a64 64 0 0 0 51.84-74.24zM548.48 832a21.333333 21.333333 0 0 1-21.333333 21.333333h-85.333334a21.333333 21.333333 0 0 1-21.333333-21.333333V320h128z m0-554.666667h-128V192a21.333333 21.333333 0 0 1 21.333333-21.333333h85.333334a21.333333 21.333333 0 0 1 21.333333 21.333333z m73.386667-69.333333A21.333333 21.333333 0 0 1 640 183.253333l83.2-14.72a21.333333 21.333333 0 0 1 24.746667 17.28l14.933333 84.053334-126.08 21.333333z m219.946666 632.746667l-84.053333 14.72a21.333333 21.333333 0 0 1-24.746667-17.28L644.266667 334.08l125.866666-21.333333 88.96 504.106666a21.333333 21.333333 0 0 1-17.28 23.893334z"
fill="#e0db84"
/>
</svg>
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.relatedFiles') }}</h3>
<p class="mt-1.5 text-xs leading-5 text-slate-500">{{ t('home.cards.relatedFilesDesc') }}</p>
</div>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>{{ t('home.cards.openRelatedFiles') }}</span>
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</div>
</article>
</div>
</div>
</div>
<div
v-if="existingProjectDialogOpen"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
@click.self="closeExistingProjectDialog"
>
<div class="w-full max-w-lg rounded-3xl border border-slate-200/60 bg-white shadow-2xl">
<div class="flex items-start justify-between border-b border-slate-100 px-6 pt-6 pb-4">
<div>
<h3 class="text-xl font-bold text-[#1a1a1a]">{{ t('home.dialog.chooseExistingProject') }}</h3>
<p class="mt-1.5 text-base text-[#666]">{{ t('home.dialog.chooseExistingProjectDesc') }}</p>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
@click="closeExistingProjectDialog"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-80 space-y-3 overflow-auto px-6 py-5">
<div
v-if="!existingProjectLoading && existingProjects.length === 0"
class="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-5 text-center text-sm text-slate-500"
>
{{ t('home.dialog.noProjectYet') }}
</div>
<button
v-for="project in existingProjects"
:key="project.id"
type="button"
:disabled="isExistingProjectOpened(project.id)"
class="flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3.5 text-left transition hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
@click="enterExistingProject(project.id)"
>
<div class="min-w-0 flex-1">
<div class="text-base font-medium text-[#1a1a1a]">
{{ project.name }}
</div>
<div class="mt-1 text-sm text-[#888]">{{ project.id }}</div>
</div>
<div class="shrink-0 pl-4 text-sm text-[#888]">
{{ t('tab.toolbar.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}
</div>
</button>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-100 px-6 pt-4 pb-6">
<button
type="button"
class="cursor-pointer rounded-lg border border-slate-200 bg-white px-5 py-2.5 text-base font-medium text-[#666] transition hover:border-slate-300 hover:bg-slate-50"
@click="closeExistingProjectDialog"
>
{{ t('common.cancel') }}
</button>
</div>
</div>
</div>
<div
v-if="projectDialogOpen"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
@click.self="closeProjectCalcDialog"
>
<div class="w-full max-w-lg rounded-3xl border border-slate-200/60 bg-white shadow-2xl">
<div class="flex items-start justify-between border-b border-slate-100 px-6 pt-6 pb-4">
<div>
<h3 class="text-xl font-bold text-[#1a1a1a]">{{ t('home.dialog.newProject') }}</h3>
<p class="mt-1.5 text-base text-[#666]">选择工程行业后,进入项目计算页面</p>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
@click="closeProjectCalcDialog"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="px-6 pt-5 pb-4">
<h4 class="mb-3 text-base font-semibold text-[#1a1a1a]">{{ t('home.dialog.industry') }}</h4>
<div class="space-y-3">
<button
v-for="item in industryTypeList"
:key="`project-${item.id}`"
type="button"
:class="[
'flex w-full items-center justify-between rounded-xl border px-4 py-3.5 text-left transition',
projectIndustry === String(item.id)
? 'border-[#1a1a1a] bg-white text-[#1a1a1a] shadow-sm'
: 'border-slate-200 bg-white text-[#1a1a1a] hover:border-slate-300'
]"
@click="projectIndustry = String(item.id)"
>
<span class="text-base">{{ getIndustryDisplayName(item.id, locale) }}</span>
<div
:class="[
'flex h-5 w-5 items-center justify-center rounded-md border transition',
projectIndustry === String(item.id)
? 'border-[#1a1a1a] bg-[#1a1a1a] text-white'
: 'border-slate-300 bg-white'
]"
>
<Check v-if="projectIndustry === String(item.id)" class="h-3.5 w-3.5" />
</div>
</button>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-100 px-6 pt-4 pb-6">
<button
type="button"
class="cursor-pointer rounded-lg border border-slate-200 bg-white px-5 py-2.5 text-base font-medium text-[#666] transition hover:border-slate-300 hover:bg-slate-50"
@click="closeProjectCalcDialog; openExistingProjectDialog()"
>
{{ t('home.cards.pickExisting') }}
</button>
<button
type="button"
:disabled="projectSubmitting || !projectIndustry"
class="cursor-pointer rounded-lg bg-[#1a1a1a] px-5 py-2.5 text-base font-medium text-white transition hover:bg-[#2a2a2a] disabled:cursor-not-allowed disabled:opacity-50"
@click="confirmProjectCalc"
>
{{ projectSubmitting ? t('home.dialog.entering') : t('home.dialog.enterProjectCalc') }}
</button>
</div>
</div>
</div>
<div
v-if="homeImportConfirmOpen"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
@click.self="cancelHomeImportConfirm"
>
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
<div class=" px-5 py-4">
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.confirmImport') }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ t('home.dialog.confirmImportDesc', { file: pendingHomeImportFileName || t('home.cards.pickFile') }) }}
</p>
</div>
<div class="flex items-center justify-end gap-2 px-5 py-4">
<Button variant="outline" @click="cancelHomeImportConfirm">{{ t('common.cancel') }}</Button>
<Button variant="destructive" @click="confirmHomeImport">{{ t('home.dialog.confirmImportAction') }}</Button>
</div>
</div>
</div>
</template>
<style scoped>
.home-hero {
animation: none;
}
.home-card-base {
min-height: 248px;
}
.home-title {
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
max-width: 520px;
margin: 0 auto;
padding: 18px 24px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 20px;
background-image: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(59, 130, 246, 0.06), transparent 70%);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
}
.home-entry-item {
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.home-entry-item--1 { animation-delay: 0.2s; }
.home-entry-item--2 { animation-delay: 0.3s; }
.home-entry-item--3 { animation-delay: 0.4s; }
.home-entry-item--4 { animation-delay: 0.5s; }
.home-entry-item--5 { animation-delay: 0.6s; }
@keyframes hero-in {
from { opacity: 0; transform: translateX(-20px) scale(0.97); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
.home-hero-meteor {
pointer-events: none;
position: absolute;
width: 120px;
height: 1px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0));
transform: rotate(-28deg);
opacity: 0;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
animation: hero-meteor 3.8s linear infinite;
}
.home-hero-meteor--1 {
top: 16%;
right: -30%;
animation-delay: 0s;
}
.home-hero-meteor--2 {
top: 38%;
right: -40%;
animation-delay: 1.2s;
}
.home-hero-meteor--3 {
top: 62%;
right: -35%;
animation-delay: 2.2s;
}
.home-hero-meteor--4 {
top: 24%;
right: -45%;
animation-delay: 0.6s;
}
.home-hero-meteor--5 {
top: 50%;
right: -28%;
animation-delay: 1.7s;
}
.home-hero-meteor--6 {
top: 74%;
right: -42%;
animation-delay: 2.8s;
}
.home-hero-meteor--7 {
top: 10%;
right: -48%;
animation-delay: 0.35s;
}
.home-hero-meteor--8 {
top: 31%;
right: -26%;
animation-delay: 1.05s;
}
.home-hero-meteor--9 {
top: 56%;
right: -50%;
animation-delay: 2.45s;
}
.home-hero-meteor--10 {
top: 82%;
right: -30%;
animation-delay: 3.15s;
}
@keyframes hero-meteor {
0% { transform: translate3d(0, 0, 0) rotate(-28deg); opacity: 0; }
8% { opacity: 0.9; }
34% { opacity: 0.9; }
42% { opacity: 0; }
100% { transform: translate3d(-340px, 220px, 0) rotate(-28deg); opacity: 0; }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
@@ -1,136 +0,0 @@
<template>
<TypeLine
scene="ht-fee-method-type-line"
:title="titleText"
:subtitle="t('htFeeMethodTypeLine.contractId', { id: contractIdText })"
:copy-text="contractIdText"
:storage-key="activeTypeStorageKey"
default-category="rate-fee"
:categories="categories"
/>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue'
import HtFeeGrid from '@/features/shared/components/HtFeeGrid.vue'
import HtFeeRateMethodForm from '@/features/ht/components/HtFeeRateMethodForm.vue'
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
interface TypeLineCategoryItem {
key: string
label: string
component: Component
}
const props = defineProps<{
sourceTitle?: string
storageKey: string
rowId: string | number
rowName?: string
contractId?: string
contractName?: string
}>()
const { t } = useI18n()
const sourceTitleText = computed(() => props.sourceTitle || t('htFeeMethodTypeLine.feeDetail'))
const rowNameText = computed(() => props.rowName || t('htFeeMethodTypeLine.unnamed'))
const rowIdText = computed(() => String(props.rowId || '').trim())
const contractIdText = computed(() => String(props.contractId || '').trim())
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
const titleText = computed(() => t('htFeeMethodTypeLine.title', {
contractName: contractNameText.value,
rowName: rowNameText.value || sourceTitleText.value
}))
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${rowIdText.value}`)
const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
`${props.storageKey}-${rowIdText.value}-${method}`
const quantityUnitPricePane = markRaw(
defineComponent({
name: 'HtFeeGrid',
setup() {
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
return () =>
h(HtFeeGrid, {
title: t('htFeeMethodTypeLine.quantityUnitPrice'),
storageKey: quantityStorageKey.value,
htMainStorageKey: props.storageKey,
htRowId: rowIdText.value,
htMethodType: 'quantity-unit-price-fee'
})
}
})
)
const rateFeePane = markRaw(
defineComponent({
name: 'HtFeeMethodTypePane-rate-fee',
setup() {
const rateStorageKey = computed(() => buildMethodStorageKey('rate-fee'))
return () =>
h(HtFeeRateMethodForm, {
storageKey: rateStorageKey.value,
contractId: props.contractId,
htMainStorageKey: props.storageKey,
htRowId: rowIdText.value,
htMethodType: 'rate-fee'
})
}
})
)
const hourlyFeePane = markRaw(
defineComponent({
name: 'HtFeeMethodTypePane-hourly-fee',
setup() {
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
return () =>
h(HourlyFeeGrid, {
title: t('hourlyFeeGrid.title'),
storageKey: hourlyStorageKey.value,
htMainStorageKey: props.storageKey,
htRowId: rowIdText.value,
htMethodType: 'hourly-fee'
})
}
})
)
const isReserveFee = computed(() => props.sourceTitle === t('htSummary.reservePrefix'))
const showWorkContent = computed(() => {
if (isReserveFee.value) return false
return true
})
const workContentPane = markRaw(
defineComponent({
name: 'WorkContentPane',
setup() {
const AsyncWorkContentGrid = defineAsyncComponent({
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
onError: err => {
console.error('加载 WorkContentGrid 组件失败:', err)
}
})
return () => h(AsyncWorkContentGrid, {
title: t('workContent.title'),
storageKey: `work-content-${props.storageKey}-${rowIdText.value}`,
dictMode: 'additional'
})
}
})
)
const categories = computed<TypeLineCategoryItem[]>(() => {
const base: TypeLineCategoryItem[] = [
{ key: 'rate-fee', label: t('htFeeGrid.columns.rateFee'), component: rateFeePane },
{ key: 'hourly-fee', label: t('htFeeGrid.columns.hourlyFee'), component: hourlyFeePane },
{ key: 'quantity-unit-price-fee', label: t('htFeeGrid.columns.quantityUnitPriceFee'), component: quantityUnitPricePane },
]
if (showWorkContent.value) {
base.push({ key: 'work-content', label: t('workContent.title'), component: workContentPane })
}
return base
})
</script>
File diff suppressed because it is too large. Load diff
@@ -1,211 +0,0 @@
<template>
<TypeLine
scene="zxfw-pricing-tab"
:title="`${contractName ? `${t('zxFwView.contractPrefix', { name: contractName })} · ` : ''}${fwName}${t('zxFwView.calcSuffix')}`"
:subtitle="t('zxFwView.contractId', { id: contractId })"
:copy-text="contractId"
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
:default-category="defaultCategory"
:categories="pricingCategories"
/>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
import ScaleFormulaReadonlyPane from '@/features/pricing/components/ScaleFormulaReadonlyPane.vue'
interface ServiceMethodType {
scale?: boolean | null
onlyCostScale?: boolean | null
amount?: boolean | null
workDay?: boolean | null
}
const props = defineProps<{
contractId: string
contractName?: string
serviceId: string|number
fwName:string
type?: ServiceMethodType
projectInfoKey?: string
}>()
const { t } = useI18n()
interface PricingCategoryItem {
key: string
label: string
component: Component
}
const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) =>
typeof value === 'boolean' ? value : fallback
const methodAvailability = computed(() => {
const scale = resolveMethodEnabled(props.type?.scale, true)
const onlyCostScale = resolveMethodEnabled(props.type?.onlyCostScale, false)
const amount = resolveMethodEnabled(props.type?.amount, true)
const workDay = resolveMethodEnabled(props.type?.workDay, true)
return {
investmentScale: scale,
landScale: scale && !onlyCostScale,
workload: amount,
hourly: workDay
}
})
const createPricingPane = (name: string) =>
markRaw(
defineComponent({
name,
setup() {
const AsyncPricingView = defineAsyncComponent({
loader: () => import(`@/features/pricing/components/${name}.vue`),
onError: err => {
console.error('load PricingMethodView failed:', err)
}
})
return () => h(AsyncPricingView, {
contractId: props.contractId,
contractName: props.contractName,
serviceId: props.serviceId,
projectInfoKey: props.projectInfoKey
})
}
})
)
const createMethodUnavailablePane = (title: string, message: string) =>
markRaw(
defineComponent({
name: 'MethodUnavailablePane',
setup() {
return () => h(MethodUnavailableNotice, { title, message })
}
})
)
const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
const landScaleView = createPricingPane('LandScalePricingPane')
const workloadView = createPricingPane('WorkloadPricingPane')
const hourlyView = createPricingPane('HourlyPricingPane')
const otherService = createPricingPane('OtherService')
const createScaleFormulaPane = (
method: 'investScale' | 'landScale',
name: 'InvestmentScaleFormulaPane' | 'LandScaleFormulaPane'
) =>
markRaw(
defineComponent({
name,
setup() {
return () => h(ScaleFormulaReadonlyPane, {
contractId: props.contractId,
serviceId: props.serviceId,
method
})
}
})
)
const investmentScaleFormulaView = createScaleFormulaPane('investScale', 'InvestmentScaleFormulaPane')
const landScaleFormulaView = createScaleFormulaPane('landScale', 'LandScaleFormulaPane')
const workContentPane = markRaw(
defineComponent({
name: 'WorkContentPane',
setup() {
const AsyncWorkContentGrid = defineAsyncComponent({
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
onError: err => {
console.error('load WorkContentGrid failed:', err)
}
})
return () => h(AsyncWorkContentGrid, {
title: t('zxFwView.workContentTitle'),
storageKey: `work-content-${props.contractId}-${props.serviceId}`,
contractId: props.contractId,
projectInfoKey: props.projectInfoKey,
serviceId: props.serviceId,
dictMode: 'service',
"show-no-column": true
})
}
})
)
const investmentScaleUnavailableView = createMethodUnavailablePane(
t('zxFwView.unavailable.investmentScaleTitle'),
t('zxFwView.unavailable.investmentScaleMessage')
)
const landScaleUnavailableView = createMethodUnavailablePane(
t('zxFwView.unavailable.landScaleTitle'),
t('zxFwView.unavailable.landScaleMessage')
)
const workloadUnavailableView = createMethodUnavailablePane(
t('zxFwView.unavailable.workloadTitle'),
t('zxFwView.unavailable.workloadMessage')
)
const hourlyUnavailableView = createMethodUnavailablePane(
t('zxFwView.unavailable.hourlyTitle'),
t('zxFwView.unavailable.hourlyMessage')
)
const pricingCategories = computed<PricingCategoryItem[]>(() => [
{
key: 'investment-scale-method',
label: t('zxFwView.categories.investmentScale'),
component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView
},
/*{
key: 'investment-scale-formula',
label: t('zxFwView.categories.investmentScaleFormula'),
component: methodAvailability.value.investmentScale ? investmentScaleFormulaView : investmentScaleUnavailableView
},
{
key: 'land-scale-method',
label: t('zxFwView.categories.landScale'),
component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView
},
{
key: 'land-scale-formula',
label: t('zxFwView.categories.landScaleFormula'),
component: methodAvailability.value.landScale ? landScaleFormulaView : landScaleUnavailableView
},*/
{
key: 'workload-method',
label: t('zxFwView.categories.workload'),
component: methodAvailability.value.workload ? workloadView : workloadUnavailableView
},
{
key: 'hourly-method',
label: t('zxFwView.categories.hourly'),
component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView
},
{
key: 'work-content',
label: t('zxFwView.categories.workContent'),
component: workContentPane
},
{
key: 'other-service',
label: t('zxFwView.categories.otherService'),
component: otherService
},
])
const defaultCategory = computed(() => {
const m = methodAvailability.value
if (m.investmentScale) return 'investment-scale-method'
if (m.landScale) return 'land-scale-method'
if (m.workload) return 'workload-method'
if (m.hourly) return 'hourly-method'
return 'work-content'
})
</script>
@@ -1,59 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState {
projectIndustry?: string
}
type ServiceItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('')
const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
projectIndustry.value = ''
}
}
const filteredServiceDict = computed<Record<string, ServiceItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = getServiceDictEntries()
.filter(({ item }) => isIndustryEnabledByType(item, getIndustryTypeValue(industry))
)
.map(({ id, item }) => [id, item as ServiceItem] as const)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<XmFactorGrid :title="t('htFactors.consultCategoryTitle')" storage-key="xm-consult-category-factor-v1" :dict="filteredServiceDict"
:disable-budget-edit-when-standard-null="true" :exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true" />
</template>
@@ -1,69 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState {
projectIndustry?: string
}
type MajorItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('')
const hasProjectBaseInfo = ref(false)
const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
hasProjectBaseInfo.value = Boolean(data)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
hasProjectBaseInfo.value = false
projectIndustry.value = ''
}
}
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = getMajorDictEntries()
.filter(({ id }) => isMajorIdInIndustryScope(id, industry))
.map(({ id, item }) => [id, item as MajorItem] as const)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<XmFactorGrid
:title="t('htFactors.majorTitle')"
storage-key="xm-major-factor-v1"
:dict="filteredMajorDict"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true"
/>
</template>
-441
View File
@@ -1,441 +0,0 @@
<script setup lang="ts">
import { parseDate } from '@internationalized/date'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
getIndustryDisplayName,
industryTypeList,
} from '@/sql'
import { useKvStore } from '@/pinia/kv'
import { upsertProject } from '@/lib/projectRegistry'
import { readCurrentProjectId } from '@/lib/workspace'
import { waitForHydration } from '@/pinia/Plugin/indexdb'
import { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import {
DatePickerAnchor,
DatePickerArrow,
DatePickerCalendar,
DatePickerCell,
DatePickerCellTrigger,
DatePickerClose,
DatePickerContent,
DatePickerField,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridHead,
DatePickerGridRow,
DatePickerHeadCell,
DatePickerHeader,
DatePickerHeading,
DatePickerInput,
DatePickerNext,
DatePickerPrev,
DatePickerRoot,
DatePickerTrigger
} from 'reka-ui'
interface XmInfoState {
projectIndustry?: string
projectName?: string
preparedBy?: string
reviewedBy?: string
preparedCompany?: string
preparedDate?: string
overview?: string
desc?: string
}
type MajorParentNode = { id: string; name: string }
const DB_KEY = 'xm-base-info-v1'
const { t, locale } = useI18n()
const DEFAULT_PROJECT_NAME = t('xmInfo.defaultProjectName')
const DEFAULT_DESC = t('xmInfo.defaultDesc')
const INDUSTRY_HINT_TEXT = computed(() => t('xmInfo.industryHint'))
const getTodayDateString = () => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const isProjectInitialized = ref(false)
const isBootstrapping = ref(true)
const projectName = ref('')
const projectIndustry = ref('')
const preparedBy = ref('')
const reviewedBy = ref('')
const preparedCompany = ref('')
const preparedDate = ref(getTodayDateString())
const preparedDatePickerValue = ref<any>(undefined)
const overview = ref('')
const desc = ref(DEFAULT_DESC)
const normalizeDateString = (value: unknown): string => {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
try {
const parsed = parseDate(trimmed)
return parsed.toString() === trimmed ? trimmed : ''
} catch {
return ''
}
}
const syncPreparedDatePickerFromString = () => {
if (!preparedDate.value) {
preparedDatePickerValue.value = undefined
return
}
try {
preparedDatePickerValue.value = parseDate(preparedDate.value)
} catch {
preparedDatePickerValue.value = undefined
preparedDate.value = ''
}
}
const handlePreparedDateSelect = (date: any) => {
preparedDatePickerValue.value = date
preparedDate.value = date?.toString() ?? ''
}
const majorParentNodes = computed<MajorParentNode[]>(() =>
industryTypeList.map(item => ({
id: item.id,
name: getIndustryDisplayName(item.id, locale.value) || String(item.name || '')
}))
)
const majorParentCodeSet = new Set<string>(industryTypeList.map(item => String(item.id)))
const DEFAULT_PROJECT_INDUSTRY = String(industryTypeList[0]?.id || '')
const kvStore = useKvStore()
const saveToIndexedDB = async () => {
try {
const normalizedProjectName = projectName.value.trim() || DEFAULT_PROJECT_NAME
projectName.value = normalizedProjectName
const payload: XmInfoState = {
projectIndustry: projectIndustry.value,
projectName: normalizedProjectName,
preparedBy: preparedBy.value,
reviewedBy: reviewedBy.value,
preparedCompany: preparedCompany.value,
preparedDate: preparedDate.value,
overview: overview.value,
desc: desc.value
}
await kvStore.setItem(DB_KEY, payload)
upsertProject(readCurrentProjectId(), normalizedProjectName)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await kvStore.getItem<XmInfoState>(DB_KEY)
if (data) {
isProjectInitialized.value = true
projectIndustry.value =
typeof data.projectIndustry === 'string' && majorParentCodeSet.has(data.projectIndustry)
? data.projectIndustry
: DEFAULT_PROJECT_INDUSTRY
projectName.value =
typeof data.projectName === 'string' && data.projectName.trim() ? data.projectName : DEFAULT_PROJECT_NAME
preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : ''
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
preparedDate.value = normalizeDateString(data.preparedDate) || getTodayDateString()
overview.value = typeof data.overview === 'string' ? data.overview : ''
desc.value = typeof data.desc === 'string' && data.desc.trim() ? data.desc : DEFAULT_DESC
syncPreparedDatePickerFromString()
return
}
isProjectInitialized.value = false
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = getTodayDateString()
overview.value = ''
desc.value = DEFAULT_DESC
syncPreparedDatePickerFromString()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
isProjectInitialized.value = false
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = getTodayDateString()
overview.value = ''
desc.value = DEFAULT_DESC
syncPreparedDatePickerFromString()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (!isProjectInitialized.value) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
const handleProjectNameBlur = () => {
if (!projectName.value.trim()) {
projectName.value = DEFAULT_PROJECT_NAME
}
}
watch(
[projectIndustry, projectName, preparedBy, reviewedBy, preparedCompany, preparedDate, overview, desc],
schedulePersist
)
onMounted(async () => {
try {
await waitForHydration('kv')
await loadFromIndexedDB()
} finally {
isBootstrapping.value = false
}
})
</script>
<template>
<TooltipProvider>
<div class="space-y-6 h-full">
<div
v-if="isBootstrapping"
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
>
{{ t('common.loading') }}
</div>
<div
v-else-if="!isProjectInitialized"
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
>
{{ t('xmInfo.createFromHomeFirst') }}
</div>
<div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectName') }}</label>
<input
v-model="projectName"
type="text"
required
:placeholder="t('xmInfo.defaultProjectName')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
@blur="handleProjectNameBlur"
/>
</div>
<div class="md:col-span-2 xl:col-span-4">
<div class="flex items-center gap-1.5">
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectIndustry') }}</label>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
:aria-label="t('xmInfo.industryHintAria')"
class="inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground"
>
<CircleHelp class="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ INDUSTRY_HINT_TEXT }}</TooltipContent>
</TooltipRoot>
</div>
<div class="mt-2 flex flex-wrap gap-3 rounded-lg border bg-background px-3 py-2">
<label
v-for="item in majorParentNodes"
:key="item.id"
class="inline-flex items-center gap-2 text-sm text-foreground/80"
>
<input
v-model="projectIndustry"
type="radio"
:value="item.id"
disabled
class="h-4 w-4 cursor-not-allowed accent-primary"
/>
<span>{{ item.name }}</span>
</label>
</div>
</div>
<div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.overview') }}</label>
<textarea
v-model="overview"
rows="3"
:placeholder="t('xmInfo.placeholders.overview')"
class="mt-2 w-full rounded-lg border bg-background px-4 py-2 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedBy') }}</label>
<input
v-model="preparedBy"
type="text"
:placeholder="t('xmInfo.placeholders.preparedBy')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.reviewedBy') }}</label>
<input
v-model="reviewedBy"
type="text"
:placeholder="t('xmInfo.placeholders.reviewedBy')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedCompany') }}</label>
<input
v-model="preparedCompany"
type="text"
:placeholder="t('xmInfo.placeholders.preparedCompany')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedDate') }}</label>
<DatePickerRoot
locale="en-CA"
:model-value="preparedDatePickerValue"
@update:model-value="handlePreparedDateSelect"
>
<DatePickerAnchor class="mt-2 block w-full">
<DatePickerField
v-slot="{ segments }"
class="flex h-10 w-full items-center justify-between rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-ring"
>
<div class="flex items-center">
<template
v-for="(segment, index) in segments"
:key="`${segment.part}-${index}`"
>
<DatePickerInput
:part="segment.part"
:class="segment.part === 'literal' ? 'text-muted-foreground/70' : 'text-foreground'"
>
{{ segment.part === 'literal' ? '-' : segment.value }}
</DatePickerInput>
</template>
</div>
<DatePickerTrigger as-child>
<button type="button" class="cursor-pointer inline-flex h-6 w-6 items-center justify-center text-muted-foreground">
<CalendarIcon class="h-4 w-4" />
</button>
</DatePickerTrigger>
</DatePickerField>
</DatePickerAnchor>
<DatePickerContent class="z-50 w-[22rem] rounded-lg border bg-card p-4 shadow-lg">
<DatePickerArrow class="fill-border" />
<DatePickerCalendar v-slot="{ weekDays, grid }" locale="zh-CN">
<DatePickerHeader class="mb-2 flex items-center justify-between">
<DatePickerPrev as-child>
<button
type="button"
class=" cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
>
‹
</button>
</DatePickerPrev>
<DatePickerHeading class="text-base font-medium text-foreground" />
<DatePickerNext as-child>
<button
type="button"
class="cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
>
›
</button>
</DatePickerNext>
</DatePickerHeader>
<div class="space-y-2">
<DatePickerGrid
v-for="month in grid"
:key="month.value.toString()"
class="w-full border-collapse select-none"
>
<DatePickerGridHead>
<DatePickerGridRow class="grid grid-cols-7">
<DatePickerHeadCell
v-for="day in weekDays"
:key="day"
class="flex h-9 items-center justify-center text-sm text-muted-foreground"
>
{{ day }}
</DatePickerHeadCell>
</DatePickerGridRow>
</DatePickerGridHead>
<DatePickerGridBody>
<DatePickerGridRow
v-for="(weekDates, index) in month.rows"
:key="`${month.value.toString()}-${index}`"
class="grid grid-cols-7"
>
<DatePickerCell
v-for="dateValue in weekDates"
:key="dateValue.toString()"
:date="dateValue"
class="h-10 w-full p-0.5"
>
<DatePickerCellTrigger
:day="dateValue"
:month="month.value"
class="cursor-pointer h-full w-full rounded-md border border-transparent bg-transparent text-base outline-none transition hover:bg-muted data-[outside-view]:text-muted-foreground/40 data-[selected]:border-primary data-[selected]:bg-transparent data-[selected]:text-foreground data-[disabled]:opacity-40 data-[unavailable]:text-muted-foreground/40"
>
{{ dateValue.day }}
</DatePickerCellTrigger>
</DatePickerCell>
</DatePickerGridRow>
</DatePickerGridBody>
</DatePickerGrid>
</div>
</DatePickerCalendar>
<div class="mt-2 flex justify-end">
<DatePickerClose as-child>
<button
type="button"
class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2"
>
{{ t('common.confirm') }}
</button>
</DatePickerClose>
</div>
</DatePickerContent>
</DatePickerRoot>
</div>
<div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.desc') }}</label>
<textarea
v-model="desc"
rows="4"
class="mt-2 w-full rounded-lg border bg-background px-4 py-2 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
</div>
</div>
</div>
</TooltipProvider>
</template>
-33
View File
@@ -1,33 +0,0 @@
<template>
<TypeLine
scene="xm-tab"
title=""
storage-key="project-active-cat"
default-category="info"
:categories="xmCategories"
/>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue'
const { t } = useI18n()
const infoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/info.vue')))
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/features/ht/components/Ht.vue')))
const consultCategoryFactorView = markRaw(
defineAsyncComponent(() => import('@/features/xm/components/XmConsultCategoryFactor.vue'))
)
const majorFactorView = markRaw(
defineAsyncComponent(() => import('@/features/xm/components/XmMajorFactor.vue'))
)
const xmCategories = computed(() => [
{ key: 'info', label: t('xmCard.categories.info'), component: infoView },
{ key: 'scale-info', label: t('xmCard.categories.scaleInfo'), component: scaleInfoView },
// { key: 'consult-category-factor', label: t('xmCard.categories.consultCategoryFactor'), component: consultCategoryFactorView },
// { key: 'major-factor', label: t('xmCard.categories.majorFactor'), component: majorFactorView },
{ key: 'contract', label: t('xmCard.categories.contract'), component: htView }
])
</script>
-13
View File
@@ -1,13 +0,0 @@
<script setup lang="ts">
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
const DB_KEY = 'xm-info-v3'
</script>
<template>
<CommonAgGrid title="项目明细" :dbKey="DB_KEY" />
</template>
-130
View File
@@ -1,130 +0,0 @@
export const MAJOR_NAME_EN_BY_CODE: Record<string, string> = {
E1: 'General Transportation Engineering',
'E1-1': 'Land (Sea) Acquisition Compensation',
'E1-2': 'Relocation Compensation',
'E1-3': 'Relocation/Utility Diversion Works',
'E1-4': 'Other Construction Expenses',
'E1-5': 'Contingency',
'E1-6': 'Construction Loan Interest',
E2: 'Highway Engineering',
'E2-1': 'Temporary Works',
'E2-2': 'Subgrade Works',
'E2-3': 'Pavement Works',
'E2-4': 'Bridge and Culvert Works',
'E2-5': 'Tunnel Works',
'E2-6': 'Interchange Works',
'E2-7': 'MEP Works',
'E2-8': 'Traffic Safety Facilities',
'E2-9': 'Landscaping and Environmental Works',
'E2-10': 'Building Works',
E3: 'Railway Engineering',
'E3-1': 'Large Temporary Facilities and Transitional Works',
'E3-2': 'Subgrade Works',
'E3-3': 'Bridge and Culvert Works',
'E3-4': 'Tunnel and Cut-and-Cover Works',
'E3-5': 'Track Works',
'E3-6': 'Communication, Signaling, Information and Disaster Monitoring',
'E3-7': 'Power and Traction Power Supply Works',
'E3-8': 'Building Works (Buildings and Ancillary Works)',
'E3-9': 'Interior Decoration Works',
E4: 'Waterway Engineering',
'E4-1': 'Temporary Works',
'E4-2': 'Civil Works',
'E4-3': 'Mechanical, Electrical and Steel Structure Works',
'E4-4': 'Equipment Works',
'E4-5': 'Ancillary Building Works (Buildings and Ancillary Works)'
}
export const SERVICE_NAME_EN_BY_CODE: Record<string, string> = {
D1: 'Whole-Process Cost Consulting',
D2: 'Stage-Based Cost Consulting',
'D2-1': 'Early-Stage Cost Consulting',
'D2-2-1': 'Implementation-Stage Cost Consulting (Highway/Waterway)',
'D2-2-2': 'Implementation-Stage Cost Consulting (Railway)',
D3: 'Basic Cost Consulting',
'D3-1': 'Investment Estimate',
'D3-2': 'Design Estimate',
'D3-3': 'Construction Drawing Budget',
'D3-4': 'BOQ and BOQ Budget (or Max Bid Price)',
'D3-5': 'Estimate Review/Reconciliation (Railway Only)',
'D3-6-1': 'Contract (Project) Settlement',
'D3-6-2': 'Contract (Project) Settlement',
'D3-7': 'Final Account',
D4: 'Specialized Cost Consulting',
'D4-1': 'Cost Advisory Service',
'D4-2': 'Cost Policy Formulation/Revision',
'D4-3': 'Cost Science and Technology Research',
'D4-4': 'Quota Determination',
'D4-5': 'Cost Information Consulting',
'D4-6': 'Cost Appraisal',
'D4-7': 'Cost Estimation',
'D4-8': 'Cost Accounting',
'D4-9': 'Quantity Takeoff',
'D4-10': 'Variation Cost Consulting',
'D4-11': 'Adjusted Estimate',
'D4-12': 'Adjusted Budget Estimate',
'D4-13': 'Cost Inspection',
'D4-14': 'Other Specialized Consulting',
'D4-15-1': 'Cost Data Validation (Estimate)',
'D4-15-2': 'Cost Data Validation (Budget Estimate)',
'D4-15-3': 'Cost Data Validation (Construction Drawing Budget)',
'D4-15-4': 'Cost Data Validation (BOQ and BOQ Budget)',
'D4-15-5': 'Cost Data Validation (Estimate Review, Railway Only)',
'D4-15-6': 'Cost Data Validation (Contract Settlement)',
'D4-15-7': 'Cost Data Validation (Contract Settlement)',
'D4-15-8': 'Cost Data Validation (Final Account)'
}
export const TASK_NAME_EN_BY_CODE: Record<string, string> = {
'C4-1': 'Daily Cost Advisory',
'C4-2': 'Special Cost Advisory',
'C5-1': 'Organization and Research',
'C5-2-1': 'Document Drafting',
'C5-2-2': 'Document Drafting',
'C5-3-1': 'Review',
'C5-3-2': 'Review',
'C5-3-3': 'Review',
'C6-1': 'Organization and Research',
'C6-2-1': 'Research and Report Writing',
'C6-2-2': 'Research and Report Writing',
'C6-2-3': 'Research and Report Writing',
'C6-3-1': 'Standards/Guideline Drafting',
'C6-3-2': 'Standards/Guideline Drafting',
'C6-3-3': 'Standards/Guideline Drafting',
'C6-3-4': 'Standards/Guideline Drafting',
'C6-4-1': 'Review and Acceptance',
'C6-4-2': 'Review and Acceptance',
'C6-4-3': 'Review and Acceptance',
'C6-5-1': 'Training and Communication',
'C6-5-2': 'Training and Communication',
'C7-1': 'Organization and Research',
'C7-2': 'Outline Preparation',
'C7-3': 'Data Collection and Measurement',
'C7-4-1': 'Data Processing and Analysis',
'C7-4-2': 'Data Processing and Analysis',
'C7-5': 'Quota Determination Report',
'C7-6-1': 'Quota Text and Notes Drafting',
'C7-6-2': 'Quota Text and Notes Drafting',
'C7-7-1': 'Review and Acceptance',
'C7-7-2': 'Review and Acceptance',
'C7-7-3': 'Review and Acceptance',
'C7-8-1': 'Training and Communication',
'C7-8-2': 'Training and Communication',
'C8-1': 'Q ≤ 10',
'C8-2': '10 < Q ≤ 30',
'C8-3': '30 < Q ≤ 50',
'C8-4': '50 < Q ≤ 100',
'C8-5': 'Q > 100'
}
export const EXPERT_NAME_EN_BY_CODE: Record<string, string> = {
'C9-1-1': 'Technician and Others',
'C9-1-2': 'Assistant Engineer',
'C9-1-3': 'Intermediate Engineer or Level-2 Cost Engineer',
'C9-1-4': 'Senior Engineer or Level-1 Cost Engineer',
'C9-1-5': 'Professor-Level Senior Engineer or Senior Expert',
'C9-2-1': 'Level-2 Cost Engineer + Intermediate Engineer',
'C9-3-1': 'Level-1 Cost Engineer + Intermediate Engineer',
'C9-3-2': 'Level-1 Cost Engineer + Senior Engineer'
}
-36
View File
@@ -1,36 +0,0 @@
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)
}
}
-687
View File
@@ -1,687 +0,0 @@
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: 'Engineering 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',
developing: 'In Development',
pickFile: 'Choose File',
pickExisting: 'Choose Existing',
relatedFiles: 'Related Files',
relatedFilesDesc: 'View related fee documents, tender files, contract files, service content, and work requirements',
openRelatedFiles: 'Open Page'
},
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',
saveSuccess: 'Save Success',
saveFailed: 'Save 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: 'Optional, enter manually, numeric, 4 decimals',
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: 'Name',
technician: 'Technician',
assistantEngineer: 'Assistant Engineer',
midEngineer: 'Intermediate Engineer (or Level 2 Cost Engineer)',
seniorEngineer: 'Senior Engineer (or Level 1 Cost Engineer)',
profSeniorEngineer: 'Professor-level Senior Engineer',
unitPrice: 'Unit Price (CNY/workday)',
workdayCount: 'Workday Count',
subtotal: 'Subtotal (CNY)',
avgUnitPrice: 'Average Unit Price (CNY/workday)',
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
-738
View File
@@ -1,738 +0,0 @@
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: '粤价函〔2008〕929号\n《广东省交通工程造价技术中介服务收费项目及标准表》',
subtitle: '项目计算 · 单项速算 · 导入数据',
projectCalcTab: '项目计算',
quickCalcTab: '快速计算',
cards: {
heroTitle: '智能预算一键生成',
heroSubTitle: '助力《规范》高效落地',
heroDesc: '交通建设项目工程造价咨询服务费计算',
projectBudget: '项目预算',
projectBudgetDesc: '适用于多合同段、项目级整体计算,支持导出/导入完整项目数据',
quickCalc: '单项速算',
quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果',
importData: '导入数据',
importDataDesc: '导入".zw"数据包,并自动新建一个项目用于恢复数据,不会覆盖现有项目',
enter: '进入计算',
developing: '正在开发',
pickFile: '选择文件',
pickExisting: '选择已有项目',
relatedFiles: '相关文件',
relatedFilesDesc: '可查看相关收费文件、招标文件、合同文件、服务内容、工作要求',
openRelatedFiles: '打开页面'
},
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}/{max}',
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: '导出失败',
saveSuccess: '保存成功',
saveFailed: '保存失败'
},
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: '工作内容',
otherService: '其他服务计算'
},
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: '非必填,手动录入,数字,4位',
projectLabel: '项目{index}',
columns: {
code: '编码',
investAmount: '造价金额(万元)',
landArea: '造价金额(元)',
benchmarkBudget: '基准预算(元)',
basicWork: '基本工作',
optionalWork: '可选工作',
subtotal: '小计',
budgetFee: '预算费用',
consultCategoryFactor: '咨询分类系数',
majorFactor: '专业系数',
workStageFactor: '工作环节系数',
workRatio: '工作占比',
total: '合计',
remark: '说明',
majorGroup: '项目明细费用',
name: '名称',
number: '编码',
base: '计算基础',
base2: '计算基数F(万元)',
formula: '计算公式',
calculationAmount: '计算金额(元)',
calculationGroup: '计算公式',
serviceFee: '服务费用(元)'
},
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: '最高单价(万元/份)',
cLow: '计算最低值(元)',
cMid: '计算中值(元)',
cHigh: '计算最高值(元)',
serviceFee: '本计算取值(元)',
remark: '说明'
}
},
hourlyFeeGrid: {
title: '工时法明细',
clickToInput: '点击输入',
total: '总合计',
columns: {
code: '编码',
name: '名称',
technician: '技术员',
assistantEngineer: '助理工程师',
midEngineer: '中级工程师(或二级造价工程师)',
seniorEngineer: '高级工程师(或一级造价工程师)',
profSeniorEngineer: '正高级工程师',
unitPrice: '单价(元/工日)',
workdayCount: '工日数量(工日)',
subtotal: '费用小计(元)',
unitPrice2: '单价(元/工日)',
workdayCount2: '工日数量(工日)',
subtotal2: '费用小计(元)',
unitPrice3: '单价(元/工日)',
workdayCount3: '工日数量(工日)',
subtotal3: '费用小计(元)',
unitPrice4: '单价(元/工日)',
workdayCount4: '工日数量(工日)',
subtotal4: '费用小计(元)',
unitPrice5: '单价(元/工日)',
workdayCount5: '工日数量(工日)',
subtotal5: '费用小计(元)',
unitPrice6: '单价(元/工日)',
workdayCount6: '工日数量(工日)',
subtotal6: '费用小计(元)',
avgUnitPrice: '折算单价(元/工日)',
remark: '说明',
total: '合计',
referenceUnitPrice: '参考单价(元/工日)',
laborBudgetUnitPrice: '劳动预算单价(元/工日)',
compositeBudgetUnitPrice: '综合预算单价(元/工日)',
adoptedBudgetUnitPrice: '采用单价(元/工日)',
personnelCount: '人员数量(人)',
serviceBudget: '服务费用(元)',
}
},
otherService: {
title: '其他服务计算',
clickToInput: '点击输入',
total: '小计',
columns: {
num: '序号',
code: '编码',
name: '名称',
feeItem: '费用项',
unit: '单位',
quantity: '数量',
unitPrice: '单价',
serviceFee: '服务费用(元)',
remark: '说明',
actions: '操作'
}
},
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
+232 -4473
View File
File diff suppressed because it is too large. Load diff
+38 -327
View File
@@ -1,21 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue' import { computed, ref, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger, DialogDescription
} from 'reka-ui'
import { useWindowSize } from '@vueuse/core'
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
import { readCurrentProjectId } from '@/lib/workspace'
interface TypeLineCategory { interface TypeLineCategory {
key: string key: string
label: string label: string
@@ -26,45 +12,23 @@ const props = withDefaults(
defineProps<{ defineProps<{
scene?: string scene?: string
title?: string title?: string
subtitle?: string
metaText?: string
copyText?: string
categories: TypeLineCategory[] categories: TypeLineCategory[]
storageKey?: string storageKey?: string
defaultCategory?: string defaultCategory?: string
persistActiveCategory?: boolean
}>(), }>(),
{ {
scene: 'default', scene: 'default',
title: undefined, title: '配置',
subtitle: '',
metaText: '',
copyText: '',
storageKey: '', storageKey: '',
defaultCategory: '', defaultCategory: ''
persistActiveCategory: true
} }
) )
const { t } = useI18n()
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`) const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`)
const readStoredCategory = (key: string) => {
const sessionValue = sessionStorage.getItem(key)
if (sessionValue) return sessionValue
return localStorage.getItem(key)
}
const writeStoredCategory = (key: string, value: string) => {
sessionStorage.setItem(key, value)
localStorage.setItem(key, value)
}
const resolveInitialCategory = () => { const resolveInitialCategory = () => {
const defaultKey = props.defaultCategory || props.categories[0]?.key || '' const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
if (!props.persistActiveCategory) return defaultKey const savedKey = sessionStorage.getItem(cacheKey.value)
const savedKey = readStoredCategory(scopedCacheKey.value)
const validSavedKey = props.categories.some(item => item.key === savedKey) const validSavedKey = props.categories.some(item => item.key === savedKey)
return validSavedKey ? (savedKey as string) : defaultKey return validSavedKey ? (savedKey as string) : defaultKey
} }
@@ -72,301 +36,65 @@ const resolveInitialCategory = () => {
const activeCategory = ref(resolveInitialCategory()) const activeCategory = ref(resolveInitialCategory())
watch( watch(
() => [props.categories, props.defaultCategory, scopedCacheKey.value], () => [props.categories, props.defaultCategory, cacheKey.value],
() => { () => {
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
if (isCurrentValid) return
activeCategory.value = resolveInitialCategory() activeCategory.value = resolveInitialCategory()
}, },
{ deep: true } { deep: true }
) )
const switchCategory = (cat: string) => { const switchCategory = (cat: string) => {
activeCategory.value = cat activeCategory.value = cat
if (!props.persistActiveCategory) return sessionStorage.setItem(cacheKey.value, cat)
writeStoredCategory(scopedCacheKey.value, cat)
} }
const activeComponent = computed(() => { const activeComponent = computed(() => {
const selected = props.categories.find(item => item.key === activeCategory.value) const selected = props.categories.find(item => item.key === activeCategory.value)
return selected?.component || props.categories[0]?.component || null return selected?.component || props.categories[0]?.component || null
}) })
const sideWidthStyle = computed(() => ({ width: 'var(--app-typeline-side-w)' }))
const itemGapStyle = computed(() => ({ gap: 'var(--app-typeline-gap)' }))
const dotStyle = computed(() => ({ width: 'var(--app-typeline-dot)', height: 'var(--app-typeline-dot)' }))
const dotInnerStyle = computed(() => ({ width: 'var(--app-typeline-dot-inner)', height: 'var(--app-typeline-dot-inner)' }))
const labelStyle = computed(() => ({ fontSize: 'var(--app-typeline-label-font)', lineHeight: 'var(--app-typeline-label-line)' }))
const lineStyle = computed(() => ({ left: 'var(--app-typeline-line-left)' }))
const copyBtnText = ref(t('typeLine.copy'))
const sheetOpen = ref(false)
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
let titleOverflowRafId: number | null = null
const handleCopySubtitle = async () => {
const text = (props.copyText || '').trim()
if (!text) return
try {
await navigator.clipboard.writeText(text)
copyBtnText.value = t('typeLine.copied')
} catch (error) {
console.error('copy failed:', error)
copyBtnText.value = t('typeLine.copyFailed')
}
if (copyBtnTimer) clearTimeout(copyBtnTimer)
copyBtnTimer = setTimeout(() => {
copyBtnText.value = t('typeLine.copy')
}, 1200)
}
onBeforeUnmount(() => {
if (copyBtnTimer) clearTimeout(copyBtnTimer)
if (titleOverflowRafId != null) {
cancelAnimationFrame(titleOverflowRafId)
titleOverflowRafId = null
}
if (!root) return
root.style.scale = ''
root.style.translate = ''
root.style.borderRadius = ''
})
//
const inertiaTransition = {
type: 'inertia' as const,
bounceStiffness: 300,
bounceDamping: 40,
timeConstant: 300,
}
const staticTransition = {
duration: 0.5,
ease: [0.32, 0.72, 0, 1] as const,
}
const SHEET_TOP_RATIO = 0.1
const SHEET_RADIUS = 12
const OFFICIAL_SITE_URL = 'http://www.zwgczx.com.cn/'
let root: HTMLElement | null = null
onMounted(() => {
root = document.body.firstElementChild as HTMLElement | null
})
const { height, width } = useWindowSize()
const sheetTop = computed(() => Math.round(height.value * SHEET_TOP_RATIO))
const h = computed(() => Math.max(0, height.value - sheetTop.value))
const y = useMotionValue(h.value)
watch(
() => h.value,
(nextHeight) => {
if (!sheetOpen.value) y.jump(nextHeight)
}
)
watch(
() => sheetOpen.value,
(isOpen) => {
if (!isOpen) {
y.jump(h.value)
return
}
y.jump(h.value)
animate(y, 0, staticTransition)
}
)
// Scale the body down and adjust the border radius when the sheet is open.
const bodyScale = useTransform(
y,
[0, h.value],
[(width.value - sheetTop.value) / width.value, 1],
)
const bodyTranslate = useTransform(y, [0, h.value], [sheetTop.value - SHEET_RADIUS, 0])
const bodyBorderRadius = useTransform(y, [0, h.value], [SHEET_RADIUS, 0])
useMotionValueEvent(bodyScale, 'change', (v) => {
if (!root) return
root.style.scale = `${v}`
})
useMotionValueEvent(
bodyTranslate,
'change',
(v) => {
if (!root) return
root.style.translate = `0 ${v}px`
},
)
useMotionValueEvent(
bodyBorderRadius,
'change',
(v) => {
if (!root) return
root.style.borderRadius = `${v}px`
},
)
</script> </script>
<template> <template>
<TooltipProvider> <div class="flex h-full w-full bg-background">
<div class="flex h-full w-full bg-background p-2"> <div class="w-1/5 border-r p-6 flex flex-col gap-8 relative">
<div :style="sideWidthStyle" class="shrink-0 border-r px-4 py-3 flex flex-col gap-6 relative"> <!-- <div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div> -->
<div v-if="props.title || props.subtitle || props.metaText" class="space-y-1">
<TooltipRoot>
<TooltipTrigger as-child>
<div v-if="props.title" ref="titleRef" class="title-ellipsis-2 max-w-full font-bold text-base leading-6 text-primary">
{{ props.title }}
</div>
</TooltipTrigger>
<TooltipContent side="right" :avoid-collisions="false">{{ props.title }}</TooltipContent>
</TooltipRoot>
<div v-if="props.subtitle" class="flex min-w-0 items-center gap-2 text-xs leading-5 text-muted-foreground">
<div class="min-w-0 flex-1">
<TooltipRoot>
<TooltipTrigger as-child>
<span ref="subtitleRef" class="block max-w-full truncate">{{ props.subtitle }}</span>
</TooltipTrigger>
<TooltipContent side="right" :avoid-collisions="false">{{ props.subtitle }}</TooltipContent>
</TooltipRoot>
</div>
<Button v-if="props.copyText" type="button" variant="outline" size="sm"
class="h-6 rounded-md px-2 text-[11px]" @click.stop="handleCopySubtitle">
{{ copyBtnText }}
</Button>
</div>
<div v-if="props.metaText" class="text-xs leading-5 text-muted-foreground">
{{ props.metaText }}
</div>
</div>
<div :class="['flex flex-col gap-6 relative ', (props.title || props.subtitle || props.metaText) ? 'mt-3' : 'mt-6']"> <div class="flex flex-col gap-10 relative">
<div :style="lineStyle" class="absolute top-3 bottom-3 w-[1.5px] bg-border/60"></div> <div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
<div v-for="item in props.categories" :key="item.key" <div
:style="itemGapStyle" class="relative flex items-center cursor-pointer group" @click="switchCategory(item.key)"> v-for="item in props.categories"
<div :class="[ :key="item.key"
'z-10 rounded-full border-2 flex items-center justify-center transition-all duration-200', class="relative flex items-center gap-4 cursor-pointer group"
activeCategory === item.key @click="switchCategory(item.key)"
? 'bg-blue-600 border-blue-600' >
: 'bg-background border-slate-300 group-hover:border-slate-400' <div
]" :style="dotStyle"> :class="[
<div v-if="activeCategory === item.key" class="bg-white rounded-full" :style="dotInnerStyle"></div> 'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
activeCategory === item.key
? 'bg-primary border-primary scale-110'
: 'bg-background border-muted-foreground'
]"
>
<div v-if="activeCategory === item.key" class="w-2 h-2 bg-background rounded-full"></div>
</div> </div>
<span :class="[ <span
'transition-colors duration-200', :class="[
activeCategory === item.key 'text-sm transition-colors',
? 'font-semibold text-blue-600' activeCategory === item.key
: 'text-slate-500 group-hover:text-slate-700' ? 'font-bold text-primary'
]" :style="labelStyle"> : 'text-muted-foreground group-hover:text-foreground'
]"
>
{{ item.label }} {{ item.label }}
</span> </span>
</div> </div>
</div> </div>
<DialogRoot v-model:open="sheetOpen">
<DialogTrigger as-child>
<button type="button"
class="cursor-pointer absolute left-3 right-3 bottom-3 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[11px] leading-4 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground">
<img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-8 w-8 shrink-0 rounded-sm" />
<span>{{ t('typeLine.supportText') }}</span>
</button>
</DialogTrigger>
<DialogPortal>
<AnimatePresence multiple as="div">
<DialogOverlay as-child>
<Motion class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]" :initial="{ opacity: 0 }"
:animate="{ opacity: 1 }" :exit="{ opacity: 0 }" :transition="staticTransition" />
</DialogOverlay>
<DialogContent as-child>
<Motion
class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-border/60 bg-card/95 shadow-2xl backdrop-blur-xl will-change-transform"
:style="{
y,
top: `${sheetTop}px`,
}" drag="y" :drag-constraints="{ top: 0 }" @drag-end="(e, { offset, velocity }) => {
if (offset.y > h * 0.35 || velocity.y > 10) {
sheetOpen = false;
}
else {
animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
}
}">
<div
class="mx-auto mt-2 h-1.5 w-12 cursor-grab rounded-full bg-muted-foreground/35 active:cursor-grabbing" />
<div class="mx-auto flex h-full w-full max-w-2xl flex-col px-4 pb-5 pt-3">
<div class="mb-3">
<div class="flex justify-end">
<DialogClose
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M18 6L6 18M6 6l12 12" />
</svg>
</DialogClose>
</div>
<DialogTitle class="mt-2">
<div class="flex items-center gap-3">
<img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-7 w-7 shrink-0 rounded-sm" />
<span class="text-2xl font-semibold leading-none">{{ t('typeLine.aboutTitle') }}</span>
</div>
</DialogTitle>
</div>
<DialogDescription class="mb-4 text-base text-muted-foreground">
<div class="flex items-center gap-2">
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline">
{{ t('typeLine.companyName') }}
</a>
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
:aria-label="t('typeLine.openOfficialSiteAria')" :title="t('typeLine.officialSiteTitle')">
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M7 7h10v10M7 17L17 7" />
</svg>
</a>
</div>
</DialogDescription>
<div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
<p>
{{ t('typeLine.aboutParagraph1') }}
</p>
<p>
{{ t('typeLine.aboutParagraph2') }}
</p>
</div>
</div>
</Motion>
</DialogContent>
</AnimatePresence>
</DialogPortal>
</DialogRoot>
</div> </div>
<div class="flex-1 min-w-0 min-h-0 h-full flex flex-col"> <div class="w-4/5 min-h-0">
<ScrollArea class="h-full w-full min-h-0 rightMain"> <ScrollArea class="h-full w-full">
<div class="p-4 h-full min-h-0 flex flex-col"> <div class="p-3">
<keep-alive> <keep-alive>
<component :is="activeComponent" /> <component :is="activeComponent" />
</keep-alive> </keep-alive>
@@ -374,21 +102,4 @@ useMotionValueEvent(
</ScrollArea> </ScrollArea>
</div> </div>
</div> </div>
</TooltipProvider>
</template> </template>
<style scoped>
/* 核心修改:添加 :deep() 穿透 scoped 作用域 */
:deep(.rightMain > div > div) {
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.title-ellipsis-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
</style>
-165
View File
@@ -1,165 +0,0 @@
import type { ColDef, ColGroupDef } from 'ag-grid-community'
type AnyColDef<TRow> = ColDef<TRow> | ColGroupDef<TRow>
const numericFieldKeywords = [
'amount',
'area',
'cost',
'price',
'fee',
'budget',
'subtotal',
'total',
'ratio',
'rate',
'quantity',
'count',
'num',
'workday',
'workload',
'hourly',
'scale',
'value',
'coe',
'factor'
]
const isFiniteNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value)
const isNumericLikeString = (value: unknown) => {
if (typeof value !== 'string') return false
const normalized = value.replace(/[,\s]/g, '').replace(/%$/, '')
if (!normalized) return false
const asNumber = Number(normalized)
return Number.isFinite(asNumber)
}
const looksNumericByColumnDef = <TRow>(col: ColDef<TRow>) => {
const fieldLike = String(col.field ?? col.colId ?? '').toLowerCase()
const hasNumericFieldHint = fieldLike
? numericFieldKeywords.some(keyword => fieldLike.includes(keyword))
: false
const type = col.type
const typeList = Array.isArray(type) ? type : type ? [type] : []
const hasNumericType = typeList.some(item => {
const normalized = String(item).toLowerCase()
return normalized.includes('numeric') || normalized.includes('rightaligned')
})
return hasNumericFieldHint || col.cellDataType === 'number' || hasNumericType
}
const hasRightAlignedHeaderClass = (value: unknown) => {
if (typeof value === 'string') return value.includes('ag-right-aligned-header')
if (Array.isArray(value)) return value.some(item => String(item).includes('ag-right-aligned-header'))
return false
}
const mergeHeaderClass = <TRow>(col: ColDef<TRow>, mustRightAlign: boolean): ColDef<TRow>['headerClass'] => {
if (!mustRightAlign) return col.headerClass
const base = col.headerClass
if (!base) return 'ag-right-aligned-header'
if (hasRightAlignedHeaderClass(base)) return base
if (typeof base === 'function') {
return params => {
const result = base(params)
if (!result) return 'ag-right-aligned-header'
if (typeof result === 'string') {
return result.includes('ag-right-aligned-header')
? result
: `${result} ag-right-aligned-header`
}
if (Array.isArray(result)) {
return hasRightAlignedHeaderClass(result)
? result
: [...result, 'ag-right-aligned-header']
}
return 'ag-right-aligned-header'
}
}
if (typeof base === 'string') return `${base} ag-right-aligned-header`
if (Array.isArray(base)) return [...base, 'ag-right-aligned-header']
return base
}
const mergeNumericCellClassRules = <TRow>(
col: ColDef<TRow>,
mustRightAlign: boolean
): ColDef<TRow>['cellClassRules'] => {
if (!mustRightAlign) return col.cellClassRules
const baseRules = col.cellClassRules ? { ...col.cellClassRules } : {}
const existing = baseRules['ag-right-aligned-cell']
if (existing) return baseRules
baseRules['ag-right-aligned-cell'] = params =>
looksNumericByColumnDef(params.colDef as ColDef<TRow>) ||
isFiniteNumber(params.value) ||
isNumericLikeString(params.value)
return baseRules
}
const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] => {
const baseStyle = { whiteSpace: 'normal', lineHeight: '1.4' }
if (!cellStyle) return baseStyle
if (typeof cellStyle === 'function') {
return params => {
const next = cellStyle(params)
if (next && typeof next === 'object') {
return {
...next,
...baseStyle
}
}
return baseStyle
}
}
if (typeof cellStyle === 'object') {
return {
...cellStyle,
...baseStyle
}
}
return cellStyle
}
const enhanceLeafColumn = <TRow>(col: ColDef<TRow>): ColDef<TRow> => {
const editable = col.editable
const isReadonlyColumn = editable == null || editable === false
const shouldRightAlign = looksNumericByColumnDef(col)
if (!isReadonlyColumn) {
return {
...col,
headerClass: mergeHeaderClass(col, shouldRightAlign),
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign)
}
}
return {
...col,
headerClass: mergeHeaderClass(col, shouldRightAlign),
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign),
wrapText: true,
autoHeight: true,
cellStyle: mergeCellStyle(col.cellStyle)
}
}
const enhanceColumn = <TRow>(col: AnyColDef<TRow>): AnyColDef<TRow> => {
const maybeGroup = col as ColGroupDef<TRow>
if (Array.isArray(maybeGroup.children)) {
return {
...maybeGroup,
children: maybeGroup.children.map(child => enhanceColumn(child as AnyColDef<TRow>))
}
}
return enhanceLeafColumn(col as ColDef<TRow>)
}
export const withReadonlyAutoHeight = <TRow>(
defs: Array<AnyColDef<TRow>>
): Array<AnyColDef<TRow>> => defs.map(def => enhanceColumn(def))
-80
View File
@@ -1,80 +0,0 @@
import type { IHeaderComp, IHeaderParams } from 'ag-grid-community'
import { i18n } from '@/i18n'
export type ResetHeaderParams = IHeaderParams & {
onReset?: () => void | Promise<void>
resetTitle?: string
}
export class AgGridResetHeader implements IHeaderComp {
private params!: ResetHeaderParams
private eGui!: HTMLDivElement
private eLabel!: HTMLSpanElement
private eButton!: HTMLButtonElement
private onButtonClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
void this.params.onReset?.()
}
init(params: ResetHeaderParams) {
this.params = params
const eGui = document.createElement('div')
eGui.style.display = 'flex'
eGui.style.alignItems = 'center'
eGui.style.justifyContent = 'space-between'
eGui.style.gap = '6px'
eGui.style.width = '100%'
const eLabel = document.createElement('span')
eLabel.style.flex = '1'
eLabel.style.minWidth = '0'
eLabel.style.whiteSpace = 'normal'
eLabel.style.lineHeight = '1.2'
const eButton = document.createElement('button')
eButton.type = 'button'
eButton.textContent = '↻'
const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
eButton.style.display = 'inline-flex'
eButton.style.alignItems = 'center'
eButton.style.justifyContent = 'center'
eButton.style.width = '18px'
eButton.style.height = '18px'
eButton.style.border = '1px solid #d1d5db'
eButton.style.borderRadius = '999px'
eButton.style.background = '#fff'
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)
}
}
-141
View File
@@ -1,141 +0,0 @@
export interface DataEntry {
key: string
value: any
}
export interface ContractSegmentPackage {
version: number
exportedAt: string
packageType?: 'contract-segments'
project?: {
industry: string
}
storage?: {
localforageEntries: DataEntry[]
keyedEntries?: DataEntry[]
}
contracts: Array<{
id: string
name: string
order: number
createdAt: string
}>
projectIndustry?: string
localforageEntries?: DataEntry[]
keyedEntries?: DataEntry[]
pinia?: {
zxFwPricing?: {
contracts?: Record<string, unknown>
servicePricingStates?: Record<string, unknown>
htFeeMainStates?: Record<string, unknown>
htFeeMethodStates?: Record<string, unknown>
}
}
piniaState?: {
zxFwPricing?: {
contracts?: Record<string, unknown>
servicePricingStates?: Record<string, unknown>
htFeeMainStates?: Record<string, unknown>
htFeeMethodStates?: Record<string, unknown>
}
}
}
export const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
export const CONTRACT_SEGMENT_VERSION = 3
export const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
export const SERVICE_KEY_PREFIX = 'zxFW-'
export const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
export const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
export const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-'] as const
export const PROJECT_INFO_KEY = 'xm-base-info-v1'
export const PROJECT_SCALE_KEY = 'xm-info-v3'
export const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const
export const formatExportTimestamp = (date: Date): string => {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
return `${yyyy}${mm}${dd}-${hh}${mi}`
}
export const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value))
export const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const normalizeDataEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as DataEntry).key === 'string')
.map(item => ({
key: String((item as DataEntry).key),
value: (item as DataEntry).value
}))
}
export const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
projectIndustry:
typeof payload.project?.industry === 'string' && payload.project.industry.trim()
? payload.project.industry.trim()
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
piniaState: payload.pinia ?? payload.piniaState
})
export const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
const payload = value as Partial<ContractSegmentPackage> | null
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
}
export const isContractRelatedForageKey = (key: string, contractId: string) => {
if (key === `${CONTRACT_KEY_PREFIX}${contractId}`) return true
if (key === `${SERVICE_KEY_PREFIX}${contractId}`) return true
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${contractId}`) return true
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${contractId}`) return true
if (PRICING_KEY_PREFIXES.some(prefix => key.startsWith(`${prefix}${contractId}-`))) return true
return false
}
export const isContractRelatedKeyedStateKey = (key: string, contractId: string) => {
if (key === `ht-base-info-${contractId}`) return true
if (key.startsWith(`work-content-${contractId}-`)) return true
if (key.startsWith(`work-content-htExtraFee-${contractId}-`)) return true
return false
}
export const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${fromId}`) {
return `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${toId}`
}
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
}
if (key === `ht-base-info-${fromId}`) return `ht-base-info-${toId}`
if (key.startsWith(`work-content-${fromId}-`)) {
return key.replace(`work-content-${fromId}-`, `work-content-${toId}-`)
}
if (key.startsWith(`work-content-htExtraFee-${fromId}-`)) {
return key.replace(`work-content-htExtraFee-${fromId}-`, `work-content-htExtraFee-${toId}-`)
}
for (const prefix of PRICING_KEY_PREFIXES) {
if (key.startsWith(`${prefix}${fromId}-`)) {
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
}
}
return key
}
export const generateContractId = (usedIds: Set<string>) => {
let nextId = ''
while (!nextId || usedIds.has(nextId)) {
nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
}
usedIds.add(nextId)
return nextId
}
-198
View File
@@ -1,198 +0,0 @@
import Decimal from 'decimal.js'
type MaybeNumber = number | null | undefined
type DecimalInput = Decimal.Value
export const toDecimal = (value: DecimalInput) => new Decimal(value)
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
export const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
export const toFiniteNumberOrNull = (value: unknown): number | null =>
isFiniteNumber(value) ? value : null
export const toFiniteNumberOrZero = (value: unknown): number =>
toFiniteNumberOrNull(value) ?? 0
export const toFiniteNumber = (value: unknown): number | null => {
if (isFiniteNumber(value)) return value
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return null
const numeric = Number(trimmed)
return Number.isFinite(numeric) ? numeric : null
}
return null
}
const sumFiniteValues = (values: Iterable<unknown>) => {
let total = new Decimal(0)
for (const value of values) {
if (!isFiniteNumber(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const addNumbers = (...values: MaybeNumber[]) => sumFiniteValues(values)
export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
let total = new Decimal(0)
for (const item of list) {
const value = pick(item)
if (!isFiniteNumber(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const sumNullableNumbers = (values: MaybeNumber[]): number | null => {
const validValues = values.filter(isFiniteNumber)
if (validValues.length === 0) return null
return addNumbers(...validValues)
}
export const decimalAggSum = (params: { values?: unknown[] }) => {
const values = params.values || []
let hasFinite = false
for (const value of values) {
if (!isFiniteNumber(value)) continue
hasFinite = true
break
}
if (!hasFinite) return null
return sumFiniteValues(values)
}
class DecimalExpressionParser {
private readonly source: string
private index = 0
constructor(source: string) {
this.source = source
}
parse(): Decimal | null {
const value = this.parseExpression()
this.skipWhitespace()
if (!value || this.index !== this.source.length) return null
return value
}
private parseExpression(): Decimal | null {
let value = this.parseTerm()
if (!value) return null
while (true) {
this.skipWhitespace()
const operator = this.peek()
if (operator !== '+' && operator !== '-') return value
this.index += 1
const right = this.parseTerm()
if (!right) return null
value = operator === '+' ? value.plus(right) : value.minus(right)
}
}
private parseTerm(): Decimal | null {
let value = this.parseFactor()
if (!value) return null
while (true) {
this.skipWhitespace()
const operator = this.peek()
if (operator !== '*' && operator !== '/') return value
this.index += 1
const right = this.parseFactor()
if (!right) return null
if (operator === '/') {
if (right.isZero()) return null
value = value.div(right)
continue
}
value = value.mul(right)
}
}
private parseFactor(): Decimal | null {
this.skipWhitespace()
const current = this.peek()
if (current === '+') {
this.index += 1
return this.parseFactor()
}
if (current === '-') {
this.index += 1
const value = this.parseFactor()
return value ? value.neg() : null
}
if (current === '(') {
this.index += 1
const value = this.parseExpression()
this.skipWhitespace()
if (!value || this.peek() !== ')') return null
this.index += 1
return value
}
return this.parseNumber()
}
private parseNumber(): Decimal | null {
this.skipWhitespace()
const start = this.index
let hasDigit = false
let hasDot = false
while (this.index < this.source.length) {
const char = this.source[this.index]
if (char >= '0' && char <= '9') {
hasDigit = true
this.index += 1
continue
}
if (char === '.' && !hasDot) {
hasDot = true
this.index += 1
continue
}
break
}
if (!hasDigit) {
this.index = start
return null
}
const literal = this.source.slice(start, this.index)
try {
return new Decimal(literal)
} catch {
return null
}
}
private skipWhitespace() {
while (this.index < this.source.length && /\s/.test(this.source[this.index])) {
this.index += 1
}
}
private peek() {
return this.source[this.index]
}
}
// 支持 + - * / () 的高精度表达式计算,用于数字输入框和表格 valueParser。
export const evaluateDecimalExpression = (value: string): number | null => {
const trimmed = String(value || '').trim()
if (!trimmed) return null
try {
const parsed = new DecimalExpressionParser(trimmed).parse()
return parsed ? parsed.toNumber() : null
} catch {
return null
}
}
-204
View File
@@ -1,204 +0,0 @@
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)
}
}
-41
View File
@@ -1,41 +0,0 @@
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)
}
-42
View File
@@ -1,42 +0,0 @@
import { parseNumberOrNull } from '@/lib/number'
const fixedFormatterCache = new Map<number, Intl.NumberFormat>()
const flexibleFormatterCache = new Map<number, Intl.NumberFormat>()
const getFixedFormatter = (fractionDigits: number) => {
if (!fixedFormatterCache.has(fractionDigits)) {
fixedFormatterCache.set(
fractionDigits,
new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
})
)
}
return fixedFormatterCache.get(fractionDigits)!
}
const getFlexibleFormatter = (maxFractionDigits: number) => {
if (!flexibleFormatterCache.has(maxFractionDigits)) {
flexibleFormatterCache.set(
maxFractionDigits,
new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits
})
)
}
return flexibleFormatterCache.get(maxFractionDigits)!
}
export const formatThousands = (value: unknown, fractionDigits = 2) => {
const numericValue = parseNumberOrNull(value)
if (numericValue == null) return ''
return getFixedFormatter(fractionDigits).format(numericValue)
}
export const formatThousandsFlexible = (value: unknown, maxFractionDigits = 20) => {
const numericValue = parseNumberOrNull(value)
if (numericValue == null) return ''
return getFlexibleFormatter(maxFractionDigits).format(numericValue)
}
-68
View File
@@ -1,68 +0,0 @@
/**
* 工时法计算模块
*
* 提供工时法的行构建、合并、费用计算等纯函数,
* 供 HourlyPricingPane/HourlyFeeGrid 和 pricingMethodTotals.ts 共用。
*/
import { expertList } from '@/sql'
import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
/* ----------------------------------------------------------------
* 专家字典查询
* ---------------------------------------------------------------- */
/** 获取专家条目列表(按 ID 排序) */
export const getExpertEntries = (): [string, ExpertLite][] =>
Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
/** 计算专家默认采用单价 = 基准单价 × 管理系数 */
const getDefaultHourlyAdoptedPrice = (expert: ExpertLite): number | null => {
if (expert.defPrice == null || expert.manageCoe == null) return null
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
/* ----------------------------------------------------------------
* 行构建与合并
* ---------------------------------------------------------------- */
/** 构建工时法默认行 */
export const buildDefaultHourlyRows = (): HourlyDetailRow[] =>
getExpertEntries().map(([expertId, expert]) => ({
id: `expert-${expertId}`,
adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert),
personnelCount: null,
workdayCount: null
}))
/** 合并持久化行与默认行 */
export const mergeHourlyRows = (
rowsFromDb: Array<Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>> | undefined
): HourlyDetailRow[] => {
const dbMap = new Map<string, Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>>()
for (const row of rowsFromDb || []) dbMap.set(row.id, row)
return buildDefaultHourlyRows().map(row => {
const fromDb = dbMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice),
personnelCount: toFiniteNumberOrNull(fromDb.personnelCount),
workdayCount: toFiniteNumberOrNull(fromDb.workdayCount)
}
})
}
/* ----------------------------------------------------------------
* 费用计算
* ---------------------------------------------------------------- */
/** 计算工时法单行费用 = 采用单价 × 人数 × 工日数 */
export const calcHourlyServiceBudget = (row: HourlyDetailRow): number | null => {
const { adoptedBudgetUnitPrice, personnelCount, workdayCount } = row
if (adoptedBudgetUnitPrice == null || personnelCount == null || workdayCount == null) return null
return roundTo(toDecimal(adoptedBudgetUnitPrice).mul(personnelCount).mul(workdayCount), 2)
}
File diff suppressed because it is too large. Load diff
-75
View File
@@ -1,75 +0,0 @@
/**
* 计价法持久化控制
*
* 管理 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))
}
-64
View File
@@ -1,64 +0,0 @@
export interface ScalePinnedTotalRowBase {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount?: number | null
landArea?: number | null
benchmarkBudget: number | null
benchmarkBudgetBasic: number | null
benchmarkBudgetOptional: number | null
benchmarkBudgetBasicChecked: boolean
benchmarkBudgetOptionalChecked: boolean
basicFormula: string | null
optionalFormula: string | null
consultCategoryFactor: number | null
majorFactor: number | null
workStageFactor: number | null
workRatio: number | null
budgetFee: number | null
budgetFeeBasic: number | null
budgetFeeOptional: number | null
remark: string
path: string[]
}
export const createScalePinnedTotalRow = <TRow extends ScalePinnedTotalRowBase>(
overrides: Partial<TRow> & Pick<TRow, 'budgetFee' | 'budgetFeeBasic' | 'budgetFeeOptional'>
) => {
const baseRow: ScalePinnedTotalRowBase = {
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
hasCost: false,
hasArea: false,
amount: null,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '',
optionalFormula: '',
consultCategoryFactor: null,
majorFactor: null,
workStageFactor: null,
workRatio: null,
budgetFee: null,
budgetFeeBasic: null,
budgetFeeOptional: null,
remark: '',
path: ['TOTAL']
}
return {
...baseRow,
...overrides
} as TRow
}
export const createPinnedTopRowData = <TRow>(row: TRow): TRow[] => [row]
-218
View File
@@ -1,218 +0,0 @@
/**
* 规模法通用计算(投资规模法 + 用地规模法共用)
*
* 提供行默认值构建、行合并、费用计算等纯函数,
* 供 ScalePricingPane.vue 和 pricingMethodTotals.ts 共用。
*/
import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql'
import { toFiniteNumberOrNull } from '@/lib/decimal'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
import type {
ScaleCalcRow,
ScaleType,
MajorLite,
ServiceLite
} from '@/types/pricing'
/* ----------------------------------------------------------------
* 专业字典查询
* ---------------------------------------------------------------- */
const majorById = new Map(
getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite])
)
const majorIdAliasMap = getMajorIdAliasMap()
/** 获取专业叶子节点 ID 列表(code 含 '-' 的为叶子) */
export const getMajorLeafIds = (): string[] =>
getMajorDictEntries()
.filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
.map(({ id }) => id)
/** 解析专业 ID 别名 */
export const resolveMajorId = (id: string): string =>
majorById.has(id) ? id : majorIdAliasMap.get(id) || id
/** 获取专业默认系数 */
export const getDefaultMajorFactor = (id: string): number | null => {
const resolvedId = resolveMajorId(id)
return toFiniteNumberOrNull(majorById.get(resolvedId)?.defCoe)
}
/** 判断专业是否支持投资规模(hasCost) */
export const isCostMajor = (id: string): boolean => {
const resolvedId = resolveMajorId(id)
return majorById.get(resolvedId)?.hasCost !== false
}
/** 判断专业是否支持用地规模(hasArea) */
export const isAreaMajor = (id: string): boolean => {
const resolvedId = resolveMajorId(id)
return majorById.get(resolvedId)?.hasArea !== false
}
/** 判断专业是否同时支持投资和用地 */
export const isDualScaleMajor = (id: string): boolean =>
isCostMajor(id) && isAreaMajor(id)
/** 根据行业 ID 查找对应的专业条目 */
export const getIndustryMajorEntry = (industryId: string | null | undefined) => {
const key = String(industryId || '').trim()
if (!key) return null
for (const [id, item] of majorById.entries()) {
const majorIndustryId = String(item?.industryId ?? '').trim()
if (majorIndustryId === key && !String(item?.code || '').includes('-')) {
return { id, item }
}
}
return null
}
/* ----------------------------------------------------------------
* 咨询服务字典查询
* ---------------------------------------------------------------- */
/** 获取咨询服务默认分类系数 */
export const getDefaultConsultCategoryFactor = (serviceId: string | number): number | null => {
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
return toFiniteNumberOrNull(service?.defCoe)
}
/** 判断是否为仅投资规模服务 */
export const isOnlyCostScaleService = (serviceId: string | number): boolean => {
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
return service?.onlyCostScale === true
}
/* ----------------------------------------------------------------
* 行构建与合并
* ---------------------------------------------------------------- */
/** 判断是否为分组汇总行(AG Grid tree 用) */
export const isGroupScaleRow = (row: unknown): boolean =>
Boolean(row && typeof row === 'object' && (row as Record<string, unknown>).isGroupRow === true)
/** 过滤掉分组汇总行 */
export const stripGroupScaleRows = <TRow>(rows: TRow[] | undefined): TRow[] =>
(rows || []).filter(row => !isGroupScaleRow(row))
/** 构建规模法默认行(全部专业叶子) */
export const buildDefaultScaleRows = (
serviceId: string | number,
consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>
): ScaleCalcRow[] => {
const defaultFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
return getMajorLeafIds().map(id => ({
id,
amount: null,
landArea: null,
consultCategoryFactor: defaultFactor,
majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactor(id),
workStageFactor: 1,
workRatio: 100
}))
}
const hasOwn = (obj: unknown, key: string) =>
Object.prototype.hasOwnProperty.call(obj || {}, key)
const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
const map = new Map<string, TRow>()
for (const row of rows || []) map.set(String(row.id), row)
return map
}
/** 合并持久化行与默认行(保留用户编辑值,补全缺失字段) */
export const mergeScaleRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<ScaleCalcRow> & Pick<ScaleCalcRow, 'id'>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>
): ScaleCalcRow[] => {
const sourceRows = stripGroupScaleRows(rowsFromDb)
const dbValueMap = toRowMap(sourceRows)
// 处理 ID 别名映射
for (const row of sourceRows) {
const nextId = majorIdAliasMap.get(String(row.id))
if (nextId && !dbValueMap.has(nextId)) {
dbValueMap.set(nextId, row as ScaleCalcRow)
}
}
const defaultFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap).map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
amount: toFiniteNumberOrNull(fromDb.amount),
landArea: toFiniteNumberOrNull(fromDb.landArea),
consultCategoryFactor:
toFiniteNumberOrNull(fromDb.consultCategoryFactor) ??
(hasOwn(fromDb, 'consultCategoryFactor') ? null : defaultFactor),
majorFactor:
toFiniteNumberOrNull(fromDb.majorFactor) ??
(hasOwn(fromDb, 'majorFactor') ? null : (majorFactorMap?.get(row.id) ?? getDefaultMajorFactor(row.id))),
workStageFactor:
toFiniteNumberOrNull(fromDb.workStageFactor) ??
(hasOwn(fromDb, 'workStageFactor') ? null : row.workStageFactor),
workRatio:
toFiniteNumberOrNull(fromDb.workRatio) ??
(hasOwn(fromDb, 'workRatio') ? null : row.workRatio)
}
})
}
/* ----------------------------------------------------------------
* 费用计算
* ---------------------------------------------------------------- */
/** 计算投资规模法单行费用 */
export const calcInvestBudgetFee = (row: ScaleCalcRow): number | null =>
getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByScale(row.amount, 'cost'),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
})
/** 计算用地规模法单行费用 */
export const calcLandBudgetFee = (row: ScaleCalcRow): number | null =>
getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByScale(row.landArea, 'area'),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
})
/** 根据规模类型计算单行费用 */
export const calcScaleBudgetFee = (row: ScaleCalcRow, scaleType: ScaleType): number | null =>
scaleType === 'invest' ? calcInvestBudgetFee(row) : calcLandBudgetFee(row)
/** 判断行是否属于指定规模类型 */
export const isRowForScaleType = (rowId: string, scaleType: ScaleType): boolean =>
scaleType === 'invest' ? isCostMajor(rowId) : isAreaMajor(rowId)
/* ----------------------------------------------------------------
* 可空数值求和
* ---------------------------------------------------------------- */
/** 对数组求和,全部为 null 时返回 null */
export const sumNullableBy = <T>(list: T[], pick: (item: T) => number | null | undefined): number | null => {
let hasValid = false
let total = 0
for (const item of list) {
const value = toFiniteNumberOrNull(pick(item))
if (value == null) continue
hasValid = true
total += value
}
return hasValid ? total : null
}
-387
View File
@@ -1,387 +0,0 @@
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import {
formatScaleEditableNumber,
formatScaleReadonlyMoney,
getScaleMergeColSpanBeforeTotal
} from '@/lib/pricingScaleGrid'
import { i18n } from '@/i18n'
type ScaleColumnField<TRow> = Extract<keyof TRow, string> | string
const scaleT = (key: string, params?: Record<string, unknown>) =>
params ? i18n.global.t(`pricingScale.${key}`, params) : i18n.global.t(`pricingScale.${key}`)
export const createScaleValueColumn = <TRow>(options: {
headerName: string
field: ScaleColumnField<TRow>
headerTooltip: string
onReset: () => Promise<void> | void
resetTitle: string
headerComponent: any
minWidth?: number
flex?: number
isEditable: (row: TRow | undefined) => boolean
emptyTextPredicate: (row: TRow | undefined, value: unknown) => boolean
valueParser: (params: any) => any
valueFormatter: (params: any) => string
}) : ColDef<TRow> => ({
headerName: options.headerName,
field: options.field as any,
headerTooltip: options.headerTooltip,
headerComponent: options.headerComponent,
headerComponentParams: {
onReset: options.onReset,
resetTitle: options.resetTitle
},
headerClass: 'ag-right-aligned-header',
minWidth: options.minWidth ?? 90,
flex: options.flex ?? 2,
editable: params => !params.node?.group && !params.node?.rowPinned && options.isEditable(params.data),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && options.isEditable(params.data)
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && options.emptyTextPredicate(params.data, params.value)
},
valueParser: options.valueParser,
valueFormatter: options.valueFormatter
})
export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
getCheckedSplit: (row: TRow | undefined) => { basic?: number | null; optional?: number | null; total?: number | null } | null
createBudgetCellRendererWithCheck: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
getHeaderComponent?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
getHeaderComponentParams?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => Record<string, unknown>
}) : ColGroupDef<TRow> => ({
headerName: scaleT('columns.benchmarkBudget'),
marryChildren: true,
children: [
{
headerName: scaleT('columns.basicWork'),
field: 'benchmarkBudgetBasic' as any,
colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header',
headerComponent: options.getHeaderComponent?.('benchmarkBudgetBasicChecked'),
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetBasicChecked'),
minWidth: 130,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.basic ?? null),
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatScaleReadonlyMoney
},
{
headerName: scaleT('columns.optionalWork'),
field: 'benchmarkBudgetOptional' as any,
colId: 'benchmarkBudgetOptional',
headerClass: 'ag-right-aligned-header',
headerComponent: options.getHeaderComponent?.('benchmarkBudgetOptionalChecked'),
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetOptionalChecked'),
minWidth: 130,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.optional ?? null),
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatScaleReadonlyMoney
},
{
headerName: scaleT('columns.subtotal'),
field: 'benchmarkBudget' as any,
colId: 'benchmarkBudgetTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.total ?? null),
valueFormatter: formatScaleReadonlyMoney
}
]
})
export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
headerComponent: any
restoreConsultCategoryFactorColumnDefaults: () => Promise<void> | void
restoreMajorFactorColumnDefaults: () => Promise<void> | void
parseNumberOrNull: (value: any, options?: any) => any
getBudgetFee: (row: TRow | undefined) => number | null
aggFunc: any
}) : ColGroupDef<TRow> => ({
headerName: scaleT('columns.budgetFee'),
marryChildren: true,
children: [
{
headerName: scaleT('columns.consultCategoryFactor'),
field: 'consultCategoryFactor' as any,
colId: 'consultCategoryFactor',
headerTooltip: scaleT('tooltip.resetConsultCategoryFactor'),
headerComponent: options.headerComponent,
headerComponentParams: {
onReset: options.restoreConsultCategoryFactorColumnDefaults,
resetTitle: scaleT('tooltip.resetConsultCategoryFactor')
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => formatScaleEditableNumber(params)
},
{
headerName: scaleT('columns.majorFactor'),
field: 'majorFactor' as any,
colId: 'majorFactor',
headerTooltip: scaleT('tooltip.resetMajorFactor'),
headerComponent: options.headerComponent,
headerComponentParams: {
onReset: options.restoreMajorFactorColumnDefaults,
resetTitle: scaleT('tooltip.resetMajorFactor')
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => formatScaleEditableNumber(params)
},
{
headerName: scaleT('columns.workStageFactor'),
field: 'workStageFactor' as any,
colId: 'workStageFactor',
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => formatScaleEditableNumber(params)
},
{
headerName: scaleT('columns.workRatio'),
field: 'workRatio' as any,
colId: 'workRatio',
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: params => formatScaleEditableNumber(params, 2)
},
{
headerName: scaleT('columns.total'),
field: 'budgetFee' as any,
colId: 'budgetFeeTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
aggFunc: options.aggFunc,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? (params.data as any)?.budgetFee ?? null : options.getBudgetFee(params.data)),
valueFormatter: formatScaleReadonlyMoney
}
]
})
export const createScaleRemarkColumn = <TRow>() : ColDef<TRow> => ({
headerName: scaleT('columns.remark'),
field: 'remark' as any,
minWidth: 100,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return scaleT('clickToInput')
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}
})
export const createScaleAutoGroupColumn = <TRow>(options: {
totalLabel: string
idLabelMap: Map<string, string>
parseProjectIndexFromPathKey: (key: string) => number | null
}) : ColDef<TRow> => ({
headerName: scaleT('columns.number'),
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) {
return rowData.majorCode
}
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) {
return rowData.majorCode
}
const nodeId = String(params.value || '')
const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return scaleT('projectLabel', { index: projectIndex })
return options.idLabelMap.get(nodeId) || nodeId
}
})
export const createScaleCalculationFormulaColumnGroup = <TRow>(options: {
getCalculationBase?: (row: TRow | undefined) => string | null
getCalculationBaseValue?: (row: TRow | undefined) => number | null
getCalculationFormula?: (row: TRow | undefined) => string | null
getCalculationAmount?: (row: TRow | undefined) => number | null
isBaseEditable?: (row: TRow | undefined) => boolean
parseNumberOrNull: (value: any, options?: any) => any
}) : ColGroupDef<TRow> => ({
headerName: scaleT('columns.calculationGroup'),
marryChildren: true,
children: [
{
headerName: scaleT('columns.base'),
field: 'calculationBase' as any,
colId: 'calculationBase',
minWidth: 120,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && (options.isBaseEditable?.(params.data) ?? true),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && (options.isBaseEditable?.(params.data) ?? true)
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (options.isBaseEditable?.(params.data) ?? true) && (params.value == null || params.value === '')
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCalculationBase?.(params.data) ?? null),
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
if (params.node?.rowPinned) return ''
const value = params.value
return value == null || value === '' ? '' : String(value)
}
},
{
headerName: scaleT('columns.base2'),
field: 'calculationBaseValue' as any,
colId: 'calculationBaseValue',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCalculationBaseValue?.(params.data) ?? null),
valueFormatter: params => {
if (params.node?.rowPinned) return ''
const value = params.value
if (value == null) return ''
return typeof value === 'number' ? value.toLocaleString() : String(value)
}
},
{
headerName: scaleT('columns.formula'),
field: 'calculationFormula' as any,
colId: 'calculationFormula',
minWidth: 200,
flex: 1.5,
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
valueGetter: params => (params.node?.rowPinned ? null : options.getCalculationFormula?.(params.data) ?? null),
valueFormatter: params => {
if (params.node?.rowPinned) return ''
return params.value || ''
}
},
{
headerName: scaleT('columns.calculationAmount'),
field: 'calculationAmount' as any,
colId: 'calculationAmount',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCalculationAmount?.(params.data) ?? null),
valueFormatter: params => {
if (params.node?.rowPinned) return ''
const value = params.value
if (value == null) return ''
return typeof value === 'number' ? value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(value)
}
}
]
})
-164
View File
@@ -1,164 +0,0 @@
import { addNumbers, roundTo } from '@/lib/decimal'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit, type ScaleFeeSplitResult } from '@/lib/pricingScaleFee'
export type ScaleMode = 'cost' | 'area'
export interface ScaleBudgetCheckRow {
benchmarkBudgetBasicChecked?: boolean
benchmarkBudgetOptionalChecked?: boolean
}
export interface ScaleBudgetSourceRow extends ScaleBudgetCheckRow {
amount?: number | null
landArea?: number | null
majorFactor?: number | null
consultCategoryFactor?: number | null
workStageFactor?: number | null
workRatio?: number | null
}
export interface ScaleDetailComputedRow extends ScaleBudgetSourceRow {
benchmarkBudget?: number | null
benchmarkBudgetBasic?: number | null
benchmarkBudgetOptional?: number | null
basicFormula?: string | null
optionalFormula?: string | null
budgetFee?: number | null
budgetFeeBasic?: number | null
budgetFeeOptional?: number | null
}
export type CheckedScaleFeeSplitResult = Omit<ScaleFeeSplitResult, 'total'> & {
total: number | null
}
const getScaleValueByMode = (
row: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea'> | undefined,
mode: ScaleMode
) => (mode === 'cost' ? row?.amount : row?.landArea)
export const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
if (left == null && right == null) return true
if (left == null || right == null) return false
return roundTo(left, 6) === roundTo(right, 6)
}
export const isSameNullableText = (left: string | null | undefined, right: string | null | undefined) =>
String(left ?? '') === String(right ?? '')
export const isBenchmarkBudgetFullyUnchecked = (
row?: Pick<ScaleBudgetCheckRow, 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
) => row?.benchmarkBudgetBasicChecked === false && row?.benchmarkBudgetOptionalChecked === false
export const getBenchmarkBudgetRawSplitByRow = (
row: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea'> | undefined,
mode: ScaleMode
) => getBenchmarkBudgetSplitByScale(getScaleValueByMode(row, mode), mode)
export const getCheckedBenchmarkBudgetSplitByRow = (
row?: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea' | 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>,
mode: ScaleMode = 'cost'
): CheckedScaleFeeSplitResult | null => {
const split = getBenchmarkBudgetRawSplitByRow(row, mode)
if (!split) return null
const basic = row?.benchmarkBudgetBasicChecked === false ? 0 : split.basic
const optional = row?.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
return {
...split,
basic,
optional,
total: isBenchmarkBudgetFullyUnchecked(row) ? null : roundTo(addNumbers(basic, optional), 2)
}
}
export const getScaleBudgetFeeSplitByRow = (
row?: Pick<
ScaleBudgetSourceRow,
| 'amount'
| 'landArea'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'majorFactor'
| 'consultCategoryFactor'
| 'workStageFactor'
| 'workRatio'
>,
mode: ScaleMode = 'cost'
) => {
if (isBenchmarkBudgetFullyUnchecked(row)) return null
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByRow(row, mode)
if (!benchmarkBudgetSplit) return null
return getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
})
}
export const getScaleBudgetFeeByRow = (
row?: Pick<
ScaleBudgetSourceRow,
| 'amount'
| 'landArea'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'majorFactor'
| 'consultCategoryFactor'
| 'workStageFactor'
| 'workRatio'
>,
mode: ScaleMode = 'cost'
) => getScaleBudgetFeeSplitByRow(row, mode)?.total ?? null
export const recomputeScaleDetailRow = <TRow extends ScaleDetailComputedRow>(
row: TRow,
mode: ScaleMode
): TRow => {
const benchmarkBudgetRawSplit = getBenchmarkBudgetRawSplitByRow(row, mode)
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByRow(row, mode)
const budgetFeeSplit = getScaleBudgetFeeSplitByRow(row, mode)
return {
...row,
benchmarkBudget: benchmarkBudgetSplit?.total ?? null,
benchmarkBudgetBasic: benchmarkBudgetSplit?.basic ?? null,
benchmarkBudgetOptional: benchmarkBudgetSplit?.optional ?? null,
basicFormula: row.benchmarkBudgetBasicChecked === false ? null : (benchmarkBudgetRawSplit?.basicFormula ?? ''),
optionalFormula: row.benchmarkBudgetOptionalChecked === false ? null : (benchmarkBudgetRawSplit?.optionalFormula ?? ''),
budgetFee: budgetFeeSplit?.total ?? null,
budgetFeeBasic: budgetFeeSplit?.basic ?? null,
budgetFeeOptional: budgetFeeSplit?.optional ?? null
}
}
export const recomputeScaleDetailRowsInPlace = <TRow extends ScaleDetailComputedRow>(
rows: TRow[],
mode: ScaleMode
) => {
for (const row of rows) {
Object.assign(row, recomputeScaleDetailRow(row, mode))
}
}
export const isSameScaleDetailRow = (
left: ScaleDetailComputedRow,
right: ScaleDetailComputedRow,
mode: ScaleMode
) => {
const isSameScaleValue = mode === 'cost'
? isSameNullableNumber(left.amount, right.amount)
: isSameNullableNumber(left.landArea, right.landArea)
return isSameScaleValue
&& isSameNullableNumber(left.benchmarkBudget, right.benchmarkBudget)
&& isSameNullableNumber(left.benchmarkBudgetBasic, right.benchmarkBudgetBasic)
&& isSameNullableNumber(left.benchmarkBudgetOptional, right.benchmarkBudgetOptional)
&& isSameNullableText(left.basicFormula, right.basicFormula)
&& isSameNullableText(left.optionalFormula, right.optionalFormula)
&& isSameNullableNumber(left.budgetFee, right.budgetFee)
&& isSameNullableNumber(left.budgetFeeBasic, right.budgetFeeBasic)
&& isSameNullableNumber(left.budgetFeeOptional, right.budgetFeeOptional)
}
-120
View File
@@ -1,120 +0,0 @@
export interface ScaleDictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
export interface ScaleDictGroup {
id: string
code: string
name: string
children: ScaleDictLeaf[]
}
type MajorLite = {
code: string
name: string
hasCost?: boolean
hasArea?: boolean
}
export const buildScaleDetailDict = (
entries: Array<[string, MajorLite]>,
includeLeaf: (params: { id: string; item: MajorLite; hasCost: boolean; hasArea: boolean }) => boolean
): ScaleDictGroup[] => {
const groupMap = new Map<string, ScaleDictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
for (const [key, item] of entries) {
const code = item.code
const isGroup = !code.includes('-')
if (isGroup) {
if (!groupMap.has(code)) groupOrder.push(code)
groupMap.set(code, {
id: key,
code,
name: item.name,
children: []
})
continue
}
const parentCode = code.split('-')[0]
if (!groupMap.has(parentCode)) {
const parent = codeLookup.get(parentCode)
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: parent?.name || parentCode,
children: []
})
}
const hasCost = item.hasCost !== false
const hasArea = item.hasArea !== false
if (!includeLeaf({ id: key, item, hasCost, hasArea })) continue
groupMap.get(parentCode)!.children.push({
id: key,
code,
name: item.name,
hasCost,
hasArea
})
}
return groupOrder.map(code => groupMap.get(code)).filter((group): group is ScaleDictGroup => Boolean(group))
}
export const buildScaleIdLabelMap = (detailDict: ScaleDictGroup[]) => {
const idLabelMap = new Map<string, string>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
return idLabelMap
}
export const buildScaleRowsFromDict = <TRow>(options: {
detailDict: ScaleDictGroup[]
projectCount: number
activeIndustryCode: string
isMajorInIndustryScope: (groupId: string, industryCode: string) => boolean
buildScopedRowId: (projectIndex: number, majorId: string) => string
buildProjectGroupPathKey: (projectIndex: number) => string
isMutipleService: boolean
createRow: (params: {
projectIndex: number
group: ScaleDictGroup
child: ScaleDictLeaf
rowId: string
path: string[]
}) => TRow
}) => {
if (!options.activeIndustryCode) return [] as TRow[]
const rows: TRow[] = []
for (let projectIndex = 1; projectIndex <= options.projectCount; projectIndex++) {
for (const group of options.detailDict) {
if (options.activeIndustryCode && !options.isMajorInIndustryScope(group.id, options.activeIndustryCode)) continue
for (const child of group.children) {
const rowId = options.buildScopedRowId(projectIndex, child.id)
rows.push(options.createRow({
projectIndex,
group,
child,
rowId,
path: options.isMutipleService
? [options.buildProjectGroupPathKey(projectIndex), group.id, rowId]
: [group.id, rowId]
}))
}
}
}
return rows
}
-143
View File
@@ -1,143 +0,0 @@
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)
}
-234
View File
@@ -1,234 +0,0 @@
import { roundTo } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import type { GridApi } from 'ag-grid-community'
import { nextTick } from 'vue'
export type ScaleBudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
export type ScaleBudgetHeaderCheckState = 'all' | 'none' | 'partial'
type BudgetCheckRow = {
id: string
benchmarkBudgetBasicChecked: boolean
benchmarkBudgetOptionalChecked: boolean
benchmarkBudgetBasic?: number | null
benchmarkBudgetOptional?: number | null
}
export interface ScaleBudgetToggleHeaderParams {
displayName?: string
field: ScaleBudgetCheckField
getHeaderCheckState?: (field: ScaleBudgetCheckField) => ScaleBudgetHeaderCheckState
onToggleAll?: (field: ScaleBudgetCheckField, checked: boolean) => void
}
export class ScaleBudgetToggleHeader {
private params!: ScaleBudgetToggleHeaderParams
private eGui!: HTMLDivElement
private checkbox!: HTMLInputElement
private label!: HTMLSpanElement
init(params: ScaleBudgetToggleHeaderParams) {
this.params = params
const root = document.createElement('div')
root.style.display = 'inline-flex'
root.style.alignItems = 'center'
root.style.gap = '6px'
root.style.width = '100%'
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('mousedown', event => event.stopPropagation())
checkbox.addEventListener('change', event => {
event.stopPropagation()
this.params.onToggleAll?.(this.params.field, checkbox.checked)
})
const label = document.createElement('span')
label.textContent = String(params.displayName || '')
label.style.userSelect = 'none'
label.addEventListener('click', event => event.stopPropagation())
root.append(checkbox, label)
this.eGui = root
this.checkbox = checkbox
this.label = label
this.applyCheckState()
}
getGui() {
return this.eGui
}
refresh(params: ScaleBudgetToggleHeaderParams) {
this.params = params
this.label.textContent = String(params.displayName || '')
this.applyCheckState()
return true
}
destroy() {
// noop
}
private applyCheckState() {
const state = this.params.getHeaderCheckState?.(this.params.field) || 'none'
this.checkbox.indeterminate = state === 'partial'
this.checkbox.checked = state === 'all'
}
}
export const formatScaleEditableNumber = (params: any, precision = 3, emptyText = '请输入') => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return emptyText
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, precision)
}
export const formatScaleEditableConditionalNumber = (
params: any,
options: { enabled: boolean; precision?: number; emptyText?: string }
) => {
if (!params.node?.group && !params.node?.rowPinned && !options.enabled) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return options.emptyText ?? '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, options.precision ?? 3)
}
export const formatScaleReadonlyMoney = (params: any) => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(roundTo(params.value, 3), 3)
}
export const updateScaleBudgetCheckState = <TRow extends BudgetCheckRow>(
rows: TRow[],
rowId: string,
checkField: ScaleBudgetCheckField,
checked: boolean
) => {
for (const row of rows) {
if (row.id !== rowId) continue
if (checkField === 'benchmarkBudgetBasicChecked') {
row.benchmarkBudgetBasicChecked = checked
row.benchmarkBudgetBasic = checked ? row.benchmarkBudgetBasic : 0
return
}
row.benchmarkBudgetOptionalChecked = checked
row.benchmarkBudgetOptional = checked ? row.benchmarkBudgetOptional : 0
return
}
}
export const createScaleBudgetCellRendererWithCheck = <TRow extends Record<string, any>>(
checkField: ScaleBudgetCheckField,
options: {
formatValue: (params: any) => string
onToggle: (row: TRow, checked: boolean) => void
}
) => (params: any) => {
const valueText = options.formatValue(params)
const hasValue = params.value != null && params.value !== ''
if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
return valueText
}
const wrapper = document.createElement('div')
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'space-between'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
wrapper.addEventListener('pointerdown', event => event.stopPropagation())
wrapper.addEventListener('mousedown', event => event.stopPropagation())
wrapper.addEventListener('click', event => event.stopPropagation())
wrapper.addEventListener('dblclick', event => event.stopPropagation())
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('pointerdown', event => event.stopPropagation())
checkbox.addEventListener('mousedown', event => event.stopPropagation())
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', event => {
event.stopPropagation()
const targetRow = params.data as TRow | undefined
if (!targetRow) return
options.onToggle(targetRow, checkbox.checked)
void nextTick(() => {
params.api?.redrawRows?.({
rowNodes: params.node ? [params.node] : undefined
})
params.api?.refreshCells?.({
rowNodes: params.node ? [params.node] : undefined,
force: true
})
})
})
const valueSpan = document.createElement('span')
valueSpan.textContent = valueText
valueSpan.addEventListener('pointerdown', event => event.stopPropagation())
valueSpan.addEventListener('mousedown', event => event.stopPropagation())
valueSpan.addEventListener('click', event => event.stopPropagation())
wrapper.append(checkbox, valueSpan)
return wrapper
}
export const createScaleBudgetCellRendererToggleFactory = <TRow extends BudgetCheckRow>(
getRows: () => TRow[],
onAfterToggle: () => void
) => (checkField: ScaleBudgetCheckField) =>
createScaleBudgetCellRendererWithCheck<TRow>(checkField, {
formatValue: formatScaleReadonlyMoney,
onToggle: (targetRow: TRow, checked: boolean) => {
updateScaleBudgetCheckState(getRows(), targetRow.id, checkField, checked)
onAfterToggle()
}
})
export const getScaleMergeColSpanBeforeTotal = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned) return 1
const displayedColumns = params.api?.getAllDisplayedColumns?.()
if (!Array.isArray(displayedColumns) || !params.column) return 1
const currentIndex = displayedColumns.findIndex((column: any) => column.getColId() === params.column.getColId())
const totalIndex = displayedColumns.findIndex((column: any) => column.getColId() === 'budgetFeeTotal')
if (currentIndex < 0 || totalIndex <= currentIndex) return 1
return totalIndex - currentIndex
}
export const refreshScaleGridAfterColumnReset = async <TRow>(gridApi: GridApi<TRow> | null | undefined) => {
await nextTick()
gridApi?.refreshHeader()
gridApi?.refreshCells({ force: true })
}
export const restoreScaleColumnDefaults = async <TRow>(options: {
gridApi: GridApi<TRow> | null | undefined
rows: TRow[]
getCurrentValue: (row: TRow) => number | null | undefined
getNextValue: (row: TRow) => number | null | undefined
isSameValue: (left: number | null | undefined, right: number | null | undefined) => boolean
applyValue: (row: TRow, nextValue: number | null) => void
afterApply: () => Promise<void>
}) => {
options.gridApi?.stopEditing()
let changed = false
for (const row of options.rows) {
const nextValue = options.getNextValue(row) ?? null
if (options.isSameValue(options.getCurrentValue(row), nextValue)) continue
options.applyValue(row, nextValue)
changed = true
}
if (!changed) return false
await options.afterApply()
await refreshScaleGridAfterColumnReset(options.gridApi)
return true
}
-168
View File
@@ -1,168 +0,0 @@
import { getMajorIdAliasMap } from '@/sql'
const majorIdAliasMap = getMajorIdAliasMap()
type ScaleLinkRow = {
id?: unknown
projectIndex?: unknown
majorDictId?: unknown
path?: unknown
}
type ScaleValueLinkRow = ScaleLinkRow & {
amount?: unknown
landArea?: unknown
isGroupRow?: unknown
}
const normalizeProjectCount = (value: unknown) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
return Math.max(1, Math.floor(parsed))
}
export const parseProjectIndexFromPathKey = (value: string) => {
const match = /^project-(\d+)$/.exec(value)
if (!match) return null
return normalizeProjectCount(Number(match[1]))
}
export const parseScopedRowId = (id: unknown) => {
const rawId = String(id || '')
const match = /^(\d+)::(.+)$/.exec(rawId)
if (!match) {
return {
projectIndex: 1,
majorDictId: rawId
}
}
return {
projectIndex: normalizeProjectCount(Number(match[1])),
majorDictId: String(match[2] || '').trim()
}
}
export const resolveScaleRowProjectIndex = (row: ScaleLinkRow | undefined) => {
if (!row) return 1
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
return normalizeProjectCount(row.projectIndex)
}
if (Array.isArray(row.path) && row.path.length > 0) {
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
if (projectIndexFromPath != null) return projectIndexFromPath
}
return parseScopedRowId(row.id).projectIndex
}
export const resolveScaleRowMajorDictId = (row: ScaleLinkRow | undefined) => {
if (!row) return ''
const direct = String(row.majorDictId || '').trim()
if (direct) return majorIdAliasMap.get(direct) || direct
const parsed = parseScopedRowId(row.id).majorDictId
return majorIdAliasMap.get(parsed) || parsed
}
export const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
export const buildContractScaleMap = <TRow extends ScaleLinkRow>(rows: TRow[] | undefined) => {
const map = new Map<string, TRow>()
for (const row of rows || []) {
const majorDictId = resolveScaleRowMajorDictId(row)
if (!majorDictId) continue
const projectIndex = resolveScaleRowProjectIndex(row)
map.set(makeProjectMajorKey(projectIndex, majorDictId), row)
}
return map
}
export const buildContractScaleIdMap = <TRow extends ScaleLinkRow>(rows: TRow[] | undefined) => {
const map = new Map<string, TRow>()
for (const row of rows || []) {
const rowId = String(row?.id || '').trim()
if (!rowId) continue
map.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId)
if (aliasId && !map.has(aliasId)) {
map.set(aliasId, row)
}
}
return map
}
export const buildContractScaleProjectTotals = <TRow extends ScaleValueLinkRow>(
rows: TRow[] | undefined,
totalAmount?: unknown
) => {
const map = new Map<number, { amount: number | null; landArea: number | null }>()
const normalizedTotalAmount =
typeof totalAmount === 'number' && Number.isFinite(totalAmount)
? totalAmount
: null
if (normalizedTotalAmount != null) {
map.set(1, {
amount: normalizedTotalAmount,
landArea: null
})
}
for (const row of rows || []) {
if (row?.isGroupRow !== true) continue
const projectIndex = resolveScaleRowProjectIndex(row)
const current = map.get(projectIndex) || { amount: null, landArea: null }
const nextAmount =
current.amount != null
? current.amount
: (typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null)
const nextLandArea =
current.landArea != null
? current.landArea
: (typeof row?.landArea === 'number' && Number.isFinite(row.landArea) ? row.landArea : null)
map.set(projectIndex, {
amount: nextAmount,
landArea: nextLandArea
})
}
return map
}
export const getContractScaleProjectTotalsByRow = (
row: ScaleLinkRow | undefined,
totalsMap: Map<number, { amount: number | null; landArea: number | null }>
) => {
const projectIndex = resolveScaleRowProjectIndex(row)
return totalsMap.get(projectIndex)
|| (projectIndex > 1 ? totalsMap.get(1) : undefined)
|| { amount: null, landArea: null }
}
export const getContractScaleRowByMajor = <TRow extends ScaleLinkRow>(
row: ScaleLinkRow,
map: Map<string, TRow>,
idMap?: Map<string, TRow>
) => {
const directRowId = String(row.id || '').trim()
if (directRowId && idMap?.has(directRowId)) return idMap.get(directRowId)
const parsedMajorId = parseScopedRowId(row.id).majorDictId
if (parsedMajorId && idMap?.has(parsedMajorId)) return idMap.get(parsedMajorId)
const majorDictId = resolveScaleRowMajorDictId(row)
if (!majorDictId) return undefined
const projectIndex = resolveScaleRowProjectIndex(row)
return map.get(makeProjectMajorKey(projectIndex, majorDictId))
|| (projectIndex > 1 ? map.get(makeProjectMajorKey(1, majorDictId)) : undefined)
}
export const normalizeChangedScaleRowIds = (rowIds?: Array<string | number>) =>
new Set(
(rowIds || [])
.map(id => {
const rawId = String(id || '').trim()
if (!rawId) return rawId
const parsedMajorId = parseScopedRowId(rawId).majorDictId
return majorIdAliasMap.get(parsedMajorId) || parsedMajorId
})
.filter(Boolean)
)
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more