Compare commits

..
13 Commits
Author SHA1 Message Date
zwgczx a2ae47fc01 Add 929 project changes 2026-06-25 09:28:28 +08:00
zwgczx 50e71f786d 1 2026-06-25 09:20:03 +08:00
ForeverSmiYng 8cc542110b 修复报表导出功能中无项目规模时的bug 2026-05-29 10:29:05 +08:00
zwgczx 66bfe8b46e 修复传值少了十位数 2026-05-28 09:39:55 +08:00
zwgczx 9508f1390e 翻译问题 2026-04-22 09:40:08 +08:00
zwgczx 32f7469e84 修复数据联动ug 2026-04-14 12:20:24 +08:00
zwgczx ce682c3679 修复bug 2026-04-14 10:44:41 +08:00
zwgczx aa67a66047 免责确认 2026-04-13 14:40:46 +08:00
zwgczx aed6fe2bfa 修复数据导出不正确 2026-04-13 11:24:47 +08:00
zwgczx 99684c04f2 20260410罗总新需求修改 2026-04-10 16:24:44 +08:00
zwgczx 860bd0161b home修改 2026-04-09 09:59:38 +08:00
zwgczx bbc9ad13fe 修改界面 2026-04-09 09:15:49 +08:00
zwgczx 6fdf336b9e 1 2026-04-07 16:27:49 +08:00
137 changed files with 30862 additions and 11648 deletions

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
{
"permissions": {
"allow": [
"Skill(update-config)",
"Bash(mv views/Ht.vue views/htCard.vue views/htInfo.vue views/HtAdditionalWorkFee.vue views/HtConsultCategoryFactor.vue views/HtFeeRateMethodForm.vue views/HtMajorFactor.vue views/HtReserveFee.vue ht/)",
"Bash(mv views/xmCard.vue views/xmInfo.vue views/info.vue views/XmMajorFactor.vue views/XmConsultCategoryFactor.vue xm/)",
"Bash(mv views/pricingView/HourlyPricingPane.vue views/pricingView/InvestmentScalePricingPane.vue views/pricingView/LandScalePricingPane.vue views/pricingView/WorkloadPricingPane.vue pricing/)",
"Bash(mv common/HourlyFeeGrid.vue common/HtFeeGrid.vue common/HtFeeMethodGrid.vue common/MethodUnavailableNotice.vue common/XmFactorGrid.vue common/xmCommonAgGrid.vue views/ServiceCheckboxSelector.vue views/WorkContentGrid.vue shared/)",
"Bash(rmdir views/pricingView common)",
"Bash(cd:*)",
"Bash(bun run:*)",
"Bash(grep:*)",
"mcp__context7__resolve-library-id",
"mcp__context7__query-docs",
"mcp__ag-mcp__detect_version",
"WebSearch",
"WebFetch(domain:reka-ui.com)"
]
}
}
+7
View File
@@ -0,0 +1,7 @@
**
!dist/
!dist/**
!Dockerfile.dist
!docker/
!docker/dist-server/
!docker/dist-server/**
-115
View File
@@ -1,115 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# 任何项目都务必遵守的规则(极其重要!!!)
## Communication
- 永远使用简体中文进行思考和对话
## Documentation
- 编写 .md 文档时,也要用中文
- 正式文档写到项目的 docs/ 目录下
- 用于讨论和评审的计划、方案等文档,写到项目的 discuss/ 目录下
## Code Architecture
- 编写代码的硬性指标,包括以下原则:
(1)对于 Python、JavaScript、TypeScript 等动态语言,尽可能确保每个代码文件不要超过 500 行
(3)每层文件夹中的文件,尽可能不超过 8 个。如有超过,需要规划为多层子文件夹
- 除了硬性指标以外,还需要时刻关注优雅的架构设计,避免出现以下可能侵蚀我们代码质量的「坏味道」:
(1)僵化 (Rigidity): 系统难以变更,任何微小的改动都会引发一连串的连锁修改。
(2)冗余 (Redundancy): 同样的代码逻辑在多处重复出现,导致维护困难且容易产生不一致。
(3)循环依赖 (Circular Dependency): 两个或多个模块互相纠缠,形成无法解耦的“死结”,导致难以测试与复用。
(4)脆弱性 (Fragility): 对代码一处的修改,导致了系统中其他看似无关部分功能的意外损坏。
(5)晦涩性 (Obscurity): 代码意图不明,结构混乱,导致阅读者难以理解其功能和设计。
(6)数据泥团 (Data Clump): 多个数据项总是一起出现在不同方法的参数中,暗示着它们应该被组合成一个独立的对象。
(7)不必要的复杂性 (Needless Complexity): 用“杀牛刀”去解决“杀鸡”的问题,过度设计使系统变得臃肿且难以理解。
- 【非常重要!!】无论是你自己编写代码,还是阅读或审核他人代码时,都要严格遵守上述硬性指标,以及时刻关注优雅的架构设计。
- 【非常重要!!】无论何时,一旦你识别出那些可能侵蚀我们代码质量的「坏味道」,都应当立即询问用户是否需要优化,并给出合理的优化建议。
## Commands
- **Dev server**: `bun run dev` (uses `bunx --bun vite`)
- **Build**: `bun run build` (type-checks then bundles via Vite)
- **Type check only**: `bun run type-check`
- **Preview build**: `bun run preview`
No test runner is configured. Use Playwright for UI automation when needed (see AGENTS.md).
## Architecture Overview
This is a **browser-only Vue 3 SPA** — no backend, no SSR. All data is persisted client-side via IndexedDB (through localforage). The domain is **交通建设项目工程造价咨询** (transport infrastructure cost-consulting fee calculation for road, railway, and waterway projects).
### App Entry & Routing
There is no Vue Router. Navigation is tab-based, managed entirely by `useTabStore` (`src/pinia/tab.ts`). `src/App.vue` shows either `HomeEntryView` (first-launch onboarding) or the main `Tab` layout depending on `tabStore.hasCompletedSetup`.
The `Tab` layout (`src/layout/tab.vue`) renders the active tab's component by name using `defineAsyncComponent` with a map of component names → import paths.
### Workspace Modes
Defined in `src/lib/workspace.ts`. Three modes: `home`, `project`, `quick`. The current mode is persisted to `localStorage` under key `jgjs-workspace-mode-v1`. Two fixed/protected tab IDs exist: `ProjectCalcView` and `QuickCalcView` (and the quick-contract tab), which cannot be closed.
### State & Persistence
- **Pinia** is used for all state management with `pinia-plugin-persistedstate`.
- The persistence plugin is customized at `src/pinia/Plugin/indexdb` — it stores pinia state in IndexedDB (not localStorage), using localforage with `mode: 'multiple'` (each store in its own IndexedDB store named `pinia`).
- `useTabStore` (`src/pinia/tab.ts`): tab list, active tab, setup flag. Persisted.
- `useZxFwPricingStore` (`src/pinia/zxFwPricing.ts`): the core domain store. Manages contract-level 咨询服务 (consulting service) pricing state, per-service pricing method states, and contract extra-fee states. Uses a generic key/value layer (`getKeyState`/`setKeyState`/`loadKeyState`) backed by `useKvStore`.
- `useKvStore` (`src/pinia/kv.ts`): raw key-value access to IndexedDB via localforage.
### Data Layer (`src/sql.ts`)
Despite the name, there is no SQL database. `src/sql.ts` is a large static data file containing:
- `industryTypeList`: road / railway / waterway
- `majorList`: engineering specialties (E1–E4 codes) with coefficient ranges
- `serviceList`: consulting service types (D1–D5 codes) with pricing method flags
- `additionalWorkList`: extra work item definitions
- `exportFile`: Excel export logic using ExcelJS
All fee calculation formulas and coefficient tables live here.
### Lib Utilities (`src/lib/`)
- `decimal.ts` / `number.ts` / `numberFormat.ts`: safe arithmetic using `decimal.js`, thousand-separator formatting
- `zwArchive.ts`: AES-GCM encryption/decryption for `.zw` save files (import/export format). The key is derived from a fixed seed via SHA-256. File magic bytes: `JGJSZW`.
- `workspace.ts`: workspace mode constants and storage helpers
- `diyAgGridOptions.ts`: shared AG Grid configuration defaults
- `pricingScaleFee.ts` / `pricingMethodTotals.ts`: fee calculation helpers
- `projectWorkspace.ts` / `xmFactorDefaults.ts`: project-level workspace helpers
- `zxFwPricingSync.ts`: syncs pricing store state to/from IndexedDB on demand
### Views (`src/components/views/`)
Key views:
- `HomeEntryView.vue`: onboarding screen shown on first launch; dispatches `home-import-selected` DOM event on completion
- `ProjectWorkspaceView.vue` / `xmCard.vue`: project card workspace (项目卡片)
- `QuickCalcView.vue`: quick calculation mode
- `ZxFwView.vue`: 咨询服务 (consulting services) grid — the primary fee input view
- `Ht.vue` / `htCard.vue`: contract (合同) views
- `HtFeeMethodTypeLineView.vue`: per-service fee method line (rate/hourly/quantity-unit-price)
- `WorkContentGrid.vue`: AG Grid-based work content table
### AG Grid
AG Grid Enterprise is used throughout. Modules are registered once globally in `src/main.ts`. A license key is set there. Shared grid option defaults live in `src/lib/diyAgGridOptions.ts`.
### UI Components
Reka UI (headless component library) + Tailwind CSS v4 + `lucide-vue-next` icons + `@iconify/vue`. Shared UI primitives are in `src/components/ui/`. Tailwind is integrated as a Vite plugin (`@tailwindcss/vite`).
### Build
Vite 8 with rolldown. Output goes to `dist/` with `base: './'` (relative paths — important for local file:// deployment). Code-splitting separates `ag-grid`, `vue/pinia`, and `reka-ui` into distinct vendor chunks.
### Code Conventions
- All code comments are in Chinese.
- Composition API (`<script setup>`) everywhere.
- No Vue Router — use `useTabStore.openTab()` to navigate.
- Numeric calculations always go through `src/lib/decimal.ts` helpers to avoid floating-point errors.
- Storage keys follow patterns like `zxFW-{contractId}`, `tzGMF-{contractId}-{serviceId}`, `htExtraFee-{contractId}-{feeType}`.
+18
View File
@@ -0,0 +1,18 @@
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY docker/dist-server/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dist-server .
FROM scratch
WORKDIR /www
COPY --from=builder /out/dist-server /dist-server
COPY dist/ /www/
EXPOSE 80
ENTRYPOINT ["/dist-server"]
-5
View File
@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
View File
Whitespace-only changes.
+33 -2
View File
@@ -9,24 +9,29 @@
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@internationalized/date": "^3.12.0", "@internationalized/date": "^3.12.0",
"@internationalized/number": "^3.6.5", "@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", "decimal.js": "^10.6.0",
"docxtemplater": "^3.68.5",
"exceljs": "^4.4.0", "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", "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": {
@@ -78,6 +83,14 @@
"@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=="],
@@ -90,6 +103,10 @@
"@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=="],
@@ -208,6 +225,8 @@
"@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=="],
@@ -288,6 +307,8 @@
"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=="], "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=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
@@ -304,6 +325,8 @@
"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=="], "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-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
@@ -422,7 +445,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "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=="],
@@ -438,6 +461,8 @@
"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=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
@@ -506,6 +531,8 @@
"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=="],
@@ -540,6 +567,8 @@
"jszip/lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], "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=="], "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=="], "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=="],
@@ -552,6 +581,8 @@
"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=="], "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=="], "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=="], "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
module dist-server
go 1.24
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
const (
defaultPort = "80"
webRoot = "/www"
indexName = "index.html"
)
func main() {
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = defaultPort
}
server := &http.Server{
Addr: ":" + port,
Handler: http.HandlerFunc(serve),
}
log.Printf("dist server listening on :%s", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
func serve(w http.ResponseWriter, r *http.Request) {
requestPath := strings.TrimPrefix(filepath.Clean("/"+r.URL.Path), "/")
if requestPath == "." || requestPath == "" {
serveIndex(w, r)
return
}
fullPath := filepath.Join(webRoot, filepath.FromSlash(requestPath))
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
applyAssetCacheHeaders(w, requestPath)
http.ServeFile(w, r, fullPath)
return
}
serveIndex(w, r)
}
func serveIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
http.ServeFile(w, r, filepath.Join(webRoot, indexName))
}
func applyAssetCacheHeaders(w http.ResponseWriter, requestPath string) {
if strings.EqualFold(requestPath, indexName) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
return
}
if strings.HasPrefix(requestPath, "static/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

+3 -3
View File
@@ -4,11 +4,11 @@
<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="/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> <!-- <script>
//上线前添加访问版本号,强制刷新缓存 //上线前添加访问版本号,强制刷新缓存
;(() => { ;(() => {
const makeVisitVersion = () => { const makeVisitVersion = () => {
@@ -28,7 +28,7 @@
window.history.replaceState(null, '', nextUrl) window.history.replaceState(null, '', nextUrl)
} }
})() })()
</script> </script> -->
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+2240
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bunx --bun vite", "dev": "bunx --bun vite",
"build": "bunx vue-tsc -b && bunx --bun vite build", "build": " 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"
}, },
@@ -14,24 +14,29 @@
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@internationalized/date": "^3.12.0", "@internationalized/date": "^3.12.0",
"@internationalized/number": "^3.6.5", "@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", "decimal.js": "^10.6.0",
"docxtemplater": "^3.68.5",
"exceljs": "^4.4.0", "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", "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.

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -0,0 +1 @@
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
-367
View File
@@ -1,367 +0,0 @@
let data1 = {
name: 'test001',
writer: '张三',// 编制人
reviewer: '李四',// 复核人
company: '测试公司',// 公司名称
date: '2021-09-24',// 编制日期
industry: 0,// 0为公路工程,1为铁路工程,2为水运工程
fee: 10000,
scaleCost: 100000,// scale的cost的合计数
overview: '项目概况××××',
desc: ' 在履行造价咨询服务时,宜根据咨询服务质量情况分级确定相应的处罚金额。其中考评得分在大于及等于85和小于90分时,处罚金额为预算费用的10%;其中考评得分在大于及等于80和小于85分时,处罚金额为预算费用的20%;其中考评得分在大于及等于75和小于80分时,处罚金额为预算费用的30%;其中考评得分在大于及等于70和小于75分时,处罚金额为预算费用的40%;其中考评得分小于70分时,处罚金额为预算费用的50%以上。',
scale: [// 规模信息
{
major: 0,
cost: 100000,
area: 200,
},
{
major: 1,
cost: 100000,
area: 200,
},
],
serviceCoes: [// 项目咨询分类系数
{
serviceid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
serviceid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
majorCoes: [// 项目工程专业系数
{
majorid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
majorid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
contracts: [// 合同段信息
{
name: 'A合同段',
serviceFee: 100000,
addtionalFee: 0,
reserveFee: 0,
fee: 10000,
quality: '造价咨询服务的综合评价应达到“较好”或综合评分90分',
duration: '自xxxx年xx月开始至xxxx年xx月结束,服务周期xx个月。',
scale: [
{
major: 0,
cost: 100000,
area: 200,
},
{
major: 1,
cost: 100000,
area: 200,
},
],
serviceCoes: [// 合同段咨询分类系数
{
serviceid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
serviceid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
majorCoes: [// 合同段工程专业系数
{
majorid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
majorid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
services: [
{
id: 0,
fee: 100000,
finalFee: 100000,
process: 0,// 工作环节,0为编制,1为审核
method1: { // 投资规模法
cost: 100000,
basicFee: 200,
basicFee_basic: 200,
basicFee_optional: 0,
fee: 250000,
proAmount: 3,
det: [
{
proNum: 1,
major: 0,
cost: 100000,
basicFee: 200,
basicFormula: '856,000+(1,000,000,000-500,000,000)×1‰',
basicFee_basic: 200,
optionalFormula: '171,200+(1,000,000,000-500,000,000)×0.2‰',
basicFee_optional: 0,
serviceCoe: 1.1,
majorCoe: 1.2,
processCoe: 1,// 工作环节系数(编审系数)
proportion: 0.5,// 工作占比
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method2: { // 用地规模法
area: 1200,
basicFee: 200,
basicFee_basic: 200,
basicFee_optional: 0,
fee: 250000,
proAmount: 3,
det: [
{
proNum: 1,
major: 0,
area: 1200,
basicFee: 200,
basicFormula: '106,000+(1,200-1,000)×60',
basicFee_basic: 200,
optionalFormula: '21,200+(1,200-1,000)×12',
basicFee_optional: 0,
serviceCoe: 1.1,
majorCoe: 1.2,
processCoe: 1,// 工作环节系数(编审系数)
proportion: 0.5,// 工作占比
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method3: { // 工作量法
basicFee: 200,
fee: 250000,
det: [
{
task: 0,
price: 100000,
amount: 10,
basicFee: 200,
serviceCoe: 1.1,
fee: 100000,
remark: '',// 用户输入的说明
},
{
task: 1,
price: 100000,
amount: 10,
basicFee: 200,
serviceCoe: 1.1,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method4: { // 工时法
person_num: 10,
work_day: 10,
fee: 250000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
tasks: [{ serviceid: 0, process: 0, text: ['abc', 'efg'] }, { serviceid: 1, process: 0, text: ['abc', 'efg'] }],// 工作内容
},
],
addtional: {// 附加工作费
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
name: '附加工作',
fee: 10000,
det: [
{
id: 0,
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
name: '人员驻场服务及其他附加工作',
fee: 10000,
m4: {
person_num: 10,
work_day: 3,
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
m5: {
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
},
{
id: 1,
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' }] },
name: '咨询服务协调工作',
fee: 10000,
m0: {
coe: 0.03,
fee: 10000,
},
m4: {
person_num: 10,
work_day: 3,
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
m5: {
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
},
]
},
reserve: {// 预备费
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }] },
name: '预备费',
fee: 10000,
m0: {
coe: 0.03,
fee: 10000,
},
m4: {
person_num: 10,
work_day: 3,
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
m5: {
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
}
},
},
],
};
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
param(
[string]$ImageName = "jgjs2026-dist",
[string]$Tag = "latest"
)
$ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $PSScriptRoot
$distPath = Join-Path $projectRoot "dist"
$dockerfilePath = Join-Path $projectRoot "Dockerfile.dist"
$serverSourcePath = Join-Path $projectRoot "docker\\dist-server"
$buildContext = Join-Path ([System.IO.Path]::GetTempPath()) ("jgjs2026-dist-docker-" + [System.Guid]::NewGuid().ToString("N"))
if (-not (Test-Path $distPath)) {
throw "dist directory not found. Run npm run build first."
}
if (-not (Test-Path $dockerfilePath)) {
throw "Dockerfile.dist not found."
}
if (-not (Test-Path $serverSourcePath)) {
throw "docker/dist-server not found."
}
New-Item -ItemType Directory -Path $buildContext | Out-Null
New-Item -ItemType Directory -Path (Join-Path $buildContext "docker") | Out-Null
try {
Copy-Item $dockerfilePath (Join-Path $buildContext "Dockerfile.dist")
Copy-Item $serverSourcePath (Join-Path $buildContext "docker\\dist-server") -Recurse
Copy-Item $distPath (Join-Path $buildContext "dist") -Recurse
Write-Host "Building Docker image ${ImageName}:${Tag} from minimal dist context..."
docker build -f (Join-Path $buildContext "Dockerfile.dist") -t "${ImageName}:${Tag}" $buildContext
}
finally {
if (Test-Path $buildContext) {
Remove-Item $buildContext -Recurse -Force
}
}
Write-Host "Done."
Write-Host "Run with:"
Write-Host "docker run --rm -p 8080:80 ${ImageName}:${Tag}"
+295 -2
View File
@@ -1,12 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import HomeEntryView from '@/components/views/HomeEntryView.vue' 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 { 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 tabStore = useTabStore()
const { t } = useI18n()
const isReady = ref(false) 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 showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
@@ -14,17 +45,279 @@ const handleImportComplete = () => {
tabStore.hasCompletedSetup = true 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(() => { 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('home-import-selected', handleImportComplete)
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted)
stopResetAllListener = listenResetAll(handleResetAll)
waitForHydration('tabs').then(() => { 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 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"> <template v-if="isReady">
<div
v-if="lockConflict"
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
>
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">{{ t('app.projectConflict.title') }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-600">
{{ t('app.projectConflict.desc', { name: currentProjectName }) }}
</p>
<p class="mt-2 text-xs text-slate-500">
{{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
</p>
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
<button
v-for="project in conflictProjectList"
:key="project.id"
type="button"
class="flex w-full items-center justify-between rounded-md border border-transparent bg-white px-3 py-2 text-left text-sm transition"
:class="isConflictProjectOpen(project.id) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-200 hover:bg-slate-100'"
:disabled="isConflictProjectOpen(project.id)"
@click="openProjectInNewTab(project.id)"
>
<span class="font-medium text-slate-700">
{{ project.name }}
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">{{ t('app.projectConflict.opened') }}</span>
</span>
<span class="text-xs text-slate-500">{{ t('app.projectConflict.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</span>
</button>
</div>
<div class="mt-4 flex items-center gap-2">
<button
type="button"
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100"
@click="createProjectAndOpen"
>
{{ t('app.projectConflict.createAndOpen') }}
</button>
<button
type="button"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
:class="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" /> <HomeEntryView v-if="showHomeEntry" />
<Tab v-else /> <Tab v-else />
</template> </template>
</template>
</template> </template>
-102
View File
@@ -1,102 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface HtBaseInfoState {
quality: string
duration:
string
}
const DEFAULT_QUALITY = '造价咨询服务的综合评价应达到"较好"或综合评分90分'
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())
quality.value = typeof data?.quality === 'string' &&
data.quality ? data.quality : DEFAULT_QUALITY
duration.value = typeof data?.duration === 'string' ? data.duration :
''
const payload: HtBaseInfoState = { quality: quality.value, duration: duration.value }
lastSavedSnapshot.value = JSON.stringify(payload)
}
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">基础信息</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">质量要求</div>
<textarea
v-model="quality"
rows="3"
placeholder="请输入质量要求"
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">工期要求</div>
<textarea
v-model="duration"
rows="3"
placeholder="请输入工期要求"
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>
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,562 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } 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 { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
interface DetailRow {
id: string
taskCode: string
taskName: string
unit: string
conversion: number | null
workload: number | null
basicFee: number | null
budgetBase: string
budgetReferenceUnitPrice: string
budgetAdoptedUnitPrice: number | null
consultCategoryFactor: number | null
serviceFee: number | null
remark: string
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string,
serviceId: string | number
}>()
const zxFwPricingStore = useZxFwPricingStore()
const 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 getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
factorDefaultsLoaded = true
}
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const getMethodState = () =>
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
const detailRows = computed<DetailRow[]>({
get: () => {
const rows = getMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'workload', {
detailRows: rows
})
}
})
type taskLite = {
serviceID: number
code?: string
ref?: string
name: string
basicParam: string
unit: string
conversion: number | null
maxPrice: number | null
minPrice: number | null
defPrice: number | null
desc: string | null
}
const formatTaskReferenceUnitPrice = (task: taskLite) => {
const unit = task.unit || ''
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
const hasMax = typeof task.maxPrice === 'number' && Number.isFinite(task.maxPrice)
if (hasMin && hasMax) return `${task.minPrice}${unit}-${task.maxPrice}${unit}`
if (hasMin) return `${task.minPrice}${unit}`
if (hasMax) return `${task.maxPrice}${unit}`
return ''
}
const getSourceTaskIds = () => {
const currentServiceId = Number(props.serviceId)
return Object.entries(taskList as Record<string, taskLite>)
.filter(([, task]) => Number(task.serviceID) === currentServiceId)
.map(([key]) => Number(key))
.filter(Number.isFinite)
.sort((a, b) => a - b)
}
const isWorkloadMethodApplicable = computed(() => getSourceTaskIds().length > 0)
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
const sourceTaskIds = getSourceTaskIds()
for (const [order, taskId] of sourceTaskIds.entries()) {
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
const taskCode = task?.code || task?.ref || ''
if (!taskCode || !task?.name) continue
const rowId = `task-${taskId}-${order}`
rows.push({
id: rowId,
taskCode,
taskName: task.name,
unit: task.unit || '',
conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null,
workload: null,
basicFee: null,
budgetBase: task.basicParam || '',
budgetReferenceUnitPrice: formatTaskReferenceUnitPrice(task),
budgetAdoptedUnitPrice:
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
serviceFee: null,
remark: task.desc|| '',
path: [rowId]
})
}
return rows
}
const isNoTaskRow = (row: DetailRow | undefined) => row?.id?.startsWith('task-none-') ?? false
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
workload: typeof fromDb.workload === 'number' ? fromDb.workload : null,
basicFee: typeof fromDb.basicFee === 'number' ? fromDb.basicFee : null,
budgetAdoptedUnitPrice:
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const parseSanitizedNumberOrNull = (value: unknown) =>
parseNumberOrNull(value, { sanitize: true, precision: 3 })
const parseSanitizedAdoptedPriceOrNull = (value: unknown) =>
parseNumberOrNull(value, { sanitize: true, precision: 6 })
const calcBasicFee = (row: DetailRow | undefined) => {
if (!row || isNoTaskRow(row)) return null
const price = row.budgetAdoptedUnitPrice
const conversion = row.conversion
const workload = row.workload
if (
typeof price !== 'number' ||
!Number.isFinite(price) ||
typeof conversion !== 'number' ||
!Number.isFinite(conversion) ||
typeof workload !== 'number' ||
!Number.isFinite(workload)
) {
return null
}
return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
}
const calcServiceFee = (row: DetailRow | undefined) => {
if (!row || isNoTaskRow(row)) return null
const factor = row.consultCategoryFactor
const basicFee = calcBasicFee(row)
if (
basicFee == null ||
typeof factor !== 'number' ||
!Number.isFinite(factor)
) {
return null
}
return roundTo(toDecimal(basicFee).mul(factor), 2)
}
const formatEditableNumber = (params: any) => {
if (isNoTaskRow(params.data)) return '无'
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
const spanRowsByTaskName = (params: any) => {
const rowA = params?.nodeA?.data as DetailRow | undefined
const rowB = params?.nodeB?.data as DetailRow | undefined
if (!rowA || !rowB) return false
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
return Boolean(rowA.taskName) && Boolean(rowA.budgetBase) && rowA.taskName === rowB.taskName && rowA.budgetBase === rowB.budgetBase
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '编码',
field: 'taskCode',
minWidth: 100,
width: 120,
pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 3 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
},
{
headerName: '名称',
field: 'taskName',
minWidth: 150,
width: 220,
pinned: 'left',
autoHeight: true,
spanRows: true,
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算基数',
field: 'budgetBase',
minWidth: 150,
autoHeight: true,
width: 180,
pinned: 'left',
spanRows: spanRowsByTaskName,
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算参考单价',
field: 'budgetReferenceUnitPrice',
minWidth: 170,
flex: 1,
valueFormatter: params => params.value || ''
},
{
headerName: '预算采用单价',
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 ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'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 '无'
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
const unit = params.data?.unit || ''
return `${formatThousandsFlexible(params.value, 6)}${unit}`
}
},
{
headerName: '工作量',
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: {
'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: '咨询分类系数',
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: {
'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: '服务费用(元)',
field: 'serviceFee',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)),
aggFunc: decimalAggSum,
valueFormatter: params => {
if (isNoTaskRow(params.data)) return '无'
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(roundTo(params.value, 3), 3)
}
},
{
headerName: '说明',
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 totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row)))
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
if (!Number.isFinite(value)) return null
hasValid = true
return value
})
return hasValid ? total : null
}
const totalServiceFee = computed(() => sumNullableBy(detailRows.value.filter(e => e.basicFee !== null && e.basicFee !== undefined), row => calcServiceFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
taskCode: '总合计',
taskName: '',
unit: '',
conversion: null,
workload: totalWorkload.value,
basicFee: totalBasicFee.value,
budgetBase: '',
budgetReferenceUnitPrice: '',
budgetAdoptedUnitPrice: null,
consultCategoryFactor: null,
serviceFee: totalServiceFee.value,
remark: '',
path: ['TOTAL']
}
])
const buildPersistDetailRows = () =>
detailRows.value.map(row => ({
...row,
basicFee: calcBasicFee(row),
serviceFee: calcServiceFee(row)
}))
const saveToIndexedDB = async () => {
if (!isWorkloadMethodApplicable.value) return
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'workload', payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'workload',
value: totalServiceFee.value
})
if (!synced) return
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
if (!isWorkloadMethodApplicable.value) {
detailRows.value = []
return
}
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
}
const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
const handleCellValueChanged = () => {
void saveToIndexedDB()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onBeforeUnmount(() => {
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;
};
const mydiyTheme = myTheme.withParams({
rowBorder: {
style: "solid",
width: 0.8,
color: "#d8d8dd"
},
columnBorder: {
style: "solid",
width: 0.8,
color: "#d8d8dd"
}
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">工作量明细</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div v-if="isWorkloadMethodApplicable" :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
:enableCellSpan="true"
@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>
<MethodUnavailableNotice
v-else
title="该服务不适用工作量法"
message="当前服务没有关联工作量法任务,无需填写此部分内容。"
/>
</div>
</div>
</template>
-605
View File
@@ -1,605 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { expertList } from '@/sql'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
interface DetailRow {
id: string
expertCode: string
expertName: string
laborBudgetUnitPrice: string
compositeBudgetUnitPrice: string
adoptedBudgetUnitPrice: number | null
personnelCount: number | null
workdayCount: number | null
serviceBudget: number | null
remark: string
path: string[]
}
interface GridState {
detailRows: DetailRow[]
}
const props = withDefaults(
defineProps<{
storageKey: string
title?: string
contractId?: string
serviceId?: string | number
enableZxFwSync?: boolean
syncField?: ZxFwPricingField
htMainStorageKey?: string
htRowId?: string
htMethodType?: HtFeeMethodType
}>(),
{
title: '工时法明细',
enableZxFwSync: false,
syncField: 'hourly'
}
)
const zxFwPricingStore = useZxFwPricingStore()
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const paneInstanceCreatedAt = Date.now()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${props.storageKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const fallbackDetailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
const serviceMethod = computed<ServicePricingMethod | null>(() => {
if (props.syncField === 'investScale') return 'investScale'
if (props.syncField === 'landScale') return 'landScale'
if (props.syncField === 'workload') return 'workload'
if (props.syncField === 'hourly') return 'hourly'
return null
})
const useServicePricingState = computed(
() => Boolean(props.enableZxFwSync && props.contractId && props.serviceId != null && serviceMethod.value)
)
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const getServiceMethodState = () => {
if (!useServicePricingState.value || !serviceMethod.value) return null
return zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
}
const getHtMethodState = () => {
if (!useHtMethodState.value) return null
return zxFwPricingStore.getHtFeeMethodState<GridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
}
const detailRows = computed<DetailRow[]>({
get: () => {
if (useServicePricingState.value) {
const rows = getServiceMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
}
if (useHtMethodState.value) {
const rows = getHtMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
}
return fallbackDetailRows.value
},
set: rows => {
if (useServicePricingState.value && serviceMethod.value) {
const currentState = getServiceMethodState()
zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, {
detailRows: rows,
projectCount: currentState?.projectCount ?? null
})
return
}
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
{ detailRows: rows }
)
return
}
fallbackDetailRows.value = rows
}
})
type ExpertLite = {
code: string
name: string
maxPrice: number | null
minPrice: number | null
defPrice: number | null
manageCoe: number | null
}
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, ExpertLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const formatPriceRange = (min: number | null, max: number | null) => {
const hasMin = typeof min === 'number' && Number.isFinite(min)
const hasMax = typeof max === 'number' && Number.isFinite(max)
if (hasMin && hasMax) return `${min}-${max}`
if (hasMin) return String(min)
if (hasMax) return String(max)
return ''
}
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
if (typeof expert.manageCoe !== 'number' || !Number.isFinite(expert.manageCoe)) return ''
const min =
typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
: null
const max =
typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
: null
return formatPriceRange(min, max)
}
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
if (
typeof expert.defPrice !== 'number' ||
!Number.isFinite(expert.defPrice) ||
typeof expert.manageCoe !== 'number' ||
!Number.isFinite(expert.manageCoe)
) {
return null
}
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const [expertId, expert] of expertEntries) {
const rowId = `expert-${expertId}`
rows.push({
id: rowId,
expertCode: expert.code,
expertName: expert.name,
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [rowId]
})
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice:
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const parseNonNegativeIntegerOrNull = (value: unknown) => {
if (value === '' || value == null) return null
if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null
const normalized = String(value).trim()
if (!/^\d+$/.test(normalized)) return null
const v = Number(normalized)
return Number.isSafeInteger(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
const formatEditableInteger = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
}
const calcServiceBudget = (row: DetailRow | undefined) => {
const adopted = row?.adoptedBudgetUnitPrice
const personnel = row?.personnelCount
const workday = row?.workdayCount
if (
typeof adopted !== 'number' ||
!Number.isFinite(adopted) ||
typeof personnel !== 'number' ||
!Number.isFinite(personnel) ||
typeof workday !== 'number' ||
!Number.isFinite(workday)
) {
return null
}
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
}
const syncServiceBudgetToRows = () => {
for (const row of detailRows.value) {
row.serviceBudget = calcServiceBudget(row)
}
}
const editableNumberCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber,
...extra
})
const editableMoneyCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
},
...extra
})
const readonlyTextCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 170,
flex: 1,
editable: false,
valueFormatter: params => params.value || '',
...extra
})
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
{
headerName: '编码',
field: 'expertCode',
minWidth: 120,
width: 140,
pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
},
{
headerName: '人员名称',
field: 'expertName',
minWidth: 200,
width: 220,
pinned: 'left',
tooltipField: 'expertName',
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算参考单价',
marryChildren: true,
children: [
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
]
},
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', {
aggFunc: decimalAggSum,
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
valueFormatter: formatEditableInteger
}),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
{
headerName: '服务预算(元)',
field: 'serviceBudget',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
valueFormatter: params => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(params.value, 3)
}
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
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 '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}
}
]
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
if (!Number.isFinite(value)) return null
hasValid = true
return value
})
return hasValid ? total : null
}
const totalServiceBudget = computed(() => sumNullableBy(detailRows.value.filter(e => e.serviceBudget !== null && e.serviceBudget !== undefined), row => calcServiceBudget(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
expertCode: '总合计',
expertName: '',
laborBudgetUnitPrice: '',
compositeBudgetUnitPrice: '',
adoptedBudgetUnitPrice: null,
personnelCount: totalPersonnelCount.value,
workdayCount: totalWorkdayCount.value,
serviceBudget: totalServiceBudget.value,
remark: '',
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
syncServiceBudgetToRows()
const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
if (useServicePricingState.value && serviceMethod.value) {
zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, payload)
} else if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload)
}
if (props.enableZxFwSync && props.contractId && props.serviceId != null) {
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: props.syncField,
value: totalServiceBudget.value
})
if (!synced) return
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
return
}
const data = useServicePricingState.value && serviceMethod.value
? await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
: useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<GridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<GridState>(props.storageKey)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
syncServiceBudgetToRows()
return
}
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
}
}
const handleCellValueChanged = () => {
syncServiceBudgetToRows()
gridApi.value?.refreshCells({ force: true })
void saveToIndexedDB()
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
}
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 (_error) {
return params.value
}
return params.value
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(async () => {
await loadFromIndexedDB()
})
watch(
() => props.storageKey,
() => {
void loadFromIndexedDB()
}
)
onDeactivated(() => {
gridApi.value?.stopEditing()
void saveToIndexedDB()
})
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">{{ props.title }}</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:gridOptions="gridOptions"
:theme="myTheme"
:treeData="false"
@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"
@grid-ready="handleGridReady"
/>
</div>
</div>
</div>
</template>
@@ -1,67 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
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 selectedSet = computed(() => new Set(props.modelValue))
const toggleService = (id: string, checked: boolean) => {
const next = new Set(props.modelValue)
if (checked) next.add(id)
else next.delete(id)
emit('update:modelValue', props.services.map(item => item.id).filter(itemId => next.has(itemId)))
}
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">选择服务</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"
>
清空
</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.id, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
<span class="text-foreground break-words">{{ item.name }}</span>
</label>
</div>
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
暂无服务
</div>
</div>
</div>
</template>
-419
View File
@@ -1,419 +0,0 @@
<script setup lang="ts">
import { computed, defineComponent, h, onBeforeUnmount, onMounted, PropType, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type {
CellValueChangedEvent,
ColDef,
GridApi,
GridReadyEvent,
ICellRendererParams,
ValueFormatterParams
} from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
import { Button } from '@/components/ui/button'
import { myTheme, agGridStyle } from '@/lib/diyAgGridOptions'
import { workList } from '@/sql'
import type { WorkType } from '@/sql'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { Trash2 } from 'lucide-vue-next'
interface WorkContentRow {
id: string
content: string
type: WorkType
remark: string
checked: boolean
custom: boolean
path: string[]
}
interface WorkContentState {
detailRows: WorkContentRow[]
}
// dictMode: 'service' 按serviceId筛选workList;'additional' 取serviceid=-1的数据;'none' 不加载词典
const props = withDefaults(defineProps<{
title?: string
storageKey: string
serviceId?: number | string
dictMode?: 'service' | 'additional' | 'none'
}>(), {
title: '工作内容',
dictMode: 'none'
})
const emit = defineEmits<{
checkedChange: [value: string[]]
}>()
const zxFwPricingStore = useZxFwPricingStore()
const gridApi = ref<GridApi<WorkContentRow> | null>(null)
const rowData = ref<WorkContentRow[]>([])
const TYPE_LABEL_MAP: Record<number, WorkType> = {
0: '基本工作',
1: '可选工作',
2: '日常顾问',
3: '专项顾问',
4: '附加工作'
}
const buildDefaultRowsFromDict = (): WorkContentRow[] => {
const rows: WorkContentRow[] = []
const entries = Object.values(workList) as Array<{ text: string; serviceid: number; order: number; type: number }>
let filtered: typeof entries
if (props.dictMode === 'service') {
const sid = Number(props.serviceId)
filtered = entries.filter(e => e.serviceid === sid)
} else if (props.dictMode === 'additional') {
filtered = entries.filter(e => e.serviceid === -1 && props.storageKey.split('-').at(-1) =='2')
} else {
return []
}
filtered.sort((a, b) => a.order - b.order)
for (const entry of filtered) {
const content = String(entry.text || '').trim()
if (!content) continue
const typeLabel = TYPE_LABEL_MAP[entry.type] ?? '基本工作'
rows.push({
id: `dict-${entry.order}`,
content,
type: typeLabel,
remark: '',
checked: false,
custom: false,
path: [typeLabel, content]
})
}
return rows
}
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
deleteConfirmOpen.value = true
}
const checkedIds = computed(() =>
rowData.value.filter(item => item.checked).map(item => item.id)
)
// 导出用:自定义内容全部包含,默认词典内容只含勾选的
const selectedTexts = computed(() =>
rowData.value
.filter(item => item.custom || item.checked)
.map(item => item.content)
.filter(Boolean)
)
defineExpose({ selectedTexts })
const emitCheckedChange = () => {
emit('checkedChange', [...checkedIds.value])
}
const saveToStore = () => {
const payload: WorkContentState = {
detailRows: rowData.value.map(item => ({ ...item }))
}
zxFwPricingStore.setKeyState(props.storageKey, payload)
emitCheckedChange()
}
const loadFromStore = async () => {
const state = await zxFwPricingStore.loadKeyState<WorkContentState>(props.storageKey)
if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) {
rowData.value = state.detailRows.map(item => ({
...item,
type: item.custom ? '自定义' : (item.type || '基本工作'),
path: Array.isArray(item.path) && item.path.length ? item.path : ['自定义', item.content || '未命名']
})) as WorkContentRow[]
} else {
rowData.value = buildDefaultRowsFromDict()
saveToStore()
}
emitCheckedChange()
}
const handleCheckedToggle = (id: string, checked: boolean) => {
const target = rowData.value.find(item => item.id === id)
if (!target) return
target.checked = checked
gridApi.value?.refreshCells({ force: true })
saveToStore()
}
const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
const data = params.data
if (!data) return ''
const wrapper = document.createElement('div')
wrapper.className = 'work-content-cell'
// 自定义行不显示 checkbox,直接显示文本;空时显示 placeholder
if (data.custom) {
const label = document.createElement('span')
if (!data.content) {
label.className = 'work-content-placeholder'
label.textContent = '点击输入工作内容'
} else {
label.className = 'work-content-text'
label.textContent = data.content
}
wrapper.appendChild(label)
return wrapper
}
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'work-content-check'
checkbox.checked = Boolean(data.checked)
checkbox.addEventListener('change', () => {
handleCheckedToggle(data.id, checkbox.checked)
})
const label = document.createElement('span')
label.className = 'work-content-text'
label.textContent = String(data.content || '')
wrapper.appendChild(checkbox)
wrapper.appendChild(label)
return wrapper
}
const columnDefs: ColDef<WorkContentRow>[] = [
{
headerName: '序号',
minWidth: 60,
width: 70,
pinned: 'left',
suppressMovable: true,
editable: false,
valueGetter: params => (params.node?.rowIndex ?? 0) + 1
},
{
headerName: '工作内容',
field: 'content',
minWidth: 320,
flex: 2,
editable: params => Boolean(params.data?.custom),
valueParser: params => String(params.newValue || '').trim(),
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.5' },
cellRenderer: contentCellRenderer
},
{
headerName: '工作类型',
field: 'type',
minWidth: 100,
width: 120,
editable: false,
valueFormatter: (params: ValueFormatterParams<WorkContentRow>) => String(params.value || '')
},
{
headerName: '备注',
field: 'remark',
minWidth: 180,
flex: 1.2,
editable: true,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
cellClass: 'remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
},
valueFormatter: params => params.value || '点击输入'
},
{
headerName: '操作',
colId: 'actions',
minWidth: 92,
maxWidth: 110,
flex: 0.8,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: defineComponent({
name: 'HtFeeGridActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<WorkContentRow>>,
required: true
}
},
setup(rendererProps) {
return () => {
const row = rendererProps.params.data
if (!row?.custom) return null
const onDelete = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
requestDeleteRow(row.id, row.content)
}
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete
},
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
)
}
}
})
}
]
const addCustomRow = () => {
const ts = Date.now()
rowData.value.push({
id: `custom-${ts}`,
content: '',
type: '自定义' as WorkType,
remark: '',
checked: false,
custom: true,
path: ['自定义', `自定义-${ts}`]
})
saveToStore()
}
const onGridReady = (event: GridReadyEvent<WorkContentRow>) => {
gridApi.value = event.api
}
const onCellValueChanged = (event: CellValueChangedEvent<WorkContentRow>) => {
const row = event.data
if (!row) return
if (event.colDef.field === 'content' && row.custom) {
row.path = ['自定义', row.content || `自定义-${row.id}`]
}
if (event.colDef.field === 'type' && row.custom) {
row.type = '自定义'
}
saveToStore()
}
onMounted(() => {
void loadFromStore()
})
onBeforeUnmount(() => {
saveToStore()
})
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const deleteRow = (id: string) => {
rowData.value = rowData.value.filter(item => item.id !== id)
saveToStore()
}
const confirmDeleteRow = () => {
const id = pendingDeleteRowId.value
if (!id) return
deleteRow(id)
deleteConfirmOpen.value = false
pendingDeleteRowId.value = null
pendingDeleteRowName.value = ''
}
</script>
<template>
<div class="h-full min-h-0 xmMx">
<div class="h-full min-h-0 rounded-2xl border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm">
<div class="flex items-center justify-between border-b border-border/60 px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3>
<Button type="button" size="sm" variant="outline" class="cursor-pointer" @click="addCustomRow">
添加自定义内容
</Button>
</div>
<div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full">
<AgGridVue
:style="agGridStyle"
:rowData="rowData"
:columnDefs="columnDefs"
:theme="myTheme"
:getRowId="(params: { data: WorkContentRow }) => params.data.id"
:animateRows="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:singleClickEdit="true"
:stopEditingWhenCellsLoseFocus="true"
:defaultColDef="{ resizable: true, sortable: false, filter: false }"
:suppressColumnVirtualisation="false"
:suppressRowVirtualisation="false"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
/>
</div>
</div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认删除行</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将删除“{{ pendingDeleteRowName }}”这条明细,是否继续?
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</template>
<style scoped>
:deep(.work-content-placeholder) {
color: #94a3b8;
font-style: italic;
min-width: 0;
flex: 1;
}
:deep(.work-content-cell) {
display: flex;
width: 100%;
align-items: center;
gap: 8px;
}
:deep(.work-content-text) {
min-width: 0;
flex: 1;
white-space: normal;
word-break: break-word;
line-height: 1.5;
}
:deep(.work-content-check) {
width: 14px;
height: 14px;
cursor: pointer;
}
</style>
-525
View File
@@ -1,525 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab'
import { useKvStore } from '@/pinia/kv'
import {
Calculator,
Check,
ChevronDown,
Download,
FolderKanban,
X,
Zap
} from 'lucide-vue-next'
import { industryTypeList } from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import {
SelectContent,
SelectIcon,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import {
PROJECT_TAB_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,
writeWorkspaceMode
} from '@/lib/workspace'
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 DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const tabStore = useTabStore()
const kvStore = useKvStore()
const projectDialogOpen = ref(false)
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
const projectSubmitting = ref(false)
const quickDialogOpen = 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 projectIconAvailable = ref(false)
const quickIconAvailable = ref(false)
const importIconAvailable = ref(false)
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 enterProjectCalc = () => {
writeWorkspaceMode('project')
tabStore.enterWorkspace({
id: PROJECT_TAB_ID,
title: '项目计算',
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 closeProjectCalcDialog = () => {
projectDialogOpen.value = false
}
const confirmProjectCalc = async () => {
const industry = projectIndustry.value.trim()
if (!industry) return
projectSubmitting.value = true
try {
await kvStore.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
projectIndustry: industry,
projectName: DEFAULT_PROJECT_NAME,
preparedBy: '',
reviewedBy: '',
preparedCompany: '',
preparedDate: getTodayDateString()
})
await initializeProjectFactorStates(
kvStore,
industry,
PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
PROJECT_MAJOR_FACTOR_KEY
)
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
enterProjectCalc()
} 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 openQuickCalcDialog = async () => {
await loadQuickDefaults()
quickDialogOpen.value = true
}
const closeQuickCalcDialog = () => {
quickDialogOpen.value = false
}
const confirmQuickCalc = async () => {
const contractName = quickContractName.value.trim()
const industry = quickIndustry.value.trim()
if (!contractName || !industry) return
quickSubmitting.value = true
try {
const currentInfo = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
...currentInfo,
projectIndustry: industry,
projectName: '快速计算'
})
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
id: QUICK_CONTRACT_ID,
name: contractName,
updatedAt: new Date().toISOString()
})
await initializeProjectFactorStates(
kvStore,
industry,
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
QUICK_MAJOR_FACTOR_KEY
)
writeWorkspaceMode('quick')
tabStore.enterWorkspace({
id: `contract-${QUICK_CONTRACT_ID}`,
title: contractName,
componentName: 'QuickCalcView',
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
} finally {
quickSubmitting.value = false
quickDialogOpen.value = false
}
}
const handleHomeImportChange = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
window.dispatchEvent(new CustomEvent('home-import-selected', {
detail: {
file
}
}))
input.value = ''
}
const openHomeImport = () => {
homeImportInputRef.value?.click()
}
onMounted(() => {
void loadProjectDefaults()
void loadQuickDefaults()
})
</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 px-4 py-8 lg:py-10">
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(59,130,246,0.06),transparent_70%)]" />
<div class="relative w-full max-w-[1240px]">
<div class="grid items-stretch gap-6 lg:grid-cols-[300px_1fr]">
<div
class="home-hero relative overflow-hidden rounded-2xl bg-[linear-gradient(145deg,#5c0a0a_0%,#991b1b_50%,#dc2626_100%)] p-7 text-white shadow-[0_24px_60px_rgba(153,27,27,0.45)]"
>
<div class="pointer-events-none absolute -right-20 -top-16 h-56 w-56 rounded-full bg-white/12 blur-2xl" />
<div class="pointer-events-none absolute -left-10 -bottom-10 h-40 w-40 rounded-full bg-white/8 blur-3xl" />
<div
class="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(140deg,rgba(255,255,255,0.06)_0,rgba(255,255,255,0.06)_1px,transparent_1px,transparent_20px)]"
/>
<div class="relative inline-flex h-11 w-11 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/35">
<Calculator class="h-5 w-5" />
</div>
<h2 class="relative mt-8 text-2xl font-semibold leading-tight tracking-tight lg:text-3xl">智能预算一键生成</h2>
<p class="relative mt-2 text-sm text-red-200/90">助力《规范》高效落地</p>
<div class="relative mt-6 h-px bg-gradient-to-r from-white/20 via-white/10 to-transparent" />
<p class="relative mt-4 text-xs leading-5 text-red-200/60">交通建设项目工程造价咨询服务费计算</p>
</div>
<div class="flex flex-col">
<div class="home-title text-center">
<h1 class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl">计算入口</h1>
<p class="mt-1.5 text-sm text-slate-500">项目计算 · 单项速算 · 导入数据</p>
</div>
<div class="mt-5 grid flex-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<Card
role="button"
tabindex="0"
class="home-card 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"
>
<CardHeader class="p-0">
<div
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-blue-100 bg-blue-50/80 shadow-sm"
>
<img
v-if="projectIconAvailable"
:src="'/image_0.png'"
alt="项目预算"
class="h-5 w-5 object-contain"
@error="projectIconAvailable = false"
>
<FolderKanban v-else class="h-5 w-5 text-blue-600" />
</div>
<CardTitle class="mt-4 text-base font-semibold text-slate-900">项目预算</CardTitle>
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
适用于多合同段、项目级整体计算,支持导出/导入完整项目数据
</CardDescription>
</CardHeader>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>进入计算</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>
</Card>
<Card
role="button"
tabindex="0"
class="home-card 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="openQuickCalcDialog"
@keydown.enter.prevent="openQuickCalcDialog"
@keydown.space.prevent="openQuickCalcDialog"
>
<CardHeader class="p-0">
<div
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-amber-100 bg-amber-50/80 shadow-sm"
>
<img
v-if="quickIconAvailable"
:src="'/image_1.png'"
alt="单项速算"
class="h-5 w-5 object-contain"
@error="quickIconAvailable = false"
>
<Zap v-else class="h-5 w-5 text-amber-500" />
</div>
<CardTitle class="mt-4 text-base font-semibold text-slate-900">单项速算</CardTitle>
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
单合同段预算、单项试算,选择行业与咨询类型,输入基数秒出结果
</CardDescription>
</CardHeader>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>进入计算</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>
</Card>
<Card
role="button"
tabindex="0"
class="home-card 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="openHomeImport"
@keydown.enter.prevent="openHomeImport"
@keydown.space.prevent="openHomeImport"
>
<CardHeader class="p-0">
<div
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-cyan-100 bg-cyan-50/80 shadow-sm"
>
<img
v-if="importIconAvailable"
:src="'/image_2.png'"
alt="导入数据"
class="h-5 w-5 object-contain"
@error="importIconAvailable = false"
>
<Download v-else class="h-5 w-5 text-cyan-600" />
</div>
<CardTitle class="mt-4 text-base font-semibold text-slate-900">导入数据</CardTitle>
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
导入".zw"数据包,快速恢复项目计算状态,续未完成工作
</CardDescription>
</CardHeader>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>选择文件</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>
</Card>
</div>
</div>
</div>
</div>
</div>
<div
v-if="projectDialogOpen"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
@click.self="closeProjectCalcDialog"
>
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
<div class="flex items-center justify-between border-b px-5 py-4">
<div>
<h3 class="text-base font-semibold text-foreground">新建项目</h3>
<p class="mt-1 text-sm text-muted-foreground">选择工程行业后,直接进入项目计算页面。</p>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeProjectCalcDialog">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-4 px-5 py-4">
<label class="block space-y-2">
<span class="text-sm font-medium text-foreground">工程行业</span>
<SelectRoot v-model="projectIndustry">
<SelectTrigger
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
>
<SelectValue placeholder="请选择工程行业" />
<SelectIcon as-child>
<ChevronDown class="h-4 w-4 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectContent
:side-offset="6"
position="popper"
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
>
<SelectViewport class="p-1">
<SelectItem
v-for="item in industryTypeList"
:key="`project-${item.id}`"
:value="String(item.id)"
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
>
<SelectItemText>{{ item.name }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
<Check class="h-4 w-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</label>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
<Button variant="outline" @click="closeProjectCalcDialog">取消</Button>
<Button :disabled="projectSubmitting || !projectIndustry" @click="confirmProjectCalc">
{{ projectSubmitting ? '进入中...' : '进入项目计算' }}
</Button>
</div>
</div>
</div>
<div
v-if="quickDialogOpen"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
@click.self="closeQuickCalcDialog"
>
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
<div class="flex items-center justify-between border-b px-5 py-4">
<div>
<h3 class="text-base font-semibold text-foreground">单项速算</h3>
<p class="mt-1 text-sm text-muted-foreground">输入合同名称后,进入单项速算页面。</p>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeQuickCalcDialog">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-4 px-5 py-4">
<label class="block space-y-2">
<span class="text-sm font-medium text-foreground">工程行业</span>
<SelectRoot v-model="quickIndustry">
<SelectTrigger
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
>
<SelectValue placeholder="请选择工程行业" />
<SelectIcon as-child>
<ChevronDown class="h-4 w-4 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectContent
:side-offset="6"
position="popper"
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
>
<SelectViewport class="p-1">
<SelectItem
v-for="item in industryTypeList"
:key="`quick-${item.id}`"
:value="String(item.id)"
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
>
<SelectItemText>{{ item.name }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
<Check class="h-4 w-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</label>
<label class="block space-y-2">
<span class="text-sm font-medium text-foreground">合同名称</span>
<input
v-model="quickContractName"
type="text"
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition placeholder:text-slate-400 hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
placeholder="请输入合同名称"
>
</label>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
<Button variant="outline" @click="closeQuickCalcDialog">取消</Button>
<Button :disabled="quickSubmitting || !quickContractName" @click="confirmQuickCalc">
{{ quickSubmitting ? '进入中...' : '进入计算' }}
</Button>
</div>
</div>
</div>
</template>
<style scoped>
.home-hero {
animation: hero-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.home-title {
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
}
.home-card:nth-child(1) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.25s both; }
.home-card:nth-child(2) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.35s both; }
.home-card:nth-child(3) { animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.45s both; }
@keyframes hero-in {
from { opacity: 0; transform: translateX(-20px) scale(0.97); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
-31
View File
@@ -1,31 +0,0 @@
<template>
<TypeLine
scene="xm-tab"
title=""
storage-key="project-active-cat"
default-category="info"
:categories="xmCategories"
/>
</template>
<script setup lang="ts">
import { defineAsyncComponent, markRaw } from 'vue'
import TypeLine from '@/layout/typeLine.vue'
const infoView = markRaw(defineAsyncComponent(() => import('@/components/xm/info.vue')))
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/components/xm/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/components/ht/Ht.vue')))
const consultCategoryFactorView = markRaw(
defineAsyncComponent(() => import('@/components/xm/XmConsultCategoryFactor.vue'))
)
const majorFactorView = markRaw(
defineAsyncComponent(() => import('@/components/xm/XmMajorFactor.vue'))
)
const xmCategories = [
{ key: 'info', label: '基础信息', component: infoView },
{ key: 'scale-info', label: '规模信息', component: scaleInfoView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView },
{ key: 'contract', label: '合同段管理', component: htView }
]
</script>
File diff suppressed because it is too large. Load diff
@@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import HtFeeMethodGrid from '@/components/shared/HtFeeMethodGrid.vue' import { useI18n } from 'vue-i18n'
import { additionalWorkList } from '@/sql' import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { getAdditionalWorkListEntries } from '@/sql'
const props = defineProps<{ const props = defineProps<{
contractId: string contractId: string
contractName?: string contractName?: string
}>() }>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`) const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
const additionalWorkNames = computed(() => const additionalWorkNames = computed(() =>
additionalWorkList.map(item => ({id:item?.id,name:item?.name})) getAdditionalWorkListEntries(locale.value).map(item => ({ id: item?.id, name: item?.name }))
) )
</script> </script>
<template> <template>
<HtFeeMethodGrid <HtFeeMethodGrid
title="附加工作费" :title="t('htFee.additionalTitle')"
:storageKey="STORAGE_KEY" :storageKey="STORAGE_KEY"
:contract-id="props.contractId" :contract-id="props.contractId"
:contract-name="props.contractName" :contract-name="props.contractName"
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface HtBaseInfoState {
quality: string
duration: string
}
const { t } = useI18n()
const DEFAULT_QUALITY = t('htBaseInfo.defaultQuality')
const DEFAULT_DURATION = ''
const props = defineProps<{
contractId: string
}>()
const zxFwPricingStore = useZxFwPricingStore()
const storageKey = () => `ht-base-info-${props.contractId}`
const quality = ref(DEFAULT_QUALITY)
const duration = ref('')
const lastSavedSnapshot = ref('')
const saveForm = (force = false) => {
const payload: HtBaseInfoState = {
quality: quality.value,
duration: duration.value
}
const snapshot = JSON.stringify(payload)
if (!force && snapshot === lastSavedSnapshot.value) return
zxFwPricingStore.setKeyState(storageKey(), payload)
lastSavedSnapshot.value = snapshot
}
const loadForm = async () => {
const data = await zxFwPricingStore.loadKeyState<HtBaseInfoState>(storageKey())
const hasStoredValue = Boolean(
data && (Object.prototype.hasOwnProperty.call(data, 'quality') || Object.prototype.hasOwnProperty.call(data, 'duration'))
)
quality.value = typeof data?.quality === 'string' && data.quality ? data.quality : DEFAULT_QUALITY
duration.value = typeof data?.duration === 'string' ? data.duration : (hasStoredValue ? '' : DEFAULT_DURATION)
lastSavedSnapshot.value = JSON.stringify({ quality: quality.value, duration: duration.value })
if (!hasStoredValue) saveForm(true)
}
watch([quality, duration], () => {
saveForm()
})
onMounted(() => {
void loadForm()
})
onBeforeUnmount(() => {
saveForm(true)
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card p-5">
<div class="mb-4 border-b pb-3">
<h3 class="text-sm font-semibold text-foreground">{{ t('htBaseInfo.title') }}</h3>
</div>
<div class="grid grid-cols-1 gap-5">
<label class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.qualityLabel') }}</div>
<textarea
v-model="quality"
rows="3"
:placeholder="t('htBaseInfo.qualityPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</label>
<label class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.durationLabel') }}</div>
<textarea
v-model="duration"
rows="3"
:placeholder="t('htBaseInfo.durationPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</label>
</div>
</div>
</div>
</template>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql' import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
const props = defineProps<{ const props = defineProps<{
@@ -25,6 +26,7 @@ type ServiceItem = {
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1') const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('') const projectIndustry = ref('')
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@@ -57,7 +59,7 @@ onActivated(() => {
<template> <template>
<XmFactorGrid <XmFactorGrid
title="咨询分类系数明细" :title="t('htFactors.consultCategoryTitle')"
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`" :storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'" :parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'"
:dict="filteredServiceDict" :dict="filteredServiceDict"
@@ -0,0 +1,598 @@
<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,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' 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 { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
@@ -18,6 +20,7 @@ const props = defineProps<{
htMethodType?: 'rate-fee' htMethodType?: 'rate-fee'
}>() }>()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t } = useI18n()
const rate = ref<number | null>(null) const rate = ref<number | null>(null)
const remark = ref('') const remark = ref('')
@@ -36,25 +39,25 @@ const isReserveFee = computed(() => {
if (mainKey) return mainKey.endsWith('-reserve') if (mainKey) return mainKey.endsWith('-reserve')
return String(props.storageKey || '').includes('-reserve') return String(props.storageKey || '').includes('-reserve')
}) })
const contractVersion = computed(() => { const contractStateSignature = computed(() => {
const contractId = contractIdText.value const contractId = contractIdText.value
if (!contractId) return 0 if (!contractId) return ''
return zxFwPricingStore.contractVersions[contractId] || 0 return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
}) })
const additionalWorkKeyVersion = computed(() => { const additionalWorkStateSignature = computed(() => {
if (!isReserveFee.value) return 0 if (!isReserveFee.value) return ''
const contractId = contractIdText.value const contractId = contractIdText.value
if (!contractId) return 0 if (!contractId) return ''
const additionalStorageKey = `htExtraFee-${contractId}-additional-work` const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
return zxFwPricingStore.getKeyVersion(additionalStorageKey) return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
}) })
const baseLabel = computed(() => const baseLabel = computed(() =>
isReserveFee.value ? '基数(咨询服务总计 + 附加工作费总计)' : '基数(所有服务费预算合计)' isReserveFee.value ? t('htFeeRate.reserveBaseLabel') : t('htFeeRate.baseLabel')
) )
const budgetFee = computed<number | null>(() => { const budgetFee = computed<number | null>(() => {
if (baseValue.value == null || rate.value == null) return null if (baseValue.value == null || rate.value == null) return null
return Number((baseValue.value * rate.value).toFixed(3)) return roundTo(baseValue.value * rate.value / 100, 2)
}) })
const formatAmount = (value: number | null) => const formatAmount = (value: number | null) =>
@@ -67,7 +70,7 @@ const ensureContractLoaded = async () => {
await zxFwPricingStore.loadContract(contractId) await zxFwPricingStore.loadContract(contractId)
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId) const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
if (!isReserveFee.value) { if (!isReserveFee.value) {
baseValue.value = serviceBase == null ? null : Number(serviceBase.toFixed(3)) baseValue.value = serviceBase == null ? null : roundTo(serviceBase, 3)
return return
} }
const additionalStorageKey = `htExtraFee-${contractId}-additional-work` const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
@@ -87,7 +90,7 @@ const ensureContractLoaded = async () => {
}, 0) }, 0)
const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0 const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0
const hasAny = (serviceBase != null) || additionalTotal !== 0 const hasAny = (serviceBase != null) || additionalTotal !== 0
baseValue.value = hasAny ? Number((serviceBaseSafe + additionalTotal).toFixed(3)) : null baseValue.value = hasAny ? roundTo(serviceBaseSafe + additionalTotal, 3) : null
} catch (error) { } catch (error) {
console.error('load contract for rate base failed:', error) console.error('load contract for rate base failed:', error)
baseValue.value = null baseValue.value = null
@@ -180,8 +183,8 @@ watch(
} }
) )
watch([contractVersion, additionalWorkKeyVersion], ([nextContract, nextAdditional], [prevContract, prevAdditional]) => { watch([contractStateSignature, additionalWorkStateSignature], ([nextContractSig, nextAdditionalSig], [prevContractSig, prevAdditionalSig]) => {
if (nextContract === prevContract && nextAdditional === prevAdditional) return if (nextContractSig === prevContractSig && nextAdditionalSig === prevAdditionalSig) return
void ensureContractLoaded() void ensureContractLoaded()
}) })
@@ -209,24 +212,31 @@ onBeforeUnmount(() => {
</label> </label>
<label class="space-y-1.5"> <label class="space-y-1.5">
<div class="text-xs text-muted-foreground">费率(%)</div> <div class="text-xs text-muted-foreground">{{ t('htFeeRate.rateLabel') }}</div>
<input v-model="rateInput" type="text" inputmode="decimal" placeholder="请输入费率,建议1 ~ 5" <input v-model="rateInput" type="text" inputmode="decimal" :placeholder="t('htFeeRate.ratePlaceholder')"
class="h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" 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" /> @blur="applyRateInput"
@keydown.enter.prevent="applyRateInput" />
</label> </label>
<label class="space-y-1.5"> <label class="space-y-1.5">
<div class="text-xs text-muted-foreground">预算费用(自动计算)</div> <div class="text-xs text-muted-foreground">{{ t('htFeeRate.budgetFeeLabel') }}</div>
<input type="text" :value="formatAmount(budgetFee)" readonly disabled tabindex="-1" <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" /> class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
</label> </label>
<label class="space-y-1.5 md:col-span-2"> <label class="space-y-1.5 md:col-span-2">
<div class="text-xs text-muted-foreground">说明</div> <div class="text-xs text-muted-foreground">{{ t('htFeeRate.remarkLabel') }}</div>
<textarea v-model="remark" rows="4" placeholder="请输入说明" <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" /> 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> </label>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
:deep(.rate-input) {
text-align: left !important;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState { interface XmBaseInfoState {
@@ -25,6 +26,7 @@ const props = defineProps<{
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1') const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('') const projectIndustry = ref('')
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@@ -57,7 +59,7 @@ onActivated(() => {
<template> <template>
<XmFactorGrid <XmFactorGrid
title="工程专业系数明细" :title="t('htFactors.majorTitle')"
:storage-key="`ht-major-factor-v1-${props.contractId}`" :storage-key="`ht-major-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'" :parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'"
:dict="filteredMajorDict" :dict="filteredMajorDict"
@@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import HtFeeMethodGrid from '@/components/shared/HtFeeMethodGrid.vue' import { useI18n } from 'vue-i18n'
import { reserveList } from '@/sql' import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { getReserveListEntries } from '@/sql'
const props = defineProps<{ const props = defineProps<{
contractId: string contractId: string
contractName?: string contractName?: string
}>() }>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`) const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
const reserveFeeNames = computed(() => const reserveFeeNames = computed(() =>
reserveList.map(item => ({name:item.name,id:item.id})) getReserveListEntries(locale.value).map(item => ({ name: item.name, id: item.id }))
) )
</script> </script>
<template> <template>
<HtFeeMethodGrid <HtFeeMethodGrid
title="预备费" :title="t('htFee.reserveTitle')"
:storageKey="STORAGE_KEY" :storageKey="STORAGE_KEY"
:contract-id="props.contractId" :contract-id="props.contractId"
:contract-name="props.contractName" :contract-name="props.contractName"
@@ -2,22 +2,24 @@
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 --> <!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
<TypeLine <TypeLine
scene="ht-tab" scene="ht-tab"
:title="`合同段:${contractName}`" :title="t('htCard.title', { name: contractName })"
:subtitle="`合同段ID:${contractId}`" :subtitle="t('htCard.subtitle', { id: contractId })"
:meta-text="`合同段预算金额:${formatBudgetAmount(contractBudget)}`" :meta-text="t('htCard.metaBudget', { amount: formatBudgetAmount(contractBudget) })"
:copy-text="contractId" :copy-text="contractId"
:storage-key="`project-active-cat-${contractId}`" :storage-key="typeLineStorageKey"
default-category="info" default-category="base-info"
:categories="xmCategories" :categories="xmCategories"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue'; 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 TypeLine from '@/layout/typeLine.vue';
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { roundTo } from '@/lib/decimal' import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat' import { formatThousands } from '@/lib/numberFormat'
import {useDataStore} from '@/pinia/zx'
// 1. 完善 Props 类型 + 添加校验(可选但推荐) // 1. 完善 Props 类型 + 添加校验(可选但推荐)
const props = defineProps<{ const props = defineProps<{
@@ -28,6 +30,7 @@ const props = defineProps<{
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键 projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
projectMajorFactorKey?: string; // 工作区工程专业系数键 projectMajorFactorKey?: string; // 工作区工程专业系数键
}>(); }>();
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
interface HtFeeMainRowLike { interface HtFeeMainRowLike {
@@ -61,16 +64,13 @@ interface QuantityMethodStateLike {
} }
const contractBudget = ref<number | null>(null) const contractBudget = ref<number | null>(null)
const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId}`)
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
const toFiniteNumber = (value: unknown): number | null => {
if (value == null || value === '') return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const formatBudgetAmount = (value: number | null | undefined) => const formatBudgetAmount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--' typeof value === 'number' && Number.isFinite(value)
? `${formatThousands(value, 2)} ${t('htCard.currencySuffix')}`
: '--'
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => { const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : [] const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
@@ -130,9 +130,8 @@ const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) =>
sumHourlyMethodFee(hourlyState), sumHourlyMethodFee(hourlyState),
sumQuantityMethodFee(quantityState) sumQuantityMethodFee(quantityState)
] ]
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) const total = sumNullableNumbers(parts)
if (validParts.length === 0) return null return total == null ? null : roundTo(total, 2)
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
} }
const loadHtMainTotalFee = async (mainStorageKey: string) => { const loadHtMainTotalFee = async (mainStorageKey: string) => {
@@ -141,9 +140,8 @@ const loadHtMainTotalFee = async (mainStorageKey: string) => {
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean) const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
if (rowIds.length === 0) return null if (rowIds.length === 0) return null
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId))) const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) const total = sumNullableNumbers(rowTotals)
if (validTotals.length === 0) return null return total == null ? null : roundTo(total, 2)
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
} }
const refreshContractBudget = async () => { const refreshContractBudget = async () => {
@@ -154,21 +152,28 @@ const refreshContractBudget = async () => {
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`) loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
]) ])
const parts = [serviceFee, additionalFee, reserveFee] const parts = [serviceFee, additionalFee, reserveFee]
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) const total = sumNullableNumbers(parts)
contractBudget.value = validParts.length === 0 ? null : roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
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 budgetRefreshSignature = computed(() => {
const contractVersion = zxFwPricingStore.contractVersions[props.contractId] || 0
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work` const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
const reserveMainKey = `htExtraFee-${props.contractId}-reserve` const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
const keyVersionEntries = Object.entries(zxFwPricingStore.keyVersions) return JSON.stringify({
const methodKeySig = keyVersionEntries contractState: zxFwPricingStore.contracts[props.contractId] || null,
.filter(([key]) => key.startsWith(`${additionalMainKey}-`) || key.startsWith(`${reserveMainKey}-`)) addMain: zxFwPricingStore.htFeeMainStates[additionalMainKey] || null,
.sort((a, b) => a[0].localeCompare(b[0])) reserveMain: zxFwPricingStore.htFeeMainStates[reserveMainKey] || null,
.map(([key, version]) => `${key}:${version}`) addMethods: zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null,
.join(',') reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
return `${contractVersion}:${zxFwPricingStore.getKeyVersion(additionalMainKey)}:${zxFwPricingStore.getKeyVersion(reserveMainKey)}:${methodKeySig}` })
}) })
const scheduleRefreshContractBudget = () => { const scheduleRefreshContractBudget = () => {
@@ -200,7 +205,7 @@ const htView = markRaw(
name: 'HtInfoWithProps', name: 'HtInfoWithProps',
setup() { setup() {
const AsyncHtInfo = defineAsyncComponent({ const AsyncHtInfo = defineAsyncComponent({
loader: () => import('@/components/ht/htInfo.vue'), loader: () => import('@/features/ht/components/htInfo.vue'),
onError: (err) => { onError: (err) => {
console.error('加载 htInfo 组件失败:', err); console.error('加载 htInfo 组件失败:', err);
} }
@@ -219,7 +224,7 @@ const zxfwView = markRaw(
name: 'ZxFwWithProps', name: 'ZxFwWithProps',
setup() { setup() {
const AsyncZxFw = defineAsyncComponent({ const AsyncZxFw = defineAsyncComponent({
loader: () => import('@/components/ht/zxFw.vue'), loader: () => import('@/features/ht/components/zxFw.vue'),
onError: (err) => { onError: (err) => {
console.error('加载 zxFw 组件失败:', err); console.error('加载 zxFw 组件失败:', err);
} }
@@ -238,7 +243,7 @@ const consultCategoryFactorView = markRaw(
name: 'HtConsultCategoryFactorWithProps', name: 'HtConsultCategoryFactorWithProps',
setup() { setup() {
const AsyncHtConsultCategoryFactor = defineAsyncComponent({ const AsyncHtConsultCategoryFactor = defineAsyncComponent({
loader: () => import('@/components/ht/HtConsultCategoryFactor.vue'), loader: () => import('@/features/ht/components/HtConsultCategoryFactor.vue'),
onError: (err) => { onError: (err) => {
console.error('加载 HtConsultCategoryFactor 组件失败:', err); console.error('加载 HtConsultCategoryFactor 组件失败:', err);
} }
@@ -257,7 +262,7 @@ const majorFactorView = markRaw(
name: 'HtMajorFactorWithProps', name: 'HtMajorFactorWithProps',
setup() { setup() {
const AsyncHtMajorFactor = defineAsyncComponent({ const AsyncHtMajorFactor = defineAsyncComponent({
loader: () => import('@/components/ht/HtMajorFactor.vue'), loader: () => import('@/features/ht/components/HtMajorFactor.vue'),
onError: (err) => { onError: (err) => {
console.error('加载 HtMajorFactor 组件失败:', err); console.error('加载 HtMajorFactor 组件失败:', err);
} }
@@ -278,7 +283,7 @@ const htBaseInfoView = markRaw(
name: 'HtBaseInfoWithProps', name: 'HtBaseInfoWithProps',
setup() { setup() {
const AsyncHtBaseInfo = defineAsyncComponent({ const AsyncHtBaseInfo = defineAsyncComponent({
loader: () => import('@/components/ht/HtBaseInfo.vue'), loader: () => import('@/features/ht/components/HtBaseInfo.vue'),
onError: (err) => { onError: (err) => {
console.error('加载 HtBaseInfo 组件失败:', err) console.error('加载 HtBaseInfo 组件失败:', err)
} }
@@ -293,7 +298,7 @@ const additionalWorkFeeView = markRaw(
name: 'HtAdditionalWorkFeeWithProps', name: 'HtAdditionalWorkFeeWithProps',
setup() { setup() {
const AsyncHtAdditionalWorkFee = defineAsyncComponent({ const AsyncHtAdditionalWorkFee = defineAsyncComponent({
loader: () => import('@/components/ht/HtAdditionalWorkFee.vue'), loader: () => import('@/features/ht/components/HtAdditionalWorkFee.vue'),
onError: (err) => { onError: (err) => {
console.error('加载 HtAdditionalWorkFee 组件失败:', err); console.error('加载 HtAdditionalWorkFee 组件失败:', err);
} }
@@ -308,7 +313,7 @@ const reserveFeeView = markRaw(
name: 'HtReserveFeeWithProps', name: 'HtReserveFeeWithProps',
setup() { setup() {
const AsyncHtReserveFee = defineAsyncComponent({ const AsyncHtReserveFee = defineAsyncComponent({
loader: () => import('@/components/ht/HtReserveFee.vue'), loader: () => import('@/features/ht/components/HtReserveFee.vue'),
onError: (err) => { onError: (err) => {
console.error('加载 HtReserveFee 组件失败:', err); console.error('加载 HtReserveFee 组件失败:', err);
} }
@@ -318,19 +323,32 @@ const reserveFeeView = markRaw(
}) })
); );
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. 给分类数组添加严格类型标注 // 4. 给分类数组添加严格类型标注
const xmCategories: XmCategoryItem[] = [ const xmCategories = computed<XmCategoryItem[]>(() => [
{ key: 'base-info', label: '基础信息', component: htBaseInfoView }, { key: 'base-info', label: t('htCard.categories.baseInfo'), component: htBaseInfoView },
{ key: 'info', label: '规模信息', component: htView }, { key: 'info', label: t('htCard.categories.scaleInfo'), component: htView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView }, { key: 'contract', label: t('htCard.categories.services'), component: zxfwView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }, // { key: 'consult-category-factor', label: t('htCard.categories.consultFactor'), component: consultCategoryFactorView },
{ key: 'contract', label: '咨询服务', component: zxfwView }, // { key: 'major-factor', label: t('htCard.categories.majorFactor'), component: majorFactorView },
{ key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView }, // { key: 'additional-work-fee', label: t('htCard.categories.additionalFee'), component: additionalWorkFeeView },
{ key: 'reserve-fee', label: '预备费', component: reserveFeeView }, // { key: 'reserve-fee', label: t('htCard.categories.reserveFee'), component: reserveFeeView },
{ key: 'all', label: '汇总', component: reserveFeeView }, // { key: 'all', label: t('htCard.categories.summary'), component: summaryView },
]);
];
watch(budgetRefreshSignature, (next, prev) => { watch(budgetRefreshSignature, (next, prev) => {
if (next === prev) return if (next === prev) return
@@ -349,3 +367,4 @@ onBeforeUnmount(() => {
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer) if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
}) })
</script> </script>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import CommonAgGrid from '@/components/shared/xmCommonAgGrid.vue' import { useI18n } from 'vue-i18n'
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
const props = defineProps<{ const props = defineProps<{
@@ -14,10 +15,11 @@ const XM_DB_KEY = computed(() => {
return props.projectScaleKey || 'xm-info-v3' return props.projectScaleKey || 'xm-info-v3'
}) })
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1') const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const { t } = useI18n()
</script> </script>
<template> <template>
<CommonAgGrid title="合同规模明细" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/> <CommonAgGrid :title="t('htInfo.scaleDetailTitle')" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/>
</template> </template>
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
export interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
export const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({
...item,
order: index,
createdAt: item.createdAt || new Date().toISOString()
}))
export const formatDateTime = (value: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.map((item, index) => {
const row = item as Partial<ContractItem>
const name = typeof row.name === 'string' ? row.name.trim() : ''
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
return {
id,
name: name || `导入合同段-${index + 1}`,
order: index,
createdAt
}
})
}
export const isEntryRelatedToAnyContract = (
key: string,
contractIds: Set<string>,
matcher: (entryKey: string, contractId: string) => boolean
) => {
for (const contractId of contractIds) {
if (matcher(key, contractId)) return true
}
return false
}
+210
View File
@@ -0,0 +1,210 @@
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
padding-top: 6px;
}
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
scroll-snap-type: none;
}
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
opacity: 0.35;
}
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
}
.ht-contract-card {
will-change: transform, opacity;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
isolation: isolate;
overflow: visible;
z-index: 0;
transition:
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
border-color 180ms ease;
box-shadow:
0 1px 2px hsl(var(--foreground) / 0.04),
0 6px 16px hsl(var(--foreground) / 0.06);
}
.ht-contract-card::before {
content: '';
position: absolute;
inset: -4px;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
130deg,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.22) 36%,
hsl(var(--foreground) / 0.09) 70%,
transparent 100%
);
opacity: 0;
transition: opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card::after {
content: '';
position: absolute;
left: 6px;
right: 6px;
bottom: -30px;
height: 52px;
border-radius: 999px;
pointer-events: none;
background:
radial-gradient(
ellipse at center,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.24) 34%,
hsl(var(--foreground) / 0.20) 58%,
transparent 86%
);
filter: blur(18px);
opacity: 0;
transform: translateY(4px);
transition:
opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card:hover {
transform: translate3d(0, -5px, 0);
z-index: 14;
box-shadow:
0 0 0 1.5px hsl(var(--primary) / 0.62),
0 0 28px hsl(var(--primary) / 0.34),
0 0 56px hsl(var(--primary) / 0.22),
0 16px 34px hsl(var(--foreground) / 0.22),
0 32px 60px hsl(var(--foreground) / 0.18);
border-color: hsl(var(--primary) / 0.72);
}
.ht-contract-card:hover::before {
opacity: 1;
}
.ht-contract-card:hover::after {
opacity: 0.95;
transform: translateY(0);
}
.ht-contract-card:active {
transform: translate3d(0, -2px, 0);
box-shadow:
0 5px 12px hsl(var(--foreground) / 0.10),
0 10px 20px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--ready {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.ht-contract-card--enter {
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--ht-card-enter-delay, 0ms);
}
.ht-contract-card--selecting {
transform-origin: 50% 100%;
animation: ht-card-select-wave 2200ms linear infinite both;
animation-delay: var(--ht-card-select-delay, 0ms);
}
.ht-contract-card--selecting:hover {
animation-play-state: paused;
}
.ht-contract-card--selecting.ht-contract-card--selected {
animation: none;
transform: translate3d(0, 0, 0) rotate(0deg);
}
.ht-contract-card--selected {
border-color: hsl(var(--primary));
transform: translate3d(0, -4px, 0);
box-shadow:
0 0 0 1px hsl(var(--primary) / 0.34),
0 12px 24px hsl(var(--primary) / 0.18),
0 22px 36px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--selected::before {
opacity: 1;
}
.ht-contract-card--selected::after {
opacity: 1;
transform: translateY(0);
}
@keyframes ht-card-slide-in {
from {
opacity: 0;
transform: translate3d(44px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes ht-card-select-wave {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
11% {
transform: translate3d(-0.4px, 0, 0) rotate(-0.7deg);
}
22% {
transform: translate3d(-0.9px, 0, 0) rotate(-1.6deg);
}
34% {
transform: translate3d(-1.2px, 0, 0) rotate(-2.3deg);
}
48% {
transform: translate3d(-0.2px, 0, 0) rotate(-0.4deg);
}
62% {
transform: translate3d(0.8px, 0, 0) rotate(1.5deg);
}
76% {
transform: translate3d(1.25px, 0, 0) rotate(2.35deg);
}
88% {
transform: translate3d(0.35px, 0, 0) rotate(0.65deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ht-contract-card--enter,
.ht-contract-card--selecting {
animation: none;
opacity: 1;
transform: none;
}
.ht-contract-card,
.ht-contract-card:hover,
.ht-contract-card:active,
.ht-contract-card--selected {
transition: none;
transform: none;
}
.ht-contract-card::before,
.ht-contract-card::after {
transition: none;
}
}
+172
View File
@@ -0,0 +1,172 @@
import {
cloneJson,
isContractRelatedForageKey,
isContractRelatedKeyedStateKey,
isRecord,
SERVICE_PRICING_METHODS
} from '@/lib/contractSegment'
type AnyRecord = Record<string, unknown>
export interface KvStoreLike {
keys: () => Promise<string[]>
getItem: (key: string) => Promise<unknown>
}
export interface ZxFwPricingStoreLike {
contracts: Record<string, unknown>
servicePricingStates: Record<string, unknown>
htFeeMainStates: Record<string, unknown>
htFeeMethodStates: Record<string, unknown>
keyedStates: Record<string, unknown>
loadContract: (...args: any[]) => Promise<unknown>
getContractState: (...args: any[]) => unknown
setContractState: (...args: any[]) => Promise<unknown>
setServicePricingMethodState: (...args: any[]) => unknown
setHtFeeMainState: (...args: any[]) => unknown
setHtFeeMethodState: (...args: any[]) => unknown
setKeyState: (...args: any[]) => unknown
}
export const buildContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
const payload = {
contracts: {} as AnyRecord,
servicePricingStates: {} as AnyRecord,
htFeeMainStates: {} as AnyRecord,
htFeeMethodStates: {} as AnyRecord
}
if (idSet.size === 0) return payload
await Promise.all(Array.from(idSet).map(id => store.loadContract(id)))
for (const contractId of idSet) {
const contractState = store.getContractState(contractId)
if (contractState) {
payload.contracts[contractId] = cloneJson(contractState)
}
const servicePricingState = store.servicePricingStates[contractId]
if (isRecord(servicePricingState)) {
payload.servicePricingStates[contractId] = cloneJson(servicePricingState)
}
const mainPrefix = `htExtraFee-${contractId}-`
for (const [mainKey, mainState] of Object.entries(store.htFeeMainStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMainStates[mainKey] = cloneJson(mainState)
}
for (const [mainKey, methodState] of Object.entries(store.htFeeMethodStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMethodStates[mainKey] = cloneJson(methodState)
}
}
return payload
}
export const applyImportedContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
piniaPayload: unknown,
oldToNewIdMap: Map<string, string>
) => {
if (!isRecord(piniaPayload)) return
const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null
if (!zxFwPayload) return
const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {}
const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {}
const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {}
const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {}
for (const [oldId, newId] of oldToNewIdMap.entries()) {
const rawContractState = contractsMap[oldId]
if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) {
await store.setContractState(newId, rawContractState as any)
}
const rawServicePricingByService = servicePricingStatesMap[oldId]
if (isRecord(rawServicePricingByService)) {
for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) {
if (!isRecord(rawServiceMethods)) continue
for (const method of SERVICE_PRICING_METHODS) {
const methodState = rawServiceMethods[method]
if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue
store.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true })
}
}
}
const oldMainPrefix = `htExtraFee-${oldId}-`
const newMainPrefix = `htExtraFee-${newId}-`
for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
store.setHtFeeMainState(newMainKey, rawMainState as any, { force: true })
}
for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawByRow)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
for (const [rowId, rawByMethod] of Object.entries(rawByRow)) {
if (!isRecord(rawByMethod)) continue
const ratePayload = rawByMethod['rate-fee']
const hourlyPayload = rawByMethod['hourly-fee']
const quantityPayload = rawByMethod['quantity-unit-price-fee']
if (ratePayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true })
}
if (hourlyPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true })
}
if (quantityPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true })
}
}
}
}
}
export const readContractRelatedForageEntries = async (
kvStore: KvStoreLike,
contractIds: string[]
) => {
const keys = await kvStore.keys()
const idSet = new Set(contractIds)
const targetKeys = keys.filter(key => {
for (const id of idSet) {
if (isContractRelatedForageKey(key, id)) return true
}
return false
})
return Promise.all(
targetKeys.map(async key => ({
key,
value: await kvStore.getItem(key)
}))
)
}
export const readContractRelatedKeyedEntries = (
store: Pick<ZxFwPricingStoreLike, 'keyedStates'>,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
return Object.entries(store.keyedStates)
.filter(([key]) => {
for (const id of idSet) {
if (isContractRelatedKeyedStateKey(key, id)) return true
}
return false
})
.map(([key, value]) => ({
key,
value: cloneJson(value)
}))
}
+39
View File
@@ -0,0 +1,39 @@
export interface XmBaseInfoState {
projectIndustry?: string
}
export interface XmScaleState {
detailRows?: unknown[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
export interface HtFeeMainRowLike {
id?: unknown
}
export interface RateMethodStateLike {
budgetFee?: unknown
}
export interface HourlyMethodRowLike {
serviceBudget?: unknown
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
}
export interface HourlyMethodStateLike {
detailRows?: HourlyMethodRowLike[]
}
export interface QuantityMethodRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
export interface QuantityMethodStateLike {
detailRows?: QuantityMethodRowLike[]
}
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import HourlyFeeGrid from '@/components/shared/HourlyFeeGrid.vue' import { useI18n } from 'vue-i18n'
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
const props = defineProps<{ const props = defineProps<{
contractId: string contractId: string
@@ -8,11 +9,12 @@ const props = defineProps<{
}>() }>()
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
const { t } = useI18n()
</script> </script>
<template> <template>
<HourlyFeeGrid <HourlyFeeGrid
title="工时法明细" :title="t('hourlyFeeGrid.title')"
:storage-key="DB_KEY" :storage-key="DB_KEY"
:contract-id="props.contractId" :contract-id="props.contractId"
:service-id="props.serviceId" :service-id="props.serviceId"
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
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { createScaleAutoGroupColumn } from '@/lib/pricingScaleColumns'
import { buildScaleDetailDict, buildScaleIdLabelMap } from '@/lib/pricingScaleDict'
import { parseProjectIndexFromPathKey } from '@/lib/pricingScaleLink'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { getMajorDictEntries } from '@/sql'
import type { ScaleDetailRow } from '@/types/pricing'
const props = defineProps<{
contractId: string
serviceId: string | number
method: 'investScale' | 'landScale'
}>()
type MajorLite = {
code: string
name: string
defCoe: number | null
hasCost?: boolean
hasArea?: boolean
}
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
const gridApi = ref<GridApi<ScaleDetailRow> | null>(null)
let autoHeightTimer: ReturnType<typeof setTimeout> | null = null
const isInvestmentFormula = computed(() => props.method === 'investScale')
const methodLabel = computed(() =>
isInvestmentFormula.value
? t('zxFwView.categories.investmentScaleFormula')
: t('zxFwView.categories.landScaleFormula')
)
const ensureMethodStateLoaded = async () => {
await zxFwPricingStore.loadServicePricingMethodState<ScaleDetailRow>(
props.contractId,
props.serviceId,
props.method
)
}
const methodState = computed(() =>
zxFwPricingStore.getServicePricingMethodState<ScaleDetailRow>(
props.contractId,
props.serviceId,
props.method
)
)
const rowData = computed<ScaleDetailRow[]>(() => {
const rows = methodState.value?.detailRows
return Array.isArray(rows) ? rows : []
})
const idLabelMap = computed(() => {
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
const detailDict = buildScaleDetailDict(
majorEntries,
isInvestmentFormula.value
? ({ hasCost, hasArea }) => hasCost && !hasArea
: ({ hasArea }) => hasArea
)
return buildScaleIdLabelMap(detailDict)
})
const numberFormatter = (params: { value?: unknown }) =>
typeof params.value === 'number' && Number.isFinite(params.value)
? formatThousandsFlexible(params.value, 3)
: ''
const scaleValueColumnField = computed<keyof ScaleDetailRow>(() =>
isInvestmentFormula.value ? 'amount' : 'landArea'
)
const scaleValueColumnHeader = computed(() =>
isInvestmentFormula.value
? t('pricingScale.columns.investAmount')
: t('pricingScale.columns.landArea')
)
const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() =>
withReadonlyAutoHeight<ScaleDetailRow>([
{
headerName: scaleValueColumnHeader.value,
field: scaleValueColumnField.value,
minWidth: 130,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: numberFormatter
},
{
headerName: t('pricingScale.columns.basicWork'),
field: 'benchmarkBudgetBasic',
minWidth: 130,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: numberFormatter
},
{
headerName: t('zxFwView.formulaColumns.basicFormula'),
field: 'basicFormula',
minWidth: 260,
flex: 2
},
{
headerName: t('pricingScale.columns.optionalWork'),
field: 'benchmarkBudgetOptional',
minWidth: 130,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: numberFormatter
},
{
headerName: t('zxFwView.formulaColumns.optionalFormula'),
field: 'optionalFormula',
minWidth: 260,
flex: 2
}
]) as ColDef<ScaleDetailRow>[]
)
const autoGroupColumnDef = computed<ColDef<ScaleDetailRow>>(() =>
createScaleAutoGroupColumn<ScaleDetailRow>({
totalLabel: methodLabel.value,
idLabelMap: idLabelMap.value,
parseProjectIndexFromPathKey
})
)
const detailGridOptions: GridOptions<ScaleDetailRow> = {
...gridOptions
}
const syncAutoRowHeights = async () => {
await nextTick()
const api = gridApi.value
if (!api || api.isDestroyed?.()) return
api.resetRowHeights()
api.onRowHeightChanged()
api.refreshCells({ force: true })
}
const scheduleAutoRowHeights = () => {
if (autoHeightTimer) clearTimeout(autoHeightTimer)
autoHeightTimer = setTimeout(() => {
autoHeightTimer = null
void syncAutoRowHeights()
}, 0)
}
const onGridReady = (event: GridReadyEvent<ScaleDetailRow>) => {
gridApi.value = event.api
scheduleAutoRowHeights()
}
watch(rowData, () => {
scheduleAutoRowHeights()
}, { deep: true })
watch(() => props.method, () => {
void ensureMethodStateLoaded()
scheduleAutoRowHeights()
})
onMounted(() => {
void ensureMethodStateLoaded()
})
onActivated(() => {
void ensureMethodStateLoaded()
scheduleAutoRowHeights()
})
onBeforeUnmount(() => {
if (autoHeightTimer) clearTimeout(autoHeightTimer)
})
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-foreground">{{ methodLabel }}</h3>
<p class="text-xs text-muted-foreground">{{ t('zxFwView.formulaColumns.subtitle') }}</p>
</div>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:theme="myTheme"
:style="agGridStyle"
:row-data="rowData"
:column-defs="columnDefs"
:auto-group-column-def="autoGroupColumnDef"
:grid-options="detailGridOptions"
:locale-text="AG_GRID_LOCALE_CN"
@grid-ready="onGridReady"
/>
</div>
</div>
</template>
@@ -0,0 +1,995 @@
<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,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue' 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 { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community' import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toDecimal } from '@/lib/decimal' import { roundTo, toDecimal } from '@/lib/decimal'
@@ -44,6 +46,7 @@ const props = defineProps<{
htMethodType?: 'quantity-unit-price-fee' htMethodType?: 'quantity-unit-price-fee'
}>() }>()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t } = useI18n()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed' const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
@@ -60,7 +63,7 @@ const createDefaultRow = (): FeeRow => ({
const createSubtotalRow = (): FeeRow => ({ const createSubtotalRow = (): FeeRow => ({
id: SUBTOTAL_ROW_ID, id: SUBTOTAL_ROW_ID,
feeItem: '小计', feeItem: t('htFeeDetail.subtotal'),
unit: '', unit: '',
quantity: null, quantity: null,
unitPrice: null, unitPrice: null,
@@ -122,7 +125,7 @@ const deleteRow = (id: string) => {
const requestDeleteRow = (id: string, name?: string) => { const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行' pendingDeleteRowName.value = String(name || '').trim() || t('htFeeDetail.currentRow')
deleteConfirmOpen.value = true deleteConfirmOpen.value = true
} }
@@ -141,19 +144,19 @@ const confirmDeleteRow = () => {
const formatEditableText = (params: any) => { const formatEditableText = (params: any) => {
if (isSubtotalRow(params.data)) return '' if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return String(params.value) return String(params.value)
} }
const formatEditableQuantity = (params: any) => { const formatEditableQuantity = (params: any) => {
if (isSubtotalRow(params.data)) return '' if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
const formatEditableUnitPrice = (params: any) => { const formatEditableUnitPrice = (params: any) => {
if (isSubtotalRow(params.data)) return '' if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
@@ -183,7 +186,7 @@ const syncComputedValuesToRows = () => {
} }
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row)) const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
if (subtotalRow) { if (subtotalRow) {
subtotalRow.feeItem = '小计' subtotalRow.feeItem = t('htFeeDetail.subtotal')
subtotalRow.unit = '' subtotalRow.unit = ''
subtotalRow.quantity = null subtotalRow.quantity = null
subtotalRow.unitPrice = null subtotalRow.unitPrice = null
@@ -228,7 +231,8 @@ const saveToIndexedDB = async () => {
props.htMainStorageKey!, props.htMainStorageKey!,
props.htRowId!, props.htRowId!,
props.htMethodType!, props.htMethodType!,
payload payload,
{ force: true }
) )
} else { } else {
zxFwPricingStore.setKeyState(props.storageKey, payload) zxFwPricingStore.setKeyState(props.storageKey, payload)
@@ -258,7 +262,7 @@ const loadFromIndexedDB = async () => {
const columnDefs: ColDef<FeeRow>[] = [ const columnDefs: ColDef<FeeRow>[] = [
{ {
headerName: '序号', headerName: t('htFeeDetail.columns.no'),
colId: 'rowNo', colId: 'rowNo',
minWidth: 68, minWidth: 68,
maxWidth: 80, maxWidth: 80,
@@ -271,14 +275,14 @@ const columnDefs: ColDef<FeeRow>[] = [
params.node?.rowPinned params.node?.rowPinned
? '' ? ''
: isSubtotalRow(params.data) : isSubtotalRow(params.data)
? '小计' ? t('htFeeDetail.subtotal')
: typeof params.node?.rowIndex === 'number' : typeof params.node?.rowIndex === 'number'
? params.node.rowIndex + 1 ? params.node.rowIndex + 1
: '', : '',
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1) colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
}, },
{ {
headerName: '费用项', headerName: t('htFeeDetail.columns.feeItem'),
field: 'feeItem', field: 'feeItem',
minWidth: 140, minWidth: 140,
flex: 1.4, flex: 1.4,
@@ -291,7 +295,7 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '单位', headerName: t('htFeeDetail.columns.unit'),
field: 'unit', field: 'unit',
minWidth: 90, minWidth: 90,
flex: 0.9, flex: 0.9,
@@ -303,45 +307,49 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '数量', headerName: t('htFeeDetail.columns.quantity'),
field: 'quantity', field: 'quantity',
minWidth: 100, minWidth: 100,
flex: 1, flex: 1,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line', cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data), editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableQuantity, valueFormatter: formatEditableQuantity,
cellClassRules: { cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
}, },
{ {
headerName: '单价(元)', headerName: t('htFeeDetail.columns.unitPrice'),
field: 'unitPrice', field: 'unitPrice',
minWidth: 120, minWidth: 120,
flex: 1.1, flex: 1.1,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line', cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data), editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice, valueFormatter: formatEditableUnitPrice,
cellClassRules: { cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
}, },
{ {
headerName: '预算费用(元)', headerName: t('htFeeDetail.columns.budgetFee'),
field: 'budgetFee', field: 'budgetFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
editable: false, editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: formatReadonlyBudgetFee valueFormatter: formatReadonlyBudgetFee
}, },
{ {
headerName: '说明', headerName: t('htFeeDetail.columns.remark'),
field: 'remark', field: 'remark',
minWidth: 170, minWidth: 170,
flex: 2, flex: 2,
@@ -357,7 +365,7 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '操作', headerName: t('htFeeDetail.columns.actions'),
field: 'actions', field: 'actions',
minWidth: 92, minWidth: 92,
maxWidth: 110, maxWidth: 110,
@@ -391,13 +399,14 @@ const columnDefs: ColDef<FeeRow>[] = [
'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', '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 onClick: onDelete
}, },
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')] [h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', t('common.delete'))]
) )
} }
} }
}) })
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<FeeRow> = { const detailGridOptions: GridOptions<FeeRow> = {
...gridOptions, ...gridOptions,
@@ -408,12 +417,52 @@ const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
gridApi.value = event.api gridApi.value = event.api
} }
const handleCellValueChanged = () => { let isBulkClipboardMutation = false
const commitGridChanges = () => {
syncComputedValuesToRows() syncComputedValuesToRows()
gridApi.value?.refreshCells({ force: true }) gridApi.value?.refreshCells({ force: true })
void saveToIndexedDB() 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 () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
}) })
@@ -430,6 +479,7 @@ watch(
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
gridApi.value?.stopEditing()
gridApi.value = null gridApi.value = null
void saveToIndexedDB() void saveToIndexedDB()
}) })
@@ -440,16 +490,17 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 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"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button> <Button type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeDetail.addRow') }}</Button>
</div> </div>
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue <AgGridVue
:style="agGridStyle" :style="agGridStyle"
:rowData="detailRows" :rowData="detailRows"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:gridOptions="detailGridOptions" :gridOptions="detailGridOptions"
:theme="myTheme" :theme="myTheme"
:animateRows="true"
:treeData="false" :treeData="false"
:localeText="AG_GRID_LOCALE_CN" :localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500" :tooltipShowDelay="500"
@@ -458,10 +509,16 @@ onBeforeUnmount(() => {
:suppressRowVirtualisation="true" :suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }" :cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true" :enableClipboard="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20" :undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady" @grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" @cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
/> />
</div> </div>
</div> </div>
@@ -471,16 +528,16 @@ onBeforeUnmount(() => {
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <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"> <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">确认删除行</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('htFeeDetail.dialog.deleteTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将删除“{{ pendingDeleteRowName }}”这条明细,是否继续? {{ t('htFeeDetail.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button> <Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue' 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 { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community' import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' 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 { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { Pencil, Eraser } from 'lucide-vue-next' import { Pencil, Eraser } from 'lucide-vue-next'
@@ -77,6 +80,7 @@ const props = defineProps<{
contractName?: string contractName?: string
fixedNames?: any[] fixedNames?: any[]
}>() }>()
const { t } = useI18n()
const tabStore = useTabStore() const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
@@ -90,32 +94,17 @@ const createDefaultRow = (name = ''): FeeMethodRow => ({
const SUMMARY_ROW_ID = 'fee-method-summary' const SUMMARY_ROW_ID = 'fee-method-summary'
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
const toFinite = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? value : 0
const round3 = (value: number) => Number(value.toFixed(3))
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve') const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => { const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
let hasValid = false const total = sumNullableNumbers(rows.map(row => pick(row)))
let total = 0 return total == null ? null : roundTo(total, 3)
for (const row of rows) {
const value = pick(row)
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total += value
hasValid = true
}
return hasValid ? round3(total) : null
} }
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => { const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
if (!row) return null if (!row) return null
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee] const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
const hasValid = values.some(value => typeof value === 'number' && Number.isFinite(value)) const total = sumNullableNumbers(values)
if (!hasValid) return null if (total == null) return null
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee)) return roundTo(total, 3)
}
const toFiniteUnknown = (value: unknown): number | null => {
if (value == null || value === '') return null
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
} }
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => { const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
@@ -141,12 +130,12 @@ const loadContractServiceFeeBase = async (): Promise<number | null> => {
await zxFwPricingStore.loadContract(contractId) await zxFwPricingStore.loadContract(contractId)
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId) const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
if (!isReserveStorageKey(props.storageKey)) { if (!isReserveStorageKey(props.storageKey)) {
return serviceBase == null ? null : round3(serviceBase) return serviceBase == null ? null : roundTo(serviceBase, 3)
} }
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId) const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
const hasAnyBase = serviceBase != null || additionalFeeTotal != null const hasAnyBase = serviceBase != null || additionalFeeTotal != null
if (!hasAnyBase) return null if (!hasAnyBase) return null
return round3(toFinite(serviceBase) + toFinite(additionalFeeTotal)) return roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalFeeTotal), 3)
} catch (error) { } catch (error) {
console.error('loadContractServiceFeeBase failed:', error) console.error('loadContractServiceFeeBase failed:', error)
return null return null
@@ -163,21 +152,21 @@ const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
let hasValid = false let hasValid = false
for (const row of rows) { for (const row of rows) {
const rowBudget = toFiniteUnknown(row?.serviceBudget) const rowBudget = toFiniteNumber(row?.serviceBudget)
if (rowBudget != null) { if (rowBudget != null) {
total += rowBudget total += rowBudget
hasValid = true hasValid = true
continue continue
} }
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice) const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteUnknown(row?.personnelCount) const personnel = toFiniteNumber(row?.personnelCount)
const workday = toFiniteUnknown(row?.workdayCount) const workday = toFiniteNumber(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday total += adopted * personnel * workday
hasValid = true hasValid = true
} }
return hasValid ? round3(total) : null return hasValid ? roundTo(total, 3) : null
} }
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => { const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
@@ -188,19 +177,19 @@ const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null
let hasValid = false let hasValid = false
for (const row of rows) { for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteUnknown(row?.budgetFee) const budget = toFiniteNumber(row?.budgetFee)
if (budget != null) { if (budget != null) {
total += budget total += budget
hasValid = true hasValid = true
continue continue
} }
const quantity = toFiniteUnknown(row?.quantity) const quantity = toFiniteNumber(row?.quantity)
const unitPrice = toFiniteUnknown(row?.unitPrice) const unitPrice = toFiniteNumber(row?.unitPrice)
if (quantity == null || unitPrice == null) continue if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice total += quantity * unitPrice
hasValid = true hasValid = true
} }
return hasValid ? round3(total) : null return hasValid ? roundTo(total, 3) : null
} }
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => { const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
@@ -215,13 +204,13 @@ const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMet
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee') zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
]) ])
const storedRateFee = toFiniteUnknown(rateData?.budgetFee) const storedRateFee = toFiniteNumber(rateData?.budgetFee)
const rateValue = toFiniteUnknown(rateData?.rate) const rateValue = toFiniteNumber(rateData?.rate)
const rateFee = const rateFee =
contractBase != null && rateValue != null contractBase != null && rateValue != null
? round3(contractBase * rateValue) ? roundTo(contractBase * rateValue / 100, 2)
: storedRateFee != null : storedRateFee != null
? round3(storedRateFee) ? roundTo(storedRateFee, 2)
: null : null
const hourlyFee = sumHourlyMethodFee(hourlyData) const hourlyFee = sumHourlyMethodFee(hourlyData)
@@ -244,6 +233,14 @@ const fixedNames = computed(() =>
: [] : []
) )
const hasFixedNames = computed(() => fixedNames.value.length > 0) 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[]>({ const detailRows = computed<FeeMethodRow[]>({
get: () => { get: () => {
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
@@ -261,7 +258,7 @@ const summaryRow = computed<FeeMethodRow>(() => {
const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee) const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee)
const result: FeeMethodRow = { const result: FeeMethodRow = {
id: SUMMARY_ROW_ID, id: SUMMARY_ROW_ID,
name: '小计', name: t('htFeeGrid.subtotal'),
rateFee, rateFee,
hourlyFee, hourlyFee,
quantityUnitPriceFee quantityUnitPriceFee
@@ -278,7 +275,7 @@ const lastSavedSnapshot = ref('')
const requestClearRow = (id: string, name?: string) => { const requestClearRow = (id: string, name?: string) => {
pendingClearRowId.value = id pendingClearRowId.value = id
pendingClearRowName.value = String(name || '').trim() || '当前行' pendingClearRowName.value = String(name || '').trim() || t('htFeeGrid.currentRow')
clearConfirmOpen.value = true clearConfirmOpen.value = true
} }
@@ -322,7 +319,7 @@ const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
typeof row.unitPrice === 'number' && typeof row.unitPrice === 'number' &&
Number.isFinite(row.unitPrice) Number.isFinite(row.unitPrice)
) { ) {
return Number((row.quantity * row.unitPrice).toFixed(2)) return roundTo(row.quantity * row.unitPrice, 2)
} }
return null return null
} }
@@ -351,13 +348,14 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
}) })
if (hasFixedNames.value) { if (hasFixedNames.value) {
const byId = new Map(rows.map(row => [String(row.id || ''), row]))
const byName = new Map(rows.map(row => [row.name, row])) const byName = new Map(rows.map(row => [row.name, row]))
return fixedNames.value.map((item, index) => { return fixedNames.value.map((item, index) => {
const fromDb = byName.get(item.name) const rowId = String(item?.id || `fee-method-fixed-${index}`)
const fromDb = byId.get(rowId) || byName.get(item.name)
return { return {
id: item?.id || `fee-method-fixed-${index}`, id: rowId,
name:item.name, name: item.name,
rateFee: fromDb?.rateFee ?? null, rateFee: fromDb?.rateFee ?? null,
hourlyFee: fromDb?.hourlyFee ?? null, hourlyFee: fromDb?.hourlyFee ?? null,
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
@@ -424,10 +422,9 @@ const clearRow = async (id: string) => {
const editRow = (id: string) => { const editRow = (id: string) => {
const row = detailRows.value.find(item => item.id === id) const row = detailRows.value.find(item => item.id === id)
if (!row) return if (!row) return
console.log(id)
tabStore.openTab({ tabStore.openTab({
id: `ht-fee-edit-${props.storageKey}-${id}`, id: `ht-fee-edit-${props.storageKey}-${id}`,
title: `费用编辑-${row.name || '未命名'}`, title: t('htFeeGrid.editTabTitle', { name: row.name || t('htFeeGrid.unnamed') }),
componentName: 'HtFeeMethodTypeLineView', componentName: 'HtFeeMethodTypeLineView',
props: { props: {
sourceTitle: props.title, sourceTitle: props.title,
@@ -454,7 +451,7 @@ const ActionCellRenderer = defineComponent({
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => { const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const rowId = props.params.data?.id const rowId = String(props.params.data?.id || '').trim()
if (!rowId) return if (!rowId) return
if (action === 'edit') { if (action === 'edit') {
props.params.context?.onActionEdit?.(rowId) props.params.context?.onActionEdit?.(rowId)
@@ -471,7 +468,7 @@ const ActionCellRenderer = defineComponent({
onClick: onActionClick('edit') onClick: onActionClick('edit')
}, [ }, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }), h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', '编辑') h('span', t('htFeeGrid.edit'))
]), ]),
h('button', { h('button', {
class: 'zxfw-action-btn zxfw-action-btn--danger', class: 'zxfw-action-btn zxfw-action-btn--danger',
@@ -480,7 +477,7 @@ const ActionCellRenderer = defineComponent({
onClick: onActionClick('clear') onClick: onActionClick('clear')
}, [ }, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }), h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '清空') h('span', t('htFeeGrid.clear'))
]) ])
]) ])
]) ])
@@ -490,7 +487,7 @@ const ActionCellRenderer = defineComponent({
const columnDefs: ColDef<FeeMethodRow>[] = [ const columnDefs: ColDef<FeeMethodRow>[] = [
{ {
headerName: '名字', headerName: t('htFeeGrid.columns.name'),
field: 'name', field: 'name',
minWidth: 180, minWidth: 180,
flex: 1.8, flex: 1.8,
@@ -505,60 +502,62 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
} }
}, },
{ {
headerName: '费率计取', headerName: t('htFeeGrid.columns.rateFee'),
field: 'rateFee', field: 'rateFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
editable: false, editable: false,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue), valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber, valueFormatter: formatEditableNumber,
cellClassRules: { cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
}, },
{ {
headerName: '工时法', headerName: t('htFeeGrid.columns.hourlyFee'),
field: 'hourlyFee', field: 'hourlyFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
editable: false, editable: false,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue), valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber, valueFormatter: formatEditableNumber,
cellClassRules: { cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
}, },
{ {
headerName: '数量单价', headerName: t('htFeeGrid.columns.quantityUnitPriceFee'),
field: 'quantityUnitPriceFee', field: 'quantityUnitPriceFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
editable: false, editable: false,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue), valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber, valueFormatter: formatEditableNumber,
cellClassRules: { cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
}, },
{ {
headerName: '小计', headerName: t('htFeeGrid.columns.subtotal'),
field: 'subtotal', field: 'subtotal',
minWidth: 140, minWidth: 140,
flex: 1.2, flex: 1.2,
editable: false, editable: false,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell', cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => getRowSubtotal(params.data), valueGetter: params => getRowSubtotal(params.data),
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
}, },
{ {
headerName: '操作', headerName: t('htFeeGrid.columns.actions'),
field: 'actions', field: 'actions',
minWidth: 220, minWidth: 220,
flex: 1.6, flex: 1.6,
@@ -570,6 +569,7 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
cellRenderer: ActionCellRenderer cellRenderer: ActionCellRenderer
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<FeeMethodRow> = { const detailGridOptions: GridOptions<FeeMethodRow> = {
...gridOptions, ...gridOptions,
@@ -610,11 +610,27 @@ const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
gridApi.value = event.api gridApi.value = event.api
} }
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => { let isBulkClipboardMutation = false
if (isSummaryRow(event.data)) return
const commitGridChanges = () => {
void saveToIndexedDB() 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 () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
}) })
@@ -624,27 +640,19 @@ onActivated(() => {
}) })
const storageKeyRef = computed(() => props.storageKey) const storageKeyRef = computed(() => props.storageKey)
const reserveAdditionalWorkKeyVersion = computed(() => { const reserveAdditionalWorkStateSignature = computed(() => {
if (!isReserveStorageKey(props.storageKey)) return 0 if (!isReserveStorageKey(props.storageKey)) return ''
const contractId = String(props.contractId || '').trim() const contractId = String(props.contractId || '').trim()
if (!contractId) return 0 if (!contractId) return ''
const additionalStorageKey = `htExtraFee-${contractId}-additional-work` const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
return zxFwPricingStore.getKeyVersion(additionalStorageKey) return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
}) })
watch(storageKeyRef, () => { watch(storageKeyRef, () => {
scheduleReloadFromStorage() scheduleReloadFromStorage()
}) })
watch( watch(
() => () => JSON.stringify(zxFwPricingStore.htFeeMethodStates[props.storageKey] || null),
detailRows.value
.map(row => {
const rateKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'rate-fee')
const hourlyKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'hourly-fee')
const quantityKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'quantity-unit-price-fee')
return `${row.id}:${zxFwPricingStore.getKeyVersion(rateKey)}:${zxFwPricingStore.getKeyVersion(hourlyKey)}:${zxFwPricingStore.getKeyVersion(quantityKey)}`
})
.join('|'),
(nextSignature, prevSignature) => { (nextSignature, prevSignature) => {
if (!nextSignature && !prevSignature) return if (!nextSignature && !prevSignature) return
if (nextSignature === prevSignature) return if (nextSignature === prevSignature) return
@@ -655,19 +663,19 @@ watch(
watch( watch(
() => { () => {
const contractId = String(props.contractId || '').trim() const contractId = String(props.contractId || '').trim()
if (!contractId) return 0 if (!contractId) return ''
return zxFwPricingStore.contractVersions[contractId] || 0 return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
}, },
(nextVersion, prevVersion) => { (nextSig, prevSig) => {
if (nextVersion === prevVersion || nextVersion === 0) return if (nextSig === prevSig) return
scheduleReloadFromStorage() scheduleReloadFromStorage()
} }
) )
watch( watch(
reserveAdditionalWorkKeyVersion, reserveAdditionalWorkStateSignature,
(nextVersion, prevVersion) => { (nextSig, prevSig) => {
if (nextVersion === prevVersion) return if (nextSig === prevSig) return
scheduleReloadFromStorage() scheduleReloadFromStorage()
} }
) )
@@ -681,6 +689,20 @@ watch([hasFixedNames], () => {
gridApi.value?.refreshCells({ force: true }) 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(() => { onBeforeUnmount(() => {
if (reloadTimer) clearTimeout(reloadTimer) if (reloadTimer) clearTimeout(reloadTimer)
gridApi.value = null gridApi.value = null
@@ -693,16 +715,17 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 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"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button> <Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeGrid.add') }}</Button>
</div> </div>
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue <AgGridVue
:style="agGridStyle" :style="agGridStyle"
:rowData="displayRows" :rowData="displayRows"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:gridOptions="detailGridOptions" :gridOptions="detailGridOptions"
:theme="myTheme" :theme="myTheme"
:animateRows="true"
:treeData="false" :treeData="false"
:localeText="AG_GRID_LOCALE_CN" :localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500" :tooltipShowDelay="500"
@@ -715,6 +738,10 @@ onBeforeUnmount(() => {
:undoRedoCellEditingLimit="20" :undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady" @grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" @cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
/> />
</div> </div>
</div> </div>
@@ -724,16 +751,16 @@ onBeforeUnmount(() => {
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <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"> <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">确认清空</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('htFeeGrid.dialog.clearTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空“{{ pendingClearRowName }}”及其编辑页面的可填和自动计算数据,是否继续? {{ t('htFeeGrid.dialog.clearDesc', { name: pendingClearRowName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmClearRow">确认清空</Button> <Button variant="destructive" @click="confirmClearRow">{{ t('htFeeGrid.dialog.confirmClear') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
title?: string title?: string
message: string message: string
}>(), }>(),
{ {
title: '该服务不适用当前计价方法' title: undefined
} }
) )
</script> </script>
@@ -15,7 +17,7 @@ const props = withDefaults(
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" 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"> <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 }}</p> <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> <p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p>
</div> </div>
</div> </div>
@@ -0,0 +1,203 @@
<!--
<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,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community' import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
interface DictItem { interface DictItem {
code: string code: string
@@ -30,6 +34,11 @@ interface GridState {
detailRows: FactorRow[] detailRows: FactorRow[]
} }
interface FactorChangeState {
changedRowIds: string[]
updatedAt: number
}
type DictSource = Record<string, DictItem> type DictSource = Record<string, DictItem>
const props = defineProps<{ const props = defineProps<{
@@ -41,10 +50,13 @@ const props = defineProps<{
excludeNotshowByZxflxs?: boolean excludeNotshowByZxflxs?: boolean
initBudgetValueFromStandard?: boolean initBudgetValueFromStandard?: boolean
}>() }>()
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore() const kvStore = useKvStore()
const detailRows = ref<FactorRow[]>([]) const detailRows = ref<FactorRow[]>([])
const gridApi = ref<GridApi<FactorRow> | null>(null) const gridApi = ref<GridApi<FactorRow> | null>(null)
const CHANGE_STORAGE_KEY = computed(() => `${props.storageKey}-change`)
const formatReadonlyFactor = (value: unknown) => { const formatReadonlyFactor = (value: unknown) => {
if (value == null || value === '') return '' if (value == null || value === '') return ''
@@ -54,7 +66,7 @@ const formatReadonlyFactor = (value: unknown) => {
} }
const formatEditableFactor = (params: any) => { const formatEditableFactor = (params: any) => {
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('xmFactorGrid.clickToInput')
const parsed = parseNumberOrNull(params.value, { precision: 3 }) const parsed = parseNumberOrNull(params.value, { precision: 3 })
if (parsed == null) return '' if (parsed == null) return ''
return String(Number(parsed)) return String(Number(parsed))
@@ -153,27 +165,32 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] =>
const columnDefs: ColDef<FactorRow>[] = [ const columnDefs: ColDef<FactorRow>[] = [
{ {
headerName: '标准系数', headerName: t('xmFactorGrid.columns.standardFactor'),
field: 'standardFactor', field: 'standardFactor',
type: 'numericColumn',
cellClass: 'ag-right-aligned-cell',
minWidth: 86, minWidth: 86,
maxWidth: 100, maxWidth: 100,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
flex: 0.9, flex: 0.9,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => formatReadonlyFactor(params.value) valueFormatter: params => formatReadonlyFactor(params.value)
}, },
{ {
headerName: '预算取值', headerName: t('xmFactorGrid.columns.budgetValue'),
field: 'budgetValue', field: 'budgetValue',
minWidth: 86, minWidth: 86,
maxWidth: 100, maxWidth: 100,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
cellClass: params => { cellClass: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell editable-cell-line' return disabled ? '' : 'editable-cell-line'
}, },
flex: 0.9, flex: 0.9,
cellClassRules: { cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => { 'editable-cell-empty': params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return !disabled && (params.value == null || params.value === '') return !disabled && (params.value == null || params.value === '')
@@ -191,7 +208,7 @@ const columnDefs: ColDef<FactorRow>[] = [
} }
}, },
{ {
headerName: '说明', headerName: t('xmFactorGrid.columns.remark'),
field: 'remark', field: 'remark',
minWidth: 170, minWidth: 170,
flex: 2.4, flex: 2.4,
@@ -200,16 +217,17 @@ const columnDefs: ColDef<FactorRow>[] = [
autoHeight: true, autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' }, cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: true, editable: true,
valueFormatter: params => params.value || '点击输入', valueFormatter: params => params.value || t('xmFactorGrid.clickToInput'),
cellClass: ' remark-wrap-cell', cellClass: ' remark-wrap-cell',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef<FactorRow> = { const autoGroupColumnDef: ColDef<FactorRow> = {
headerName: '专业编码以及工程专业名称', headerName: t('xmFactorGrid.columns.groupName'),
minWidth: 220, minWidth: 220,
flex: 2.2, flex: 2.2,
cellRendererParams: { cellRendererParams: {
@@ -228,17 +246,79 @@ const saveToIndexedDB = async () => {
const payload: GridState = { const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value)) detailRows: JSON.parse(JSON.stringify(detailRows.value))
} }
await kvStore.setItem(props.storageKey, payload) zxFwPricingStore.setKeyState(props.storageKey, payload)
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', 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> => { const loadGridState = async (storageKey: string): Promise<GridState | null> => {
if (!storageKey) return null if (!storageKey) return null
const data = await kvStore.getItem<GridState>(storageKey) const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
if (!data?.detailRows || !Array.isArray(data.detailRows)) return null if (piniaData?.detailRows && Array.isArray(piniaData.detailRows)) return piniaData
return data
// 兼容历史 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 () => { const loadFromIndexedDB = async () => {
@@ -273,13 +353,64 @@ const loadFromIndexedDB = async () => {
} }
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => { 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) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void flushGridPersist()
}, 500) }, 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) => { const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) { if (Array.isArray(params.value)) {
return JSON.stringify(params.value) return JSON.stringify(params.value)
@@ -288,6 +419,10 @@ const processCellForClipboard = (params: any) => {
} }
const processCellFromClipboard = (params: any) => { const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'budgetValue') {
return parseNumberOrNull(params.value, { precision: 3 })
}
try { try {
const parsed = JSON.parse(params.value) const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed if (Array.isArray(parsed)) return parsed
@@ -312,8 +447,9 @@ watch(
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value?.stopEditing()
gridApi.value = null gridApi.value = null
void saveToIndexedDB() void flushGridPersist()
}) })
</script> </script>
@@ -329,12 +465,17 @@ onBeforeUnmount(() => {
<AgGridVue <AgGridVue
:style="agGridStyle" :style="agGridStyle"
:rowData="detailRows" :rowData="detailRows"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:autoGroupColumnDef="autoGroupColumnDef" :autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions" :gridOptions="gridOptions"
:theme="myTheme" :theme="myTheme"
:animateRows="true"
:treeData="true" :treeData="true"
@cell-value-changed="handleCellValueChanged" @cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
:suppressColumnVirtualisation="true" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }" :cellSelection="{ handle: { mode: 'range' } }"
@@ -347,9 +488,11 @@ onBeforeUnmount(() => {
:processCellFromClipboard="processCellFromClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20" :undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -1,14 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { getIndustryDisplayName, getMajorDictEntries, isMajorIdInIndustryScope, xmProjectConfig } from '@/sql'
import { syncContractScaleToPricing, type ContractScaleSyncResult } from '@/lib/zxFwPricingSync'
import { SwitchRoot, SwitchThumb } from 'reka-ui' import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
import {
ToastAction,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport
} from 'reka-ui'
import { Button } from '@/components/ui/button'
@@ -41,14 +54,23 @@ interface XmBaseInfoState {
} }
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request' 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 } type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n()
const detailRows = ref<DetailRow[]>([]) const detailRows = ref<DetailRow[]>([])
const detailDict = ref<DictGroup[]>([]) const detailDict = ref<DictGroup[]>([])
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite]) const majorEntries = ref<Array<[string, MajorLite]>>([])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id])) 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 buildDetailDict = (entries: Array<[string, MajorLite]>) => {
const groupMap = new Map<string, DictGroup>() const groupMap = new Map<string, DictGroup>()
@@ -88,14 +110,35 @@ const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
hasArea: item.hasArea !== false hasArea: item.hasArea !== false
}) })
} }
const result = groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group)) return result
} }
const buildDefaultRows = (): DetailRow[] => { const buildDefaultRows = (): DetailRow[] => {
const rows: 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 group of detailDict.value) {
for (const child of group.children) { for (const child of group.children) {
@@ -122,7 +165,7 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
if (row?.isGroupRow === true) continue if (row?.isGroupRow === true) continue
const rowId = String(row.id) const rowId = String(row.id)
dbValueMap.set(rowId, row) dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId) const aliasId = majorIdAliasMap.value.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) { if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, row) dbValueMap.set(aliasId, row)
} }
@@ -208,12 +251,13 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
if (!activeIndustryId.value) { if (!activeIndustryId.value) {
detailDict.value = [] detailDict.value = []
detailRows.value = [] detailRows.value = []
lastPersistedLeafRows = []
roughCalcEnabled.value = false roughCalcEnabled.value = false
applyPinnedTotalAmount(api, null) applyPinnedTotalAmount(api, null)
return return
} }
const filteredEntries = majorEntries.filter(([id]) => const filteredEntries = majorEntries.value.filter(([id]) =>
isMajorIdInIndustryScope(id, activeIndustryId.value) isMajorIdInIndustryScope(id, activeIndustryId.value)
) )
detailDict.value = buildDetailDict(filteredEntries) detailDict.value = buildDetailDict(filteredEntries)
@@ -240,6 +284,7 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
Number.isFinite(contractData.totalAmount) Number.isFinite(contractData.totalAmount)
if (hasContractRows && !isLegacyEmptyScaleRows) { if (hasContractRows && !isLegacyEmptyScaleRows) {
detailRows.value = mergeWithDictRows(contractRows) detailRows.value = mergeWithDictRows(contractRows)
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
return return
} }
@@ -251,10 +296,12 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
if (Array.isArray(xmData?.detailRows) && xmData.detailRows.length > 0) { if (Array.isArray(xmData?.detailRows) && xmData.detailRows.length > 0) {
detailRows.value = mergeWithDictRows(xmData.detailRows) detailRows.value = mergeWithDictRows(xmData.detailRows)
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
return return
} }
} }
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows()
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
void saveToIndexedDB() void saveToIndexedDB()
@@ -262,6 +309,7 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
activeIndustryId.value = '' activeIndustryId.value = ''
detailRows.value = [] detailRows.value = []
lastPersistedLeafRows = []
roughCalcEnabled.value = false roughCalcEnabled.value = false
applyPinnedTotalAmount(api, null) applyPinnedTotalAmount(api, null)
} }
@@ -292,6 +340,18 @@ interface GridPersistState {
totalAmount?: number | null 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<{ const props = defineProps<{
title: string title: string
dbKey: string dbKey: string
@@ -302,19 +362,44 @@ const props = defineProps<{
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
const gridApi = ref<GridApi<DetailRow> | null>(null) const gridApi = ref<GridApi<DetailRow> | null>(null)
const activeIndustryId = ref('') const activeIndustryId = ref('')
const industryNameMap = new Map( const totalLabel = ref(xmProjectConfig.pinnedTotalLabel)
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资'
})
const roughCalcEnabled = ref(false) const roughCalcEnabled = ref(false)
const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) }) 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 = () => { const refreshPinnedTotalLabelCell = () => {
if (!gridApi.value) return if (!gridApi.value) return
const pinnedTopNode = gridApi.value.getPinnedTopRow(0) const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
@@ -327,7 +412,7 @@ const refreshPinnedTotalLabelCell = () => {
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ {
headerName: '造价金额(万元)', headerName: t('pricingScale.columns.investAmount'),
field: 'amount', field: 'amount',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 100, minWidth: 100,
@@ -338,76 +423,63 @@ const columnDefs: ColDef<DetailRow>[] = [
}, },
cellClass: params => cellClass: params =>
roughCalcEnabled.value && params.node?.rowPinned roughCalcEnabled.value && params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line' ? 'editable-cell-line'
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost : !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line' ? 'editable-cell-line'
: 'ag-right-aligned-cell', : '',
cellClassRules: { cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => 'editable-cell-empty': params =>
roughCalcEnabled.value && params.node?.rowPinned roughCalcEnabled.value && params.node?.rowPinned
? params.value == null || params.value === '' ? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '') : !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
}, },
aggFunc: decimalAggSum, aggFunc: decimalAggSum,
valueParser: params => { valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => { valueFormatter: params => {
if (roughCalcEnabled.value) { if (roughCalcEnabled.value) {
if (!params.node?.rowPinned) return '' if (!params.node?.rowPinned) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('pricingScale.clickToInput')
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) { if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return '' return ''
} }
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('pricingScale.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
}, },
{ {
headerName: '用地面积(亩)', headerName: '造价金额(元)',
field: 'landArea', field: 'amountYuan',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 100, minWidth: 120,
flex: 1, flex: 1,
editable: params => !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea), editable: false,
cellClass: params => aggFunc: decimalAggSum,
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea valueGetter: params => params.data?.amount,
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'ag-right-aligned-cell': () => true
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
},
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 3) : null
}, },
valueFormatter: params => { valueFormatter: params => {
if (roughCalcEnabled.value) { if (roughCalcEnabled.value) {
return '' return ''
} }
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) { const amount = params.value
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
return '' return ''
} }
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { return formatThousandsFlexible(roundTo(amount * 10000, 0), 0)
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
} }
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef = { const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称', headerName: t('pricingScale.columns.majorGroup'),
minWidth: 200, minWidth: 200,
flex: 2, flex: 2,
cellRendererParams: { cellRendererParams: {
@@ -437,14 +509,50 @@ const pinnedTopRowData = ref<DetailRow[]>([
path: ['TOTAL'] path: ['TOTAL']
} }
]) ])
const syncToastOpen = ref(false)
const syncToastText = ref('')
let lastPersistedLeafRows: DetailRow[] | null = null
const saveToIndexedDB = async () => { const cloneLeafRows = (rows: DetailRow[]) =>
try { rows.map(row => ({
const leafRows = detailRows.value.map(row => ({
...JSON.parse(JSON.stringify(row)), ...JSON.parse(JSON.stringify(row)),
hide: Boolean(row.hide), hide: Boolean(row.hide),
isGroupRow: false 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 = (() => { const totalAmountFromRows = (() => {
let hasValue = false let hasValue = false
let total = 0 let total = 0
@@ -469,6 +577,20 @@ const saveToIndexedDB = async () => {
payload.roughCalcEnabled = roughCalcEnabled.value payload.roughCalcEnabled = roughCalcEnabled.value
payload.totalAmount = normalizedTotalAmount payload.totalAmount = normalizedTotalAmount
await kvStore.setItem(props.dbKey, payload) 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) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
} }
@@ -481,6 +603,12 @@ const schedulePersist = () => {
}, 600) }, 600)
} }
const getRowId = (params: { data?: DetailRow }) => String(params.data?.id || '')
const detailGridOptions: GridOptions<DetailRow> = {
...gridOptions,
getRowId
}
const handleFlushPersistRequest = (event: Event) => { const handleFlushPersistRequest = (event: Event) => {
const customEvent = event as CustomEvent<{ done?: () => void }> const customEvent = event as CustomEvent<{ done?: () => void }>
const done = customEvent?.detail?.done const done = customEvent?.detail?.done
@@ -500,6 +628,24 @@ const setDetailRowsHidden = (hidden: boolean) => {
} }
let oldValue:number|null 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) => { const onRoughCalcSwitch = (checked: boolean) => {
gridApi.value?.stopEditing(true) gridApi.value?.stopEditing(true)
roughCalcEnabled.value = checked roughCalcEnabled.value = checked
@@ -520,6 +666,7 @@ const onRoughCalcSwitch = (checked: boolean) => {
const onCellValueChanged = (event: CellValueChangedEvent) => { const onCellValueChanged = (event: CellValueChangedEvent) => {
if (isBulkClipboardMutation) return
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') { if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
if (typeof event.newValue === 'number') { if (typeof event.newValue === 'number') {
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2) pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
@@ -534,6 +681,15 @@ const onCellValueChanged = (event: CellValueChangedEvent) => {
schedulePersist() schedulePersist()
} }
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
}
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
commitGridChanges()
}
const onGridReady = (event: GridReadyEvent<DetailRow>) => { const onGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api gridApi.value = event.api
@@ -551,6 +707,10 @@ const processCellForClipboard = (params: any) => {
} }
const processCellFromClipboard = (params: any) => { const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'amount' || field === 'landArea') {
return parseNumberOrNull(params.value, { precision: 3 })
}
try { try {
const parsed = JSON.parse(params.value) const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed if (Array.isArray(parsed)) return parsed
@@ -566,10 +726,45 @@ const processCellFromClipboard = (params: any) => {
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, () => { watch(totalLabel, () => {
refreshPinnedTotalLabelCell() refreshPinnedTotalLabelCell()
}) })
watch(
() => locale.value,
() => {
void relabelRowsFromMajorDict()
}
)
const syncPinnedTotalForNormalMode = () => { const syncPinnedTotalForNormalMode = () => {
if (roughCalcEnabled.value) return if (roughCalcEnabled.value) return
@@ -608,6 +803,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<ToastProvider>
<div class="h-full"> <div class="h-full">
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full"> <div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
@@ -615,7 +811,7 @@ onMounted(() => {
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"> class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
{{ props.title }} {{ props.title }}
</h3> </h3>
<div class="flex items-center gap-2"> <!-- <div class="flex items-center gap-2">
<span class=" text-xs text-muted-foreground">简要计算</span> <span class=" text-xs text-muted-foreground">简要计算</span>
<SwitchRoot <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" 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"
@@ -623,18 +819,75 @@ onMounted(() => {
<SwitchThumb <SwitchThumb
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" /> class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
</SwitchRoot> </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> </div>
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme" :columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
:animateRows="true"
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="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" :suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard" :processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" /> :undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div> </div>
</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> </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> </template>
+134
View File
@@ -0,0 +1,134 @@
import type localforage from 'localforage'
import { i18n } from '@/i18n'
export interface DataEntry {
key: string
value: any
}
export interface ForageStoreSnapshot {
storeName: string
entries: DataEntry[]
}
export interface DataPackage {
version: number
packageType?: 'project-snapshot'
exportedAt: string
projectId?: string
localStorage: DataEntry[]
sessionStorage: DataEntry[]
localforageDefault: DataEntry[]
localforageStores?: ForageStoreSnapshot[]
}
export type ForageInstance = ReturnType<typeof localforage.createInstance>
export type ForageStore = Pick<ForageInstance, 'keys' | 'getItem' | 'setItem' | 'clear'>
type XmInfoLike = {
projectName?: unknown
}
export const readWebStorage = (storageObj: Storage): DataEntry[] => {
const entries: DataEntry[] = []
for (let i = 0; i < storageObj.length; i++) {
const key = storageObj.key(i)
if (!key) continue
const raw = storageObj.getItem(key)
let value: any = raw
if (raw != null) {
try {
value = JSON.parse(raw)
} catch {
value = raw
}
}
entries.push({ key, value })
}
return entries
}
export const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
storageObj.clear()
for (const entry of entries || []) {
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
storageObj.setItem(entry.key, value)
}
}
export const toPersistableValue = (value: unknown) => {
try {
return JSON.parse(JSON.stringify(value))
} catch (error) {
console.error('normalize persist value failed, fallback to null:', error)
return null
}
}
export const readForage = async (store: ForageStore): Promise<DataEntry[]> => {
const keys = await store.keys()
const values = await Promise.all(keys.map(key => store.getItem(key)))
return keys.map((key, index) => ({
key,
value: toPersistableValue(values[index])
}))
}
export const writeForage = async (store: ForageStore, entries: DataEntry[]) => {
await store.clear()
await Promise.all((entries || []).map(entry => store.setItem(entry.key, toPersistableValue(entry.value))))
}
export const normalizeEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
}
export const normalizeForageStoreSnapshots = (value: unknown): ForageStoreSnapshot[] => {
if (!Array.isArray(value)) return []
return value
.filter(item =>
item
&& typeof item === 'object'
&& typeof (item as any).storeName === 'string'
&& Array.isArray((item as any).entries)
)
.map(item => ({
storeName: String((item as any).storeName),
entries: normalizeEntries((item as any).entries)
}))
}
export const sanitizeFileNamePart = (value: string): string => {
const cleaned = value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned || i18n.global.t('tab.messages.defaultProjectName')
}
export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: string, legacyProjectDbKey: string) => {
const target =
entries.find(item => item.key === projectInfoDbKey) ||
entries.find(item => item.key === legacyProjectDbKey)
const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string'
? sanitizeFileNamePart(data.projectName)
: i18n.global.t('tab.messages.defaultProjectName')
}
export const isDataPackageLike = (value: unknown): value is DataPackage => {
if (!value || typeof value !== 'object') return false
const payload = value as Partial<DataPackage>
const hasRequiredArrays =
Array.isArray(payload.localStorage) &&
Array.isArray(payload.sessionStorage) &&
Array.isArray(payload.localforageDefault)
if (!hasRequiredArrays) return false
if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) return false
if (payload.packageType != null && payload.packageType !== 'project-snapshot') return false
if (payload.projectId != null && typeof payload.projectId !== 'string') return false
return true
}
+67
View File
@@ -0,0 +1,67 @@
.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
@@ -0,0 +1,342 @@
export interface UserGuideStep {
title: string
description: string
points: string[]
}
export type XmInfoLike = {
projectName?: unknown
preparedBy?: unknown
reviewedBy?: unknown
preparedDate?: unknown
projectIndustry?: unknown
preparedCompany?: unknown
overview?: unknown
desc?: unknown
}
export type HtBaseInfoLike = {
quality?: unknown
duration?: unknown
}
export interface ScaleRowLike {
id: string
amount: number | null
landArea: number | null
}
export interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[]
totalAmount?: number
roughCalcEnabled?: boolean
}
export interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
}
export interface ContractCardItem {
id: string
name?: string
order?: number
}
export interface ZxFwRowLike {
id: string
process?: unknown
subtotal?: unknown
finalFee?: unknown
investScale?: unknown
landScale?: unknown
workload?: unknown
hourly?: unknown
}
export interface WorkContentRowLike {
id?: unknown
content?: unknown
checked?: unknown
custom?: unknown
serviceGroup?: unknown
serviceid?: unknown
isAddTrigger?: unknown
}
export interface WorkContentStateLike {
detailRows?: WorkContentRowLike[]
}
export interface ZxFwStorageLike {
selectedIds?: string[]
selectedCodes?: string[]
detailRows?: ZxFwRowLike[]
}
export interface ScaleMethodRowLike extends ScaleRowLike {
benchmarkBudgetBasicChecked?: unknown
benchmarkBudgetOptionalChecked?: unknown
basicFormula?: unknown
optionalFormula?: unknown
budgetFee?: unknown
budgetFeeBasic?: unknown
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
workStageFactor?: unknown
workRatio?: unknown
remark?: unknown
}
export interface HtFeeMainRowLike {
id?: unknown
name?: unknown
}
export interface RateMethodRowLike {
rate?: unknown
budgetFee?: unknown
}
export interface QuantityMethodRowLike {
id?: unknown
feeItem?: unknown
unit?: unknown
quantity?: unknown
unitPrice?: unknown
budgetFee?: number | null
remark?: unknown
}
export interface WorkloadMethodRowLike {
id: string
budgetAdoptedUnitPrice?: unknown
workload?: unknown
basicFee?: unknown
consultCategoryFactor?: unknown
serviceFee?: unknown
remark?: unknown
}
export interface HourlyMethodRowLike {
id: string
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
remark?: unknown
}
export interface DetailRowsStorageLike<T> {
detailRows?: T[]
roughCalcEnabled?: boolean
totalAmount?: number
}
export interface FactorRowLike {
id: string
standardFactor?: unknown
budgetValue?: unknown
remark?: unknown
}
export interface ExportScaleRow {
major: number
cost: number | null
area: number | null
}
export interface ExportMethod1Detail {
proNum: number
major: number
cost: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
export interface ExportMethod1 {
proAmount: number
cost: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod1Detail[]
}
export interface ExportMethod2Detail {
proNum: number
major: number
area: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
export interface ExportMethod2 {
proAmount: number
area: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod2Detail[]
}
export interface ExportMethod3Detail {
task: number
price: number
amount: number
basicFee: number
serviceCoe: number
fee: number
remark: string
}
export interface ExportMethod3 {
basicFee: number
fee: number
det: ExportMethod3Detail[]
}
export interface ExportMethod4Detail {
expert: number
price: number
person_num: number
work_day: number
fee: number
remark: string
}
export interface ExportMethod4 {
person_num: number
work_day: number
fee: number
det: ExportMethod4Detail[]
}
export interface ExportService {
id: number
fee: number
finalFee: number
process: number
tasks: ExportTaskGroup[]
method1?: ExportMethod1
method2?: ExportMethod2
method3?: ExportMethod3
method4?: ExportMethod4
}
export interface ExportTaskGroup {
serviceid?: number
text: string[]
}
export interface ExportServiceCoe {
serviceid: number
coe: number
remark: string
}
export interface ExportMajorCoe {
majorid: number
coe: number
remark: string
}
export interface ExportContract {
name: string
serviceFee: number
addtionalFee: number
reserveFee: number
fee: number
quality: string
duration: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
services: ExportService[]
addtional: ExportAdditional | null
reserve: ExportReserve | null
}
export interface ExportMethod0 {
coe: number
fee: number
}
export interface ExportMethod5Detail {
name: string
unit: string
amount: number
price: number
fee: number
remark: string
}
export interface ExportMethod5 {
fee: number
det: ExportMethod5Detail[]
}
export interface ExportAdditionalDetail {
id: number | string
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
export interface ExportAdditional {
code?: unknown
name: string
fee: number
det: ExportAdditionalDetail[]
}
export interface ExportReserve {
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
export interface ExportReportPayload {
name: string
writer: string
reviewer: string
company: string
date: string
industry: number
fee: number
scaleCost: number
overview: string
desc: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
contracts: ExportContract[]
}
@@ -0,0 +1,782 @@
<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>
@@ -2,20 +2,21 @@
<TypeLine <TypeLine
scene="ht-fee-method-type-line" scene="ht-fee-method-type-line"
:title="titleText" :title="titleText"
:subtitle="`合同ID:${contractIdText}`" :subtitle="t('htFeeMethodTypeLine.contractId', { id: contractIdText })"
:copy-text="contractIdText" :copy-text="contractIdText"
:storage-key="activeTypeStorageKey" :storage-key="activeTypeStorageKey"
default-category="work-content" default-category="rate-fee"
:categories="categories" :categories="categories"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue' import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue' import TypeLine from '@/layout/typeLine.vue'
import HtFeeGrid from '@/components/shared/HtFeeGrid.vue' import HtFeeGrid from '@/features/shared/components/HtFeeGrid.vue'
import HtFeeRateMethodForm from '@/components/ht/HtFeeRateMethodForm.vue' import HtFeeRateMethodForm from '@/features/ht/components/HtFeeRateMethodForm.vue'
import HourlyFeeGrid from '@/components/shared/HourlyFeeGrid.vue' import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
interface TypeLineCategoryItem { interface TypeLineCategoryItem {
key: string key: string
@@ -26,19 +27,24 @@ interface TypeLineCategoryItem {
const props = defineProps<{ const props = defineProps<{
sourceTitle?: string sourceTitle?: string
storageKey: string storageKey: string
rowId: string rowId: string | number
rowName?: string rowName?: string
contractId?: string contractId?: string
contractName?: string contractName?: string
}>() }>()
const sourceTitleText = computed(() => props.sourceTitle || '费用明细') const { t } = useI18n()
const rowNameText = computed(() => props.rowName || '未命名') 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 contractIdText = computed(() => String(props.contractId || '').trim())
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-') const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
const titleText = computed(() => `合同段:${contractNameText.value} · ${rowNameText.value || sourceTitleText.value}`) const titleText = computed(() => t('htFeeMethodTypeLine.title', {
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${props.rowId}`) 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') => const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
`${props.storageKey}-${props.rowId}-${method}` `${props.storageKey}-${rowIdText.value}-${method}`
const quantityUnitPricePane = markRaw( const quantityUnitPricePane = markRaw(
defineComponent({ defineComponent({
@@ -47,10 +53,10 @@ const quantityUnitPricePane = markRaw(
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee')) const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
return () => return () =>
h(HtFeeGrid, { h(HtFeeGrid, {
title: '数量单价', title: t('htFeeMethodTypeLine.quantityUnitPrice'),
storageKey: quantityStorageKey.value, storageKey: quantityStorageKey.value,
htMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
htRowId: props.rowId, htRowId: rowIdText.value,
htMethodType: 'quantity-unit-price-fee' htMethodType: 'quantity-unit-price-fee'
}) })
} }
@@ -67,7 +73,7 @@ const rateFeePane = markRaw(
storageKey: rateStorageKey.value, storageKey: rateStorageKey.value,
contractId: props.contractId, contractId: props.contractId,
htMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
htRowId: props.rowId, htRowId: rowIdText.value,
htMethodType: 'rate-fee' htMethodType: 'rate-fee'
}) })
} }
@@ -81,17 +87,17 @@ const hourlyFeePane = markRaw(
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee')) const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
return () => return () =>
h(HourlyFeeGrid, { h(HourlyFeeGrid, {
title: '工时法明细', title: t('hourlyFeeGrid.title'),
storageKey: hourlyStorageKey.value, storageKey: hourlyStorageKey.value,
htMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
htRowId: props.rowId, htRowId: rowIdText.value,
htMethodType: 'hourly-fee' htMethodType: 'hourly-fee'
}) })
} }
}) })
) )
const isReserveFee = computed(() => props.sourceTitle === '预备费') const isReserveFee = computed(() => props.sourceTitle === t('htSummary.reservePrefix'))
const showWorkContent = computed(() => { const showWorkContent = computed(() => {
if (isReserveFee.value) return false if (isReserveFee.value) return false
return true return true
@@ -102,14 +108,14 @@ const workContentPane = markRaw(
name: 'WorkContentPane', name: 'WorkContentPane',
setup() { setup() {
const AsyncWorkContentGrid = defineAsyncComponent({ const AsyncWorkContentGrid = defineAsyncComponent({
loader: () => import('@/components/shared/WorkContentGrid.vue'), loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
onError: err => { onError: err => {
console.error('加载 WorkContentGrid 组件失败:', err) console.error('加载 WorkContentGrid 组件失败:', err)
} }
}) })
return () => h(AsyncWorkContentGrid, { return () => h(AsyncWorkContentGrid, {
title: '工作内容', title: t('workContent.title'),
storageKey: `work-content-${props.storageKey}-${props.rowId}`, storageKey: `work-content-${props.storageKey}-${rowIdText.value}`,
dictMode: 'additional' dictMode: 'additional'
}) })
} }
@@ -118,12 +124,12 @@ const workContentPane = markRaw(
const categories = computed<TypeLineCategoryItem[]>(() => { const categories = computed<TypeLineCategoryItem[]>(() => {
const base: TypeLineCategoryItem[] = [ const base: TypeLineCategoryItem[] = [
{ key: 'rate-fee', label: '费率计取', component: rateFeePane }, { key: 'rate-fee', label: t('htFeeGrid.columns.rateFee'), component: rateFeePane },
{ key: 'hourly-fee', label: '工时法', component: hourlyFeePane }, { key: 'hourly-fee', label: t('htFeeGrid.columns.hourlyFee'), component: hourlyFeePane },
{ key: 'quantity-unit-price-fee', label: '数量单价', component: quantityUnitPricePane }, { key: 'quantity-unit-price-fee', label: t('htFeeGrid.columns.quantityUnitPriceFee'), component: quantityUnitPricePane },
] ]
if (showWorkContent.value) { if (showWorkContent.value) {
base.push({ key: 'work-content', label: '工作内容', component: workContentPane }) base.push({ key: 'work-content', label: t('workContent.title'), component: workContentPane })
} }
return base return base
}) })
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,8 @@
<template> <template>
<TypeLine <TypeLine
scene="zxfw-pricing-tab" scene="zxfw-pricing-tab"
:title="`${contractName ? `合同段:${contractName} · ` : ''}${fwName}计算`" :title="`${contractName ? `${t('zxFwView.contractPrefix', { name: contractName })} · ` : ''}${fwName}${t('zxFwView.calcSuffix')}`"
:subtitle="`合同ID:${contractId}`" :subtitle="t('zxFwView.contractId', { id: contractId })"
:copy-text="contractId" :copy-text="contractId"
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`" :storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
:default-category="defaultCategory" :default-category="defaultCategory"
@@ -12,8 +12,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue' import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue' import TypeLine from '@/layout/typeLine.vue'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue' import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
import ScaleFormulaReadonlyPane from '@/features/pricing/components/ScaleFormulaReadonlyPane.vue'
interface ServiceMethodType { interface ServiceMethodType {
scale?: boolean | null scale?: boolean | null
@@ -30,6 +32,7 @@ const props = defineProps<{
type?: ServiceMethodType type?: ServiceMethodType
projectInfoKey?: string projectInfoKey?: string
}>() }>()
const { t } = useI18n()
interface PricingCategoryItem { interface PricingCategoryItem {
key: string key: string
@@ -45,6 +48,7 @@ const methodAvailability = computed(() => {
const onlyCostScale = resolveMethodEnabled(props.type?.onlyCostScale, false) const onlyCostScale = resolveMethodEnabled(props.type?.onlyCostScale, false)
const amount = resolveMethodEnabled(props.type?.amount, true) const amount = resolveMethodEnabled(props.type?.amount, true)
const workDay = resolveMethodEnabled(props.type?.workDay, true) const workDay = resolveMethodEnabled(props.type?.workDay, true)
return { return {
investmentScale: scale, investmentScale: scale,
landScale: scale && !onlyCostScale, landScale: scale && !onlyCostScale,
@@ -59,14 +63,15 @@ const createPricingPane = (name: string) =>
name, name,
setup() { setup() {
const AsyncPricingView = defineAsyncComponent({ const AsyncPricingView = defineAsyncComponent({
loader: () => import(`@/components/pricing/${name}.vue`), loader: () => import(`@/features/pricing/components/${name}.vue`),
onError: err => { onError: err => {
console.error('加载 PricingMethodView 组件失败:', err) console.error('load PricingMethodView failed:', err)
} }
}) })
return () => h(AsyncPricingView, { return () => h(AsyncPricingView, {
contractId: props.contractId, contractId: props.contractId,
contractName: props.contractName,
serviceId: props.serviceId, serviceId: props.serviceId,
projectInfoKey: props.projectInfoKey projectInfoKey: props.projectInfoKey
}) })
@@ -88,75 +93,119 @@ const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
const landScaleView = createPricingPane('LandScalePricingPane') const landScaleView = createPricingPane('LandScalePricingPane')
const workloadView = createPricingPane('WorkloadPricingPane') const workloadView = createPricingPane('WorkloadPricingPane')
const hourlyView = createPricingPane('HourlyPricingPane') 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( const workContentPane = markRaw(
defineComponent({ defineComponent({
name: 'WorkContentPane', name: 'WorkContentPane',
setup() { setup() {
const AsyncWorkContentGrid = defineAsyncComponent({ const AsyncWorkContentGrid = defineAsyncComponent({
loader: () => import('@/components/shared/WorkContentGrid.vue'), loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
onError: err => { onError: err => {
console.error('加载 WorkContentGrid 组件失败:', err) console.error('load WorkContentGrid failed:', err)
} }
}) })
return () => h(AsyncWorkContentGrid, { return () => h(AsyncWorkContentGrid, {
title: '工作内容', title: t('zxFwView.workContentTitle'),
storageKey: `work-content-${props.contractId}-${props.serviceId}`, storageKey: `work-content-${props.contractId}-${props.serviceId}`,
contractId: props.contractId,
projectInfoKey: props.projectInfoKey,
serviceId: props.serviceId, serviceId: props.serviceId,
dictMode: 'service' dictMode: 'service',
"show-no-column": true
}) })
} }
}) })
) )
const investmentScaleUnavailableView = createMethodUnavailablePane( const investmentScaleUnavailableView = createMethodUnavailablePane(
'该服务不适用投资规模法', t('zxFwView.unavailable.investmentScaleTitle'),
'当前服务未启用规模法,投资规模法不可编辑。' t('zxFwView.unavailable.investmentScaleMessage')
) )
const landScaleUnavailableView = createMethodUnavailablePane( const landScaleUnavailableView = createMethodUnavailablePane(
'该服务不适用用地规模法', t('zxFwView.unavailable.landScaleTitle'),
'当前服务仅支持投资规模法,用地规模法不可编辑。' t('zxFwView.unavailable.landScaleMessage')
) )
const workloadUnavailableView = createMethodUnavailablePane( const workloadUnavailableView = createMethodUnavailablePane(
'该服务不适用工作量法', t('zxFwView.unavailable.workloadTitle'),
'当前服务未启用工作量法,工作量法不可编辑。' t('zxFwView.unavailable.workloadMessage')
) )
const hourlyUnavailableView = createMethodUnavailablePane( const hourlyUnavailableView = createMethodUnavailablePane(
'该服务不适用工时法', t('zxFwView.unavailable.hourlyTitle'),
'当前服务未启用工时法,工时法不可编辑。' t('zxFwView.unavailable.hourlyMessage')
) )
const pricingCategories = computed<PricingCategoryItem[]>(() => [ const pricingCategories = computed<PricingCategoryItem[]>(() => [
{ {
key: 'investment-scale-method', key: 'investment-scale-method',
label: '投资规模法', label: t('zxFwView.categories.investmentScale'),
component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView 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', key: 'land-scale-method',
label: '用地规模法', label: t('zxFwView.categories.landScale'),
component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView
}, },
{
key: 'land-scale-formula',
label: t('zxFwView.categories.landScaleFormula'),
component: methodAvailability.value.landScale ? landScaleFormulaView : landScaleUnavailableView
},*/
{ {
key: 'workload-method', key: 'workload-method',
label: '工作量法', label: t('zxFwView.categories.workload'),
component: methodAvailability.value.workload ? workloadView : workloadUnavailableView component: methodAvailability.value.workload ? workloadView : workloadUnavailableView
}, },
{ {
key: 'hourly-method', key: 'hourly-method',
label: '工时法', label: t('zxFwView.categories.hourly'),
component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView
}, },
{ {
key: 'work-content', key: 'work-content',
label: '工作内容', label: t('zxFwView.categories.workContent'),
component: workContentPane component: workContentPane
}, },
{
key: 'other-service',
label: t('zxFwView.categories.otherService'),
component: otherService
},
]) ])
const defaultCategory = computed(() => { 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' return 'work-content'
}) })
</script> </script>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql' import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState { interface XmBaseInfoState {
@@ -19,6 +20,7 @@ type ServiceItem = {
const PROJECT_INFO_KEY = 'xm-base-info-v1' const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('') const projectIndustry = ref('')
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@@ -51,7 +53,7 @@ onActivated(() => {
</script> </script>
<template> <template>
<XmFactorGrid title="咨询分类系数明细" storage-key="xm-consult-category-factor-v1" :dict="filteredServiceDict" <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" :disable-budget-edit-when-standard-null="true" :exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true" /> :init-budget-value-from-standard="true" />
</template> </template>
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue' import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState { interface XmBaseInfoState {
@@ -21,6 +22,7 @@ const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('') const projectIndustry = ref('')
const hasProjectBaseInfo = ref(false) const hasProjectBaseInfo = ref(false)
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@@ -56,7 +58,7 @@ onActivated(() => {
<template> <template>
<XmFactorGrid <XmFactorGrid
title="工程专业系数明细" :title="t('htFactors.majorTitle')"
storage-key="xm-major-factor-v1" storage-key="xm-major-factor-v1"
:dict="filteredMajorDict" :dict="filteredMajorDict"
:disable-budget-edit-when-standard-null="true" :disable-budget-edit-when-standard-null="true"
@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseDate } from '@internationalized/date' import { parseDate } from '@internationalized/date'
import { onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { import {
getIndustryDisplayName,
industryTypeList, industryTypeList,
} from '@/sql' } from '@/sql'
import { useKvStore } from '@/pinia/kv' 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 { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { import {
@@ -44,9 +49,10 @@ interface XmInfoState {
type MajorParentNode = { id: string; name: string } type MajorParentNode = { id: string; name: string }
const DB_KEY = 'xm-base-info-v1' const DB_KEY = 'xm-base-info-v1'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务' const { t, locale } = useI18n()
const DEFAULT_DESC = '在履行造价咨询服务时,宜根据咨询服务质量情况分级确定相应的处罚金额。其中考评得分在大于及等于85和小于90分时,处罚金额为预算费用的10%;其中考评得分在大于及等于80和小于85分时,处罚金额为预算费用的20%;其中考评得分在大于及等于75和小于80分时,处罚金额为预算费用的30%;其中考评得分在大于及等于70和小于75分时,处罚金额为预算费用的40%;其中考评得分小于70分时,处罚金额为预算费用的50%以上。' const DEFAULT_PROJECT_NAME = t('xmInfo.defaultProjectName')
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择' const DEFAULT_DESC = t('xmInfo.defaultDesc')
const INDUSTRY_HINT_TEXT = computed(() => t('xmInfo.industryHint'))
const getTodayDateString = () => { const getTodayDateString = () => {
const now = new Date() const now = new Date()
const year = String(now.getFullYear()) const year = String(now.getFullYear())
@@ -56,6 +62,7 @@ const getTodayDateString = () => {
} }
const isProjectInitialized = ref(false) const isProjectInitialized = ref(false)
const isBootstrapping = ref(true)
const projectName = ref('') const projectName = ref('')
const projectIndustry = ref('') const projectIndustry = ref('')
@@ -97,19 +104,23 @@ const handlePreparedDateSelect = (date: any) => {
preparedDate.value = date?.toString() ?? '' preparedDate.value = date?.toString() ?? ''
} }
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({ const majorParentNodes = computed<MajorParentNode[]>(() =>
industryTypeList.map(item => ({
id: item.id, id: item.id,
name: item.name name: getIndustryDisplayName(item.id, locale.value) || String(item.name || '')
})) }))
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id)) )
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || '' const majorParentCodeSet = new Set<string>(industryTypeList.map(item => String(item.id)))
const DEFAULT_PROJECT_INDUSTRY = String(industryTypeList[0]?.id || '')
const kvStore = useKvStore() const kvStore = useKvStore()
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
try { try {
const normalizedProjectName = projectName.value.trim() || DEFAULT_PROJECT_NAME
projectName.value = normalizedProjectName
const payload: XmInfoState = { const payload: XmInfoState = {
projectIndustry: projectIndustry.value, projectIndustry: projectIndustry.value,
projectName: projectName.value, projectName: normalizedProjectName,
preparedBy: preparedBy.value, preparedBy: preparedBy.value,
reviewedBy: reviewedBy.value, reviewedBy: reviewedBy.value,
preparedCompany: preparedCompany.value, preparedCompany: preparedCompany.value,
@@ -118,6 +129,7 @@ const saveToIndexedDB = async () => {
desc: desc.value desc: desc.value
} }
await kvStore.setItem(DB_KEY, payload) await kvStore.setItem(DB_KEY, payload)
upsertProject(readCurrentProjectId(), normalizedProjectName)
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
} }
@@ -156,6 +168,7 @@ const loadFromIndexedDB = async () => {
syncPreparedDatePickerFromString() syncPreparedDatePickerFromString()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
isProjectInitialized.value = false
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = '' preparedBy.value = ''
@@ -189,7 +202,12 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
try {
await waitForHydration('kv')
await loadFromIndexedDB() await loadFromIndexedDB()
} finally {
isBootstrapping.value = false
}
}) })
</script> </script>
@@ -197,21 +215,28 @@ onMounted(async () => {
<TooltipProvider> <TooltipProvider>
<div class="space-y-6 h-full"> <div class="space-y-6 h-full">
<div <div
v-if="!isProjectInitialized" v-if="isBootstrapping"
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground" 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>
<div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5"> <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="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="md:col-span-2 xl:col-span-4"> <div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">项目名称</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectName') }}</label>
<input <input
v-model="projectName" v-model="projectName"
type="text" type="text"
required required
placeholder="xxx造价咨询服务" :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" 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" @blur="handleProjectNameBlur"
/> />
@@ -219,12 +244,12 @@ onMounted(async () => {
<div class="md:col-span-2 xl:col-span-4"> <div class="md:col-span-2 xl:col-span-4">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<label class="block text-sm font-medium text-foreground">工程行业</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectIndustry') }}</label>
<TooltipRoot> <TooltipRoot>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
type="button" type="button"
aria-label="工程行业提示" :aria-label="t('xmInfo.industryHintAria')"
class="inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground" 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" /> <CircleHelp class="h-5 w-5" />
@@ -250,39 +275,47 @@ onMounted(async () => {
</label> </label>
</div> </div>
</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> <div>
<label class="block text-sm font-medium text-foreground">编制人</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedBy') }}</label>
<input <input
v-model="preparedBy" v-model="preparedBy"
type="text" type="text"
placeholder="请输入编制人" :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" 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>
<div> <div>
<label class="block text-sm font-medium text-foreground">复核人</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.reviewedBy') }}</label>
<input <input
v-model="reviewedBy" v-model="reviewedBy"
type="text" type="text"
placeholder="请输入复核人" :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" 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>
<div> <div>
<label class="block text-sm font-medium text-foreground">编制单位</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedCompany') }}</label>
<input <input
v-model="preparedCompany" v-model="preparedCompany"
type="text" type="text"
placeholder="请输入编制单位" :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" 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>
<div> <div>
<label class="block text-sm font-medium text-foreground">编制日期</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedDate') }}</label>
<DatePickerRoot <DatePickerRoot
locale="en-CA" locale="en-CA"
:model-value="preparedDatePickerValue" :model-value="preparedDatePickerValue"
@@ -383,7 +416,7 @@ onMounted(async () => {
type="button" type="button"
class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2" class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2"
> >
确认 {{ t('common.confirm') }}
</button> </button>
</DatePickerClose> </DatePickerClose>
@@ -391,18 +424,10 @@ onMounted(async () => {
</DatePickerContent> </DatePickerContent>
</DatePickerRoot> </DatePickerRoot>
</div> </div>
<div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">项目概况</label>
<textarea
v-model="overview"
rows="3"
placeholder="请输入项目概况"
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 class="md:col-span-2 xl:col-span-4"> <div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">备注</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.desc') }}</label>
<textarea <textarea
v-model="desc" v-model="desc"
rows="4" rows="4"
+33
View File
@@ -0,0 +1,33 @@
<template>
<TypeLine
scene="xm-tab"
title=""
storage-key="project-active-cat"
default-category="info"
:categories="xmCategories"
/>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue'
const { t } = useI18n()
const infoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/info.vue')))
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/features/ht/components/Ht.vue')))
const consultCategoryFactorView = markRaw(
defineAsyncComponent(() => import('@/features/xm/components/XmConsultCategoryFactor.vue'))
)
const majorFactorView = markRaw(
defineAsyncComponent(() => import('@/features/xm/components/XmMajorFactor.vue'))
)
const xmCategories = computed(() => [
{ key: 'info', label: t('xmCard.categories.info'), component: infoView },
{ key: 'scale-info', label: t('xmCard.categories.scaleInfo'), component: scaleInfoView },
// { key: 'consult-category-factor', label: t('xmCard.categories.consultCategoryFactor'), component: consultCategoryFactorView },
// { key: 'major-factor', label: t('xmCard.categories.majorFactor'), component: majorFactorView },
{ key: 'contract', label: t('xmCard.categories.contract'), component: htView }
])
</script>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import CommonAgGrid from '@/components/shared/xmCommonAgGrid.vue' import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
const DB_KEY = 'xm-info-v3' const DB_KEY = 'xm-info-v3'
+130
View File
@@ -0,0 +1,130 @@
export const MAJOR_NAME_EN_BY_CODE: Record<string, string> = {
E1: 'General Transportation Engineering',
'E1-1': 'Land (Sea) Acquisition Compensation',
'E1-2': 'Relocation Compensation',
'E1-3': 'Relocation/Utility Diversion Works',
'E1-4': 'Other Construction Expenses',
'E1-5': 'Contingency',
'E1-6': 'Construction Loan Interest',
E2: 'Highway Engineering',
'E2-1': 'Temporary Works',
'E2-2': 'Subgrade Works',
'E2-3': 'Pavement Works',
'E2-4': 'Bridge and Culvert Works',
'E2-5': 'Tunnel Works',
'E2-6': 'Interchange Works',
'E2-7': 'MEP Works',
'E2-8': 'Traffic Safety Facilities',
'E2-9': 'Landscaping and Environmental Works',
'E2-10': 'Building Works',
E3: 'Railway Engineering',
'E3-1': 'Large Temporary Facilities and Transitional Works',
'E3-2': 'Subgrade Works',
'E3-3': 'Bridge and Culvert Works',
'E3-4': 'Tunnel and Cut-and-Cover Works',
'E3-5': 'Track Works',
'E3-6': 'Communication, Signaling, Information and Disaster Monitoring',
'E3-7': 'Power and Traction Power Supply Works',
'E3-8': 'Building Works (Buildings and Ancillary Works)',
'E3-9': 'Interior Decoration Works',
E4: 'Waterway Engineering',
'E4-1': 'Temporary Works',
'E4-2': 'Civil Works',
'E4-3': 'Mechanical, Electrical and Steel Structure Works',
'E4-4': 'Equipment Works',
'E4-5': 'Ancillary Building Works (Buildings and Ancillary Works)'
}
export const SERVICE_NAME_EN_BY_CODE: Record<string, string> = {
D1: 'Whole-Process Cost Consulting',
D2: 'Stage-Based Cost Consulting',
'D2-1': 'Early-Stage Cost Consulting',
'D2-2-1': 'Implementation-Stage Cost Consulting (Highway/Waterway)',
'D2-2-2': 'Implementation-Stage Cost Consulting (Railway)',
D3: 'Basic Cost Consulting',
'D3-1': 'Investment Estimate',
'D3-2': 'Design Estimate',
'D3-3': 'Construction Drawing Budget',
'D3-4': 'BOQ and BOQ Budget (or Max Bid Price)',
'D3-5': 'Estimate Review/Reconciliation (Railway Only)',
'D3-6-1': 'Contract (Project) Settlement',
'D3-6-2': 'Contract (Project) Settlement',
'D3-7': 'Final Account',
D4: 'Specialized Cost Consulting',
'D4-1': 'Cost Advisory Service',
'D4-2': 'Cost Policy Formulation/Revision',
'D4-3': 'Cost Science and Technology Research',
'D4-4': 'Quota Determination',
'D4-5': 'Cost Information Consulting',
'D4-6': 'Cost Appraisal',
'D4-7': 'Cost Estimation',
'D4-8': 'Cost Accounting',
'D4-9': 'Quantity Takeoff',
'D4-10': 'Variation Cost Consulting',
'D4-11': 'Adjusted Estimate',
'D4-12': 'Adjusted Budget Estimate',
'D4-13': 'Cost Inspection',
'D4-14': 'Other Specialized Consulting',
'D4-15-1': 'Cost Data Validation (Estimate)',
'D4-15-2': 'Cost Data Validation (Budget Estimate)',
'D4-15-3': 'Cost Data Validation (Construction Drawing Budget)',
'D4-15-4': 'Cost Data Validation (BOQ and BOQ Budget)',
'D4-15-5': 'Cost Data Validation (Estimate Review, Railway Only)',
'D4-15-6': 'Cost Data Validation (Contract Settlement)',
'D4-15-7': 'Cost Data Validation (Contract Settlement)',
'D4-15-8': 'Cost Data Validation (Final Account)'
}
export const TASK_NAME_EN_BY_CODE: Record<string, string> = {
'C4-1': 'Daily Cost Advisory',
'C4-2': 'Special Cost Advisory',
'C5-1': 'Organization and Research',
'C5-2-1': 'Document Drafting',
'C5-2-2': 'Document Drafting',
'C5-3-1': 'Review',
'C5-3-2': 'Review',
'C5-3-3': 'Review',
'C6-1': 'Organization and Research',
'C6-2-1': 'Research and Report Writing',
'C6-2-2': 'Research and Report Writing',
'C6-2-3': 'Research and Report Writing',
'C6-3-1': 'Standards/Guideline Drafting',
'C6-3-2': 'Standards/Guideline Drafting',
'C6-3-3': 'Standards/Guideline Drafting',
'C6-3-4': 'Standards/Guideline Drafting',
'C6-4-1': 'Review and Acceptance',
'C6-4-2': 'Review and Acceptance',
'C6-4-3': 'Review and Acceptance',
'C6-5-1': 'Training and Communication',
'C6-5-2': 'Training and Communication',
'C7-1': 'Organization and Research',
'C7-2': 'Outline Preparation',
'C7-3': 'Data Collection and Measurement',
'C7-4-1': 'Data Processing and Analysis',
'C7-4-2': 'Data Processing and Analysis',
'C7-5': 'Quota Determination Report',
'C7-6-1': 'Quota Text and Notes Drafting',
'C7-6-2': 'Quota Text and Notes Drafting',
'C7-7-1': 'Review and Acceptance',
'C7-7-2': 'Review and Acceptance',
'C7-7-3': 'Review and Acceptance',
'C7-8-1': 'Training and Communication',
'C7-8-2': 'Training and Communication',
'C8-1': 'Q ≤ 10',
'C8-2': '10 < Q ≤ 30',
'C8-3': '30 < Q ≤ 50',
'C8-4': '50 < Q ≤ 100',
'C8-5': 'Q > 100'
}
export const EXPERT_NAME_EN_BY_CODE: Record<string, string> = {
'C9-1-1': 'Technician and Others',
'C9-1-2': 'Assistant Engineer',
'C9-1-3': 'Intermediate Engineer or Level-2 Cost Engineer',
'C9-1-4': 'Senior Engineer or Level-1 Cost Engineer',
'C9-1-5': 'Professor-Level Senior Engineer or Senior Expert',
'C9-2-1': 'Level-2 Cost Engineer + Intermediate Engineer',
'C9-3-1': 'Level-1 Cost Engineer + Intermediate Engineer',
'C9-3-2': 'Level-1 Cost Engineer + Senior Engineer'
}
+36
View File
@@ -0,0 +1,36 @@
import { createI18n } from 'vue-i18n'
import { enUS } from './locales/en-US'
import { zhCN } from './locales/zh-CN'
export const I18N_LOCALE_KEY = 'jgjs-locale-v1'
export const DEFAULT_LOCALE = 'zh-CN'
const messages = {
'zh-CN': zhCN,
'en-US': enUS
} as const
export type AppLocale = keyof typeof messages
const getInitialLocale = (): AppLocale => {
if (typeof window === 'undefined') return DEFAULT_LOCALE
const saved = String(localStorage.getItem(I18N_LOCALE_KEY) || '').trim() as AppLocale
if (saved in messages) return saved
const language = String(navigator.language || '').toLowerCase()
return language.startsWith('en') ? 'en-US' : DEFAULT_LOCALE
}
export const i18n = createI18n({
legacy: false,
locale: getInitialLocale(),
fallbackLocale: DEFAULT_LOCALE,
messages
})
export const setAppLocale = (locale: AppLocale) => {
i18n.global.locale.value = locale
if (typeof window !== 'undefined') {
localStorage.setItem(I18N_LOCALE_KEY, locale)
}
}
+687
View File
@@ -0,0 +1,687 @@
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
@@ -0,0 +1,738 @@
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
+3600 -1073
View File
File diff suppressed because it is too large. Load diff
+58 -31
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue' import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
@@ -14,6 +15,7 @@ import {
} from 'reka-ui' } from 'reka-ui'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v' 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
@@ -30,23 +32,39 @@ const props = withDefaults(
categories: TypeLineCategory[] categories: TypeLineCategory[]
storageKey?: string storageKey?: string
defaultCategory?: string defaultCategory?: string
persistActiveCategory?: boolean
}>(), }>(),
{ {
scene: 'default', scene: 'default',
title: '配置', title: undefined,
subtitle: '', subtitle: '',
metaText: '', metaText: '',
copyText: '', 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 || ''
const savedKey = sessionStorage.getItem(cacheKey.value) if (!props.persistActiveCategory) return defaultKey
const savedKey = readStoredCategory(scopedCacheKey.value)
const validSavedKey = props.categories.some(item => item.key === savedKey) const validSavedKey = props.categories.some(item => item.key === savedKey)
return validSavedKey ? (savedKey as string) : defaultKey return validSavedKey ? (savedKey as string) : defaultKey
} }
@@ -54,8 +72,10 @@ const resolveInitialCategory = () => {
const activeCategory = ref(resolveInitialCategory()) const activeCategory = ref(resolveInitialCategory())
watch( watch(
() => [props.categories, props.defaultCategory, cacheKey.value], () => [props.categories, props.defaultCategory, scopedCacheKey.value],
() => { () => {
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
if (isCurrentValid) return
activeCategory.value = resolveInitialCategory() activeCategory.value = resolveInitialCategory()
}, },
{ deep: true } { deep: true }
@@ -65,7 +85,8 @@ watch(
const switchCategory = (cat: string) => { const switchCategory = (cat: string) => {
activeCategory.value = cat activeCategory.value = cat
sessionStorage.setItem(cacheKey.value, cat) if (!props.persistActiveCategory) return
writeStoredCategory(scopedCacheKey.value, cat)
} }
const activeComponent = computed(() => { const activeComponent = computed(() => {
@@ -73,7 +94,14 @@ const activeComponent = computed(() => {
return selected?.component || props.categories[0]?.component || null return selected?.component || props.categories[0]?.component || null
}) })
const copyBtnText = ref('复制') 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) const sheetOpen = ref(false)
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
@@ -91,15 +119,15 @@ const handleCopySubtitle = async () => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
copyBtnText.value = '已复制' copyBtnText.value = t('typeLine.copied')
} catch (error) { } catch (error) {
console.error('copy failed:', error) console.error('copy failed:', error)
copyBtnText.value = '复制失败' copyBtnText.value = t('typeLine.copyFailed')
} }
if (copyBtnTimer) clearTimeout(copyBtnTimer) if (copyBtnTimer) clearTimeout(copyBtnTimer)
copyBtnTimer = setTimeout(() => { copyBtnTimer = setTimeout(() => {
copyBtnText.value = '复制' copyBtnText.value = t('typeLine.copy')
}, 1200) }, 1200)
} }
@@ -199,7 +227,7 @@ useMotionValueEvent(
<template> <template>
<TooltipProvider> <TooltipProvider>
<div class="flex h-full w-full bg-background p-2"> <div class="flex h-full w-full bg-background p-2">
<div class="w-[200px] shrink-0 border-r px-4 py-3 flex flex-col gap-6 relative "> <div :style="sideWidthStyle" class="shrink-0 border-r px-4 py-3 flex flex-col gap-6 relative">
<div v-if="props.title || props.subtitle || props.metaText" class="space-y-1"> <div v-if="props.title || props.subtitle || props.metaText" class="space-y-1">
<TooltipRoot> <TooltipRoot>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@@ -228,25 +256,25 @@ useMotionValueEvent(
</div> </div>
</div> </div>
<div :class="['flex flex-col gap-6 relative', (props.title || props.subtitle || props.metaText) ? 'mt-4' : '']"> <div :class="['flex flex-col gap-6 relative ', (props.title || props.subtitle || props.metaText) ? 'mt-3' : 'mt-6']">
<div class="absolute left-[9px] top-3 bottom-3 w-[1.5px] bg-border/60"></div> <div :style="lineStyle" class="absolute top-3 bottom-3 w-[1.5px] bg-border/60"></div>
<div v-for="item in props.categories" :key="item.key" <div v-for="item in props.categories" :key="item.key"
class="relative flex items-center gap-3 cursor-pointer group" @click="switchCategory(item.key)"> :style="itemGapStyle" class="relative flex items-center cursor-pointer group" @click="switchCategory(item.key)">
<div :class="[ <div :class="[
'z-10 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all duration-200', 'z-10 rounded-full border-2 flex items-center justify-center transition-all duration-200',
activeCategory === item.key activeCategory === item.key
? 'bg-primary border-primary shadow-[0_0_0_3px_rgba(var(--primary),0.15)]' ? 'bg-blue-600 border-blue-600'
: 'bg-background border-muted-foreground/40 group-hover:border-muted-foreground/70' : 'bg-background border-slate-300 group-hover:border-slate-400'
]"> ]" :style="dotStyle">
<div v-if="activeCategory === item.key" class="w-1.5 h-1.5 bg-background rounded-full"></div> <div v-if="activeCategory === item.key" class="bg-white rounded-full" :style="dotInnerStyle"></div>
</div> </div>
<span :class="[ <span :class="[
'text-[12px] leading-4 transition-colors duration-200', 'transition-colors duration-200',
activeCategory === item.key activeCategory === item.key
? 'font-semibold text-primary' ? 'font-semibold text-blue-600'
: 'text-muted-foreground group-hover:text-foreground' : 'text-slate-500 group-hover:text-slate-700'
]"> ]" :style="labelStyle">
{{ item.label }} {{ item.label }}
</span> </span>
</div> </div>
@@ -256,8 +284,8 @@ useMotionValueEvent(
<DialogTrigger as-child> <DialogTrigger as-child>
<button type="button" <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"> 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="/favicon.ico" alt="众为咨询" class="h-5 w-5 shrink-0 rounded-sm" /> <img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-8 w-8 shrink-0 rounded-sm" />
<span>本网站由众为工程咨询有限公司提供免费技术支持</span> <span>{{ t('typeLine.supportText') }}</span>
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogPortal> <DialogPortal>
@@ -296,8 +324,8 @@ useMotionValueEvent(
</div> </div>
<DialogTitle class="mt-2"> <DialogTitle class="mt-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src="/favicon.ico" alt="众为咨询" class="h-7 w-7 shrink-0 rounded-sm" /> <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">关于我们</span> <span class="text-2xl font-semibold leading-none">{{ t('typeLine.aboutTitle') }}</span>
</div> </div>
</DialogTitle> </DialogTitle>
</div> </div>
@@ -306,11 +334,11 @@ useMotionValueEvent(
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer" <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"> class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline">
众为工程咨询有限公司 {{ t('typeLine.companyName') }}
</a> </a>
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer" <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" 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="跳转到官网首页" title="官网首页"> :aria-label="t('typeLine.openOfficialSiteAria')" :title="t('typeLine.officialSiteTitle')">
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true"> <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" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M7 7h10v10M7 17L17 7" /> stroke-width="2" d="M7 7h10v10M7 17L17 7" />
@@ -321,11 +349,10 @@ useMotionValueEvent(
<div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7"> <div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
<p> <p>
众为工程咨询有限公司 2009 {{ t('typeLine.aboutParagraph1') }}
年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。
</p> </p>
<p> <p>
公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。 {{ t('typeLine.aboutParagraph2') }}
</p> </p>
</div> </div>
</div> </div>
+165
View File
@@ -0,0 +1,165 @@
import type { ColDef, ColGroupDef } from 'ag-grid-community'
type AnyColDef<TRow> = ColDef<TRow> | ColGroupDef<TRow>
const numericFieldKeywords = [
'amount',
'area',
'cost',
'price',
'fee',
'budget',
'subtotal',
'total',
'ratio',
'rate',
'quantity',
'count',
'num',
'workday',
'workload',
'hourly',
'scale',
'value',
'coe',
'factor'
]
const isFiniteNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value)
const isNumericLikeString = (value: unknown) => {
if (typeof value !== 'string') return false
const normalized = value.replace(/[,\s]/g, '').replace(/%$/, '')
if (!normalized) return false
const asNumber = Number(normalized)
return Number.isFinite(asNumber)
}
const looksNumericByColumnDef = <TRow>(col: ColDef<TRow>) => {
const fieldLike = String(col.field ?? col.colId ?? '').toLowerCase()
const hasNumericFieldHint = fieldLike
? numericFieldKeywords.some(keyword => fieldLike.includes(keyword))
: false
const type = col.type
const typeList = Array.isArray(type) ? type : type ? [type] : []
const hasNumericType = typeList.some(item => {
const normalized = String(item).toLowerCase()
return normalized.includes('numeric') || normalized.includes('rightaligned')
})
return hasNumericFieldHint || col.cellDataType === 'number' || hasNumericType
}
const hasRightAlignedHeaderClass = (value: unknown) => {
if (typeof value === 'string') return value.includes('ag-right-aligned-header')
if (Array.isArray(value)) return value.some(item => String(item).includes('ag-right-aligned-header'))
return false
}
const mergeHeaderClass = <TRow>(col: ColDef<TRow>, mustRightAlign: boolean): ColDef<TRow>['headerClass'] => {
if (!mustRightAlign) return col.headerClass
const base = col.headerClass
if (!base) return 'ag-right-aligned-header'
if (hasRightAlignedHeaderClass(base)) return base
if (typeof base === 'function') {
return params => {
const result = base(params)
if (!result) return 'ag-right-aligned-header'
if (typeof result === 'string') {
return result.includes('ag-right-aligned-header')
? result
: `${result} ag-right-aligned-header`
}
if (Array.isArray(result)) {
return hasRightAlignedHeaderClass(result)
? result
: [...result, 'ag-right-aligned-header']
}
return 'ag-right-aligned-header'
}
}
if (typeof base === 'string') return `${base} ag-right-aligned-header`
if (Array.isArray(base)) return [...base, 'ag-right-aligned-header']
return base
}
const mergeNumericCellClassRules = <TRow>(
col: ColDef<TRow>,
mustRightAlign: boolean
): ColDef<TRow>['cellClassRules'] => {
if (!mustRightAlign) return col.cellClassRules
const baseRules = col.cellClassRules ? { ...col.cellClassRules } : {}
const existing = baseRules['ag-right-aligned-cell']
if (existing) return baseRules
baseRules['ag-right-aligned-cell'] = params =>
looksNumericByColumnDef(params.colDef as ColDef<TRow>) ||
isFiniteNumber(params.value) ||
isNumericLikeString(params.value)
return baseRules
}
const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] => {
const baseStyle = { whiteSpace: 'normal', lineHeight: '1.4' }
if (!cellStyle) return baseStyle
if (typeof cellStyle === 'function') {
return params => {
const next = cellStyle(params)
if (next && typeof next === 'object') {
return {
...next,
...baseStyle
}
}
return baseStyle
}
}
if (typeof cellStyle === 'object') {
return {
...cellStyle,
...baseStyle
}
}
return cellStyle
}
const enhanceLeafColumn = <TRow>(col: ColDef<TRow>): ColDef<TRow> => {
const editable = col.editable
const isReadonlyColumn = editable == null || editable === false
const shouldRightAlign = looksNumericByColumnDef(col)
if (!isReadonlyColumn) {
return {
...col,
headerClass: mergeHeaderClass(col, shouldRightAlign),
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign)
}
}
return {
...col,
headerClass: mergeHeaderClass(col, shouldRightAlign),
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign),
wrapText: true,
autoHeight: true,
cellStyle: mergeCellStyle(col.cellStyle)
}
}
const enhanceColumn = <TRow>(col: AnyColDef<TRow>): AnyColDef<TRow> => {
const maybeGroup = col as ColGroupDef<TRow>
if (Array.isArray(maybeGroup.children)) {
return {
...maybeGroup,
children: maybeGroup.children.map(child => enhanceColumn(child as AnyColDef<TRow>))
}
}
return enhanceLeafColumn(col as ColDef<TRow>)
}
export const withReadonlyAutoHeight = <TRow>(
defs: Array<AnyColDef<TRow>>
): Array<AnyColDef<TRow>> => defs.map(def => enhanceColumn(def))
+80
View File
@@ -0,0 +1,80 @@
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
@@ -0,0 +1,141 @@
export interface DataEntry {
key: string
value: any
}
export interface ContractSegmentPackage {
version: number
exportedAt: string
packageType?: 'contract-segments'
project?: {
industry: string
}
storage?: {
localforageEntries: DataEntry[]
keyedEntries?: DataEntry[]
}
contracts: Array<{
id: string
name: string
order: number
createdAt: string
}>
projectIndustry?: string
localforageEntries?: DataEntry[]
keyedEntries?: DataEntry[]
pinia?: {
zxFwPricing?: {
contracts?: Record<string, unknown>
servicePricingStates?: Record<string, unknown>
htFeeMainStates?: Record<string, unknown>
htFeeMethodStates?: Record<string, unknown>
}
}
piniaState?: {
zxFwPricing?: {
contracts?: Record<string, unknown>
servicePricingStates?: Record<string, unknown>
htFeeMainStates?: Record<string, unknown>
htFeeMethodStates?: Record<string, unknown>
}
}
}
export const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
export const CONTRACT_SEGMENT_VERSION = 3
export const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
export const SERVICE_KEY_PREFIX = 'zxFW-'
export const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
export const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
export const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-'] as const
export const PROJECT_INFO_KEY = 'xm-base-info-v1'
export const PROJECT_SCALE_KEY = 'xm-info-v3'
export const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const
export const formatExportTimestamp = (date: Date): string => {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
return `${yyyy}${mm}${dd}-${hh}${mi}`
}
export const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value))
export const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const normalizeDataEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as DataEntry).key === 'string')
.map(item => ({
key: String((item as DataEntry).key),
value: (item as DataEntry).value
}))
}
export const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
projectIndustry:
typeof payload.project?.industry === 'string' && payload.project.industry.trim()
? payload.project.industry.trim()
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
piniaState: payload.pinia ?? payload.piniaState
})
export const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
const payload = value as Partial<ContractSegmentPackage> | null
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
}
export const isContractRelatedForageKey = (key: string, contractId: string) => {
if (key === `${CONTRACT_KEY_PREFIX}${contractId}`) return true
if (key === `${SERVICE_KEY_PREFIX}${contractId}`) return true
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${contractId}`) return true
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${contractId}`) return true
if (PRICING_KEY_PREFIXES.some(prefix => key.startsWith(`${prefix}${contractId}-`))) return true
return false
}
export const isContractRelatedKeyedStateKey = (key: string, contractId: string) => {
if (key === `ht-base-info-${contractId}`) return true
if (key.startsWith(`work-content-${contractId}-`)) return true
if (key.startsWith(`work-content-htExtraFee-${contractId}-`)) return true
return false
}
export const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${fromId}`) {
return `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${toId}`
}
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
}
if (key === `ht-base-info-${fromId}`) return `ht-base-info-${toId}`
if (key.startsWith(`work-content-${fromId}-`)) {
return key.replace(`work-content-${fromId}-`, `work-content-${toId}-`)
}
if (key.startsWith(`work-content-htExtraFee-${fromId}-`)) {
return key.replace(`work-content-htExtraFee-${fromId}-`, `work-content-htExtraFee-${toId}-`)
}
for (const prefix of PRICING_KEY_PREFIXES) {
if (key.startsWith(`${prefix}${fromId}-`)) {
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
}
}
return key
}
export const generateContractId = (usedIds: Set<string>) => {
let nextId = ''
while (!nextId || usedIds.has(nextId)) {
nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
}
usedIds.add(nextId)
return nextId
}
+156 -1
View File
@@ -1,5 +1,4 @@
import Decimal from 'decimal.js' import Decimal from 'decimal.js'
import { isFiniteNumber } from '@/lib/number'
type MaybeNumber = number | null | undefined type MaybeNumber = number | null | undefined
type DecimalInput = Decimal.Value type DecimalInput = Decimal.Value
@@ -9,6 +8,26 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value)
export const roundTo = (value: DecimalInput, decimalPlaces = 2) => export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber() 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>) => { const sumFiniteValues = (values: Iterable<unknown>) => {
let total = new Decimal(0) let total = new Decimal(0)
for (const value of values) { for (const value of values) {
@@ -30,6 +49,12 @@ export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
return total.toNumber() 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[] }) => { export const decimalAggSum = (params: { values?: unknown[] }) => {
const values = params.values || [] const values = params.values || []
let hasFinite = false let hasFinite = false
@@ -41,3 +66,133 @@ export const decimalAggSum = (params: { values?: unknown[] }) => {
if (!hasFinite) return null if (!hasFinite) return null
return sumFiniteValues(values) 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
}
}
+145 -12
View File
@@ -1,23 +1,33 @@
import type { GridOptions } from 'ag-grid-community' import type {
CellPosition,
ColDef,
GridApi,
GridSizeChangedEvent,
FirstDataRenderedEvent,
RowDataUpdatedEvent,
ColumnResizedEvent,
GridOptions,
SuppressKeyboardEventParams
} from 'ag-grid-community'
import { themeQuartz } from 'ag-grid-community' import { themeQuartz } from 'ag-grid-community'
const borderConfig = { const borderConfig = {
style: 'solid', style: 'solid',
width: 0.3, width: 0.3,
color: '#d3d3d3' color: 'var(--border)'
} }
export const myTheme = themeQuartz.withParams({ export const myTheme = themeQuartz.withParams({
wrapperBorder: false, wrapperBorder: false,
wrapperBorderRadius: 0, wrapperBorderRadius: 0,
headerBackgroundColor: '#f0f2f3', headerBackgroundColor: 'var(--muted)',
headerTextColor: '#374151', headerTextColor: 'var(--foreground)',
headerFontSize: 15, headerFontSize: 15,
headerFontWeight: 'normal', headerFontWeight: 'normal',
rowBorder: borderConfig, rowBorder: borderConfig,
columnBorder: borderConfig, columnBorder: borderConfig,
headerRowBorder: borderConfig, headerRowBorder: borderConfig,
dataBackgroundColor: '#fefefe' dataBackgroundColor: 'var(--card)'
}) })
// AG Grid 容器通用 class(占满父容器,配合父元素为 flex/grid 且有明确高度使用) // AG Grid 容器通用 class(占满父容器,配合父元素为 flex/grid 且有明确高度使用)
@@ -26,6 +36,121 @@ export const agGridWrapClass = 'ag-theme-quartz h-full min-h-0 w-full flex-1'
// AG Grid 组件通用 style(撑满容器 div) // AG Grid 组件通用 style(撑满容器 div)
export const agGridStyle = { height: '100%' } 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 = { export const gridOptions: GridOptions = {
treeData: true, treeData: true,
animateRows: true, animateRows: true,
@@ -37,6 +162,8 @@ export const gridOptions: GridOptions = {
suppressContextMenu: false, suppressContextMenu: false,
groupDefaultExpanded: -1, groupDefaultExpanded: -1,
suppressFieldDotNotation: true, suppressFieldDotNotation: true,
enterNavigatesVertically: true,
enterNavigatesVerticallyAfterEdit: true,
// rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。 // rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。
getRowId: params => { getRowId: params => {
const id = params.data?.id const id = params.data?.id
@@ -57,15 +184,21 @@ export const gridOptions: GridOptions = {
return [fallback || '__row__'] return [fallback || '__row__']
}, },
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'], getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: { defaultColDef: agGridDefaultColDef,
resizable: true,
sortable: false,
filter: false,
wrapHeaderText: true,
autoHeaderHeight: true
},
defaultColGroupDef: { defaultColGroupDef: {
wrapHeaderText: true, wrapHeaderText: true,
autoHeaderHeight: 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)
} }
} }
+22 -11
View File
@@ -1,8 +1,11 @@
export const isFiniteNumber = (value: unknown): value is number => import {
typeof value === 'number' && Number.isFinite(value) evaluateDecimalExpression,
isFiniteNumber,
roundTo,
toFiniteNumberOrNull
} from '@/lib/decimal'
export const toFiniteNumberOrNull = (value: unknown): number | null => export { isFiniteNumber, toFiniteNumberOrNull }
isFiniteNumber(value) ? value : null
export const parseNumberOrNull = ( export const parseNumberOrNull = (
value: unknown, value: unknown,
@@ -10,12 +13,23 @@ export const parseNumberOrNull = (
): number | null => { ): number | null => {
if (value === '' || value == null) return null if (value === '' || value == null) return null
const normalized = const normalizedValue =
options?.sanitize && typeof value === 'string' options?.sanitize && typeof value === 'string'
? value.replace(/[^0-9.\-]/g, '') ? value.replace(/[^0-9.+\-*/()\s]/g, '')
: value : value
const numericValue = Number(normalized) 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 if (!Number.isFinite(numericValue)) return null
const precision = options?.precision const precision = options?.precision
@@ -23,8 +37,5 @@ export const parseNumberOrNull = (
return numericValue return numericValue
} }
const factor = 10 ** precision return roundTo(numericValue, precision)
if (!Number.isFinite(factor) || factor <= 0) return numericValue
if (numericValue >= 0) return Math.round((numericValue + Number.EPSILON) * factor) / factor
return -Math.round((-numericValue + Number.EPSILON) * factor) / factor
} }
+68
View File
@@ -0,0 +1,68 @@
/**
* 工时法计算模块
*
* 提供工时法的行构建、合并、费用计算等纯函数,
* 供 HourlyPricingPane/HourlyFeeGrid 和 pricingMethodTotals.ts 共用。
*/
import { expertList } from '@/sql'
import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
/* ----------------------------------------------------------------
* 专家字典查询
* ---------------------------------------------------------------- */
/** 获取专家条目列表(按 ID 排序) */
export const getExpertEntries = (): [string, ExpertLite][] =>
Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
/** 计算专家默认采用单价 = 基准单价 × 管理系数 */
const getDefaultHourlyAdoptedPrice = (expert: ExpertLite): number | null => {
if (expert.defPrice == null || expert.manageCoe == null) return null
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
/* ----------------------------------------------------------------
* 行构建与合并
* ---------------------------------------------------------------- */
/** 构建工时法默认行 */
export const buildDefaultHourlyRows = (): HourlyDetailRow[] =>
getExpertEntries().map(([expertId, expert]) => ({
id: `expert-${expertId}`,
adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert),
personnelCount: null,
workdayCount: null
}))
/** 合并持久化行与默认行 */
export const mergeHourlyRows = (
rowsFromDb: Array<Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>> | undefined
): HourlyDetailRow[] => {
const dbMap = new Map<string, Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>>()
for (const row of rowsFromDb || []) dbMap.set(row.id, row)
return buildDefaultHourlyRows().map(row => {
const fromDb = dbMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice),
personnelCount: toFiniteNumberOrNull(fromDb.personnelCount),
workdayCount: toFiniteNumberOrNull(fromDb.workdayCount)
}
})
}
/* ----------------------------------------------------------------
* 费用计算
* ---------------------------------------------------------------- */
/** 计算工时法单行费用 = 采用单价 × 人数 × 工日数 */
export const calcHourlyServiceBudget = (row: HourlyDetailRow): number | null => {
const { adoptedBudgetUnitPrice, personnelCount, workdayCount } = row
if (adoptedBudgetUnitPrice == null || personnelCount == null || workdayCount == null) return null
return roundTo(toDecimal(adoptedBudgetUnitPrice).mul(personnelCount).mul(workdayCount), 2)
}
+136 -53
View File
@@ -5,14 +5,15 @@ import {
getServiceDictById, getServiceDictById,
taskList taskList
} from '@/sql' } from '@/sql'
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { roundTo, sumByNumber, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number' import { getScaleBudgetFee } from '@/lib/pricingScaleFee'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee' import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail'
import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing' import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
interface StoredDetailRowsState<T = any> { interface StoredDetailRowsState<T = any> {
detailRows?: T[] detailRows?: T[]
totalAmount?: number | null
} }
interface StoredFactorState { interface StoredFactorState {
@@ -36,10 +37,22 @@ const sumByNumberNullable = <T>(list: T[], pick: (item: T) => MaybeNumber): numb
return hasValid ? total : null return hasValid ? total : null
} }
const getOnlyCostScaleSummaryAmount = (
rows?: Array<{ amount?: unknown; isGroupRow?: unknown }>,
totalAmount?: unknown
) => {
if (typeof totalAmount === 'number' && Number.isFinite(totalAmount)) return totalAmount
const summaryRow = (rows || []).find(row => row?.isGroupRow === true)
if (typeof summaryRow?.amount === 'number' && Number.isFinite(summaryRow.amount)) return summaryRow.amount
return null
}
interface ScaleRow { interface ScaleRow {
id: string id: string
amount: number | null amount: number | null
landArea: number | null landArea: number | null
benchmarkBudgetBasicChecked: boolean
benchmarkBudgetOptionalChecked: boolean
consultCategoryFactor: number | null consultCategoryFactor: number | null
majorFactor: number | null majorFactor: number | null
workStageFactor: number | null workStageFactor: number | null
@@ -189,6 +202,20 @@ const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
return map return map
} }
const parseScopedMajorId = (value: unknown) => {
const raw = String(value || '').trim()
const scoped = /^\d+::(.+)$/.exec(raw)
return (scoped ? String(scoped[1] || '').trim() : raw) || raw
}
const hasScopedScaleRows = (rows?: Array<Record<string, unknown>>) =>
(rows || []).some(row => {
const id = String(row?.id || '')
if (/^\d+::/.test(id)) return true
const projectIndex = Number((row as { projectIndex?: unknown })?.projectIndex)
return Number.isFinite(projectIndex) && projectIndex > 1
})
const getDefaultConsultCategoryFactor = (serviceId: string | number) => { const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)] const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
return toFiniteNumberOrNull(service?.defCoe) return toFiniteNumberOrNull(service?.defCoe)
@@ -248,10 +275,12 @@ const resolveFactorValue = (
fallback: number | null fallback: number | null
) => { ) => {
if (!row) return fallback if (!row) return fallback
const budgetValue = toFiniteNumberOrNull(row.budgetValue) if (hasOwn(row, 'budgetValue')) {
if (budgetValue != null) return budgetValue return toFiniteNumberOrNull(row.budgetValue)
const standardFactor = toFiniteNumberOrNull(row.standardFactor) }
if (standardFactor != null) return standardFactor if (hasOwn(row, 'standardFactor')) {
return toFiniteNumberOrNull(row.standardFactor)
}
return fallback return fallback
} }
@@ -299,6 +328,8 @@ const buildDefaultScaleRows = (
id, id,
amount: null, amount: null,
landArea: null, landArea: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
consultCategoryFactor: defaultConsultCategoryFactor, consultCategoryFactor: defaultConsultCategoryFactor,
majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactorById(id), majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactorById(id),
workStageFactor: 1, workStageFactor: 1,
@@ -337,6 +368,14 @@ const mergeScaleRows = (
...row, ...row,
amount: toFiniteNumberOrNull(fromDb.amount), amount: toFiniteNumberOrNull(fromDb.amount),
landArea: toFiniteNumberOrNull(fromDb.landArea), landArea: toFiniteNumberOrNull(fromDb.landArea),
benchmarkBudgetBasicChecked:
typeof (fromDb as { benchmarkBudgetBasicChecked?: unknown }).benchmarkBudgetBasicChecked === 'boolean'
? Boolean((fromDb as { benchmarkBudgetBasicChecked?: unknown }).benchmarkBudgetBasicChecked)
: true,
benchmarkBudgetOptionalChecked:
typeof (fromDb as { benchmarkBudgetOptionalChecked?: unknown }).benchmarkBudgetOptionalChecked === 'boolean'
? Boolean((fromDb as { benchmarkBudgetOptionalChecked?: unknown }).benchmarkBudgetOptionalChecked)
: true,
consultCategoryFactor: consultCategoryFactor:
toFiniteNumberOrNull(fromDb.consultCategoryFactor) ?? toFiniteNumberOrNull(fromDb.consultCategoryFactor) ??
(hasConsultCategoryFactor ? null : defaultConsultCategoryFactor), (hasConsultCategoryFactor ? null : defaultConsultCategoryFactor),
@@ -353,30 +392,18 @@ const mergeScaleRows = (
}) })
} }
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) => const getInvestmentBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'cost')
getBenchmarkBudgetByScale(amount, 'cost')
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) =>
getBenchmarkBudgetByScale(landArea, 'area')
const getInvestmentBudgetFee = (row: ScaleRow) => {
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(row.amount),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
})
}
const getOnlyCostScaleBudgetFee = ( const getOnlyCostScaleBudgetFee = (
serviceId: string, serviceId: string,
rowsFromDb: Array<Record<string, unknown>> | undefined, rowsFromDb: Array<Record<string, unknown>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>, consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>, majorFactorMap?: Map<string, number | null>,
industryId?: string | null industryId?: string | null,
totalAmount?: number | null
) => { ) => {
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const rawRows = rowsFromDb || []
const sourceRows = stripGroupScaleRows(rowsFromDb) const sourceRows = stripGroupScaleRows(rowsFromDb)
const defaultConsultCategoryFactor = const defaultConsultCategoryFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
@@ -395,35 +422,37 @@ const getOnlyCostScaleBudgetFee = (
return sumByNumberNullable(sourceRows, row => { return sumByNumberNullable(sourceRows, row => {
const amount = toFiniteNumberOrNull(row?.amount) const amount = toFiniteNumberOrNull(row?.amount)
if (amount == null) return null if (amount == null) return null
return getScaleBudgetFee({ return getScaleBudgetFeeByRow({
benchmarkBudget: getBenchmarkBudgetByAmount(amount), amount,
benchmarkBudgetBasicChecked: typeof row?.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked: typeof row?.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true,
majorFactor: getRowNumberOrFallback(row, 'majorFactor', defaultMajorFactor), majorFactor: getRowNumberOrFallback(row, 'majorFactor', defaultMajorFactor),
consultCategoryFactor: getRowNumberOrFallback(row, 'consultCategoryFactor', defaultConsultCategoryFactor), consultCategoryFactor: getRowNumberOrFallback(row, 'consultCategoryFactor', defaultConsultCategoryFactor),
workStageFactor: getRowNumberOrFallback(row, 'workStageFactor', 1), workStageFactor: getRowNumberOrFallback(row, 'workStageFactor', 1),
workRatio: getRowNumberOrFallback(row, 'workRatio', 100) workRatio: getRowNumberOrFallback(row, 'workRatio', 100)
}) }, 'cost')
}) })
} }
const totalAmount = sumByNumberNullable(sourceRows, row => const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount)
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null if (resolvedTotalAmount == null) return null
)
if (totalAmount == null) return null
const onlyRow = const onlyRow =
sourceRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) || rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) || sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) ||
sourceRows[0] sourceRows[0]
const consultCategoryFactor = getRowNumberOrFallback(onlyRow, 'consultCategoryFactor', defaultConsultCategoryFactor) const consultCategoryFactor = getRowNumberOrFallback(onlyRow, 'consultCategoryFactor', defaultConsultCategoryFactor)
const majorFactor = getRowNumberOrFallback(onlyRow, 'majorFactor', defaultMajorFactor) const majorFactor = getRowNumberOrFallback(onlyRow, 'majorFactor', defaultMajorFactor)
const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1) const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1)
const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100) const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100)
return getScaleBudgetFee({ return getScaleBudgetFeeByRow({
benchmarkBudget: getBenchmarkBudgetByAmount(totalAmount), amount: resolvedTotalAmount,
benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true,
majorFactor, majorFactor,
consultCategoryFactor, consultCategoryFactor,
workStageFactor, workStageFactor,
workRatio workRatio
}) }, 'cost')
} }
const buildOnlyCostScaleDetailRows = ( const buildOnlyCostScaleDetailRows = (
@@ -431,16 +460,16 @@ const buildOnlyCostScaleDetailRows = (
rowsFromDb: Array<Record<string, unknown>> | undefined, rowsFromDb: Array<Record<string, unknown>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>, consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>, majorFactorMap?: Map<string, number | null>,
industryId?: string | null industryId?: string | null,
totalAmount?: number | null
) => { ) => {
const rawRows = rowsFromDb || []
const sourceRows = stripGroupScaleRows(rowsFromDb) const sourceRows = stripGroupScaleRows(rowsFromDb)
const totalAmount = sumByNumberNullable(sourceRows, row => const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount)
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null
)
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID
const onlyRow = const onlyRow =
sourceRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) || rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
sourceRows.find(row => String(row?.id || '') === onlyCostRowId) sourceRows.find(row => String(row?.id || '') === onlyCostRowId)
const consultCategoryFactor = getRowNumberOrFallback( const consultCategoryFactor = getRowNumberOrFallback(
onlyRow, onlyRow,
@@ -460,7 +489,8 @@ const buildOnlyCostScaleDetailRows = (
return [ return [
{ {
id: onlyCostRowId, id: onlyCostRowId,
amount: totalAmount, amount: resolvedTotalAmount,
landArea: null,
consultCategoryFactor, consultCategoryFactor,
majorFactor, majorFactor,
workStageFactor, workStageFactor,
@@ -471,15 +501,7 @@ const buildOnlyCostScaleDetailRows = (
] ]
} }
const getLandBudgetFee = (row: ScaleRow) => { const getLandBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'area')
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByLandArea(row.landArea),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
})
}
const getTaskEntriesByServiceId = (serviceId: string | number) => const getTaskEntriesByServiceId = (serviceId: string | number) =>
Object.entries(taskList as Record<string, TaskLite>) Object.entries(taskList as Record<string, TaskLite>)
@@ -614,6 +636,50 @@ const resolveScaleRows = (
return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap) return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap)
} }
const normalizeScopedScaleRows = (
serviceId: string,
rowsFromDb: Array<Record<string, unknown>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>
): ScaleRow[] => {
const rows = stripGroupScaleRows(rowsFromDb) as Array<Record<string, unknown>>
if (rows.length === 0) return []
const defaultConsultCategoryFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
return rows.map(row => {
const parsedMajorId = parseScopedMajorId(row.id)
const resolvedMajorId = majorById.has(parsedMajorId) ? parsedMajorId : (majorIdAliasMap.get(parsedMajorId) || parsedMajorId)
const hasConsultCategoryFactor = hasOwn(row, 'consultCategoryFactor')
const hasMajorFactor = hasOwn(row, 'majorFactor')
const hasWorkStageFactor = hasOwn(row, 'workStageFactor')
const hasWorkRatio = hasOwn(row, 'workRatio')
return {
id: resolvedMajorId,
amount: toFiniteNumberOrNull(row.amount),
landArea: toFiniteNumberOrNull(row.landArea),
benchmarkBudgetBasicChecked:
typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked:
typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true,
consultCategoryFactor:
toFiniteNumberOrNull(row.consultCategoryFactor) ??
(hasConsultCategoryFactor ? null : defaultConsultCategoryFactor),
majorFactor:
toFiniteNumberOrNull(row.majorFactor) ??
(hasMajorFactor ? null : (majorFactorMap?.get(resolvedMajorId) ?? getDefaultMajorFactorById(resolvedMajorId))),
workStageFactor:
toFiniteNumberOrNull(row.workStageFactor) ??
(hasWorkStageFactor ? null : 1),
workRatio:
toFiniteNumberOrNull(row.workRatio) ??
(hasWorkRatio ? null : 100)
}
})
}
// 统一生成某合同下某个咨询服务四种计费方式的存储键。
// 优先复用 Pinia store 当前约定的 key,避免与旧版 fallback key 脱节。
export const getPricingMethodDetailDbKeys = ( export const getPricingMethodDetailDbKeys = (
contractId: string, contractId: string,
serviceId: string | number serviceId: string | number
@@ -680,7 +746,8 @@ const buildDefaultPricingMethodDetailRows = (
context.htData?.detailRows as Array<Record<string, unknown>> | undefined, context.htData?.detailRows as Array<Record<string, unknown>> | undefined,
context.consultCategoryFactorMap, context.consultCategoryFactorMap,
context.majorFactorMap, context.majorFactorMap,
context.industryId context.industryId,
context.htData?.totalAmount ?? null
) )
: scaleRows.filter(row => { : scaleRows.filter(row => {
if (!isCostMajorById(row.id)) return false if (!isCostMajorById(row.id)) return false
@@ -697,6 +764,8 @@ const buildDefaultPricingMethodDetailRows = (
} }
} }
// 强制为一组服务重建并落库默认明细行。
// 这个方法会同时写入 Pinia 内存态和底层 KV 存储,适合“重置为默认值”场景。
export const persistDefaultPricingMethodDetailRowsForServices = async (params: { export const persistDefaultPricingMethodDetailRowsForServices = async (params: {
contractId: string contractId: string
serviceIds: Array<string | number> serviceIds: Array<string | number>
@@ -729,6 +798,8 @@ export const persistDefaultPricingMethodDetailRowsForServices = async (params: {
) )
} }
// 汇总单个服务的四类计费方式金额。
// 数据读取顺序是:优先读当前 Pinia 中已加载的计费页数据,缺失时再回退到 KV 存储和合同段默认信息。
export const getPricingMethodTotalsForService = async (params: { export const getPricingMethodTotalsForService = async (params: {
contractId: string contractId: string
serviceId: string | number serviceId: string | number
@@ -772,6 +843,14 @@ export const getPricingMethodTotalsForService = async (params: {
// 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。 // 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。
const excludeInvestmentCostAndAreaRows = params.options?.excludeInvestmentCostAndAreaRows === true const excludeInvestmentCostAndAreaRows = params.options?.excludeInvestmentCostAndAreaRows === true
const investScaleRowsSource = stripGroupScaleRows(investData?.detailRows as Array<Record<string, unknown>> | undefined)
const landScaleRowsSource = stripGroupScaleRows(landData?.detailRows as Array<Record<string, unknown>> | undefined)
const scopedInvestRows = hasScopedScaleRows(investScaleRowsSource)
? normalizeScopedScaleRows(serviceId, investScaleRowsSource, consultCategoryFactorMap, majorFactorMap)
: null
const scopedLandRows = hasScopedScaleRows(landScaleRowsSource)
? normalizeScopedScaleRows(serviceId, landScaleRowsSource, consultCategoryFactorMap, majorFactorMap)
: null
const investScale = onlyCostScale const investScale = onlyCostScale
? getOnlyCostScaleBudgetFee( ? getOnlyCostScaleBudgetFee(
serviceId, serviceId,
@@ -779,10 +858,11 @@ export const getPricingMethodTotalsForService = async (params: {
(htData?.detailRows as Array<Record<string, unknown>> | undefined), (htData?.detailRows as Array<Record<string, unknown>> | undefined),
consultCategoryFactorMap, consultCategoryFactorMap,
majorFactorMap, majorFactorMap,
industryId industryId,
htData?.totalAmount ?? null
) )
: (() => { : (() => {
const investRows = resolveScaleRows( const investRows = scopedInvestRows || resolveScaleRows(
serviceId, serviceId,
investData, investData,
htData, htData,
@@ -796,7 +876,7 @@ export const getPricingMethodTotalsForService = async (params: {
}) })
})() })()
const landRows = resolveScaleRows( const landRows = scopedLandRows || resolveScaleRows(
serviceId, serviceId,
landData, landData,
htData, htData,
@@ -830,6 +910,8 @@ export const getPricingMethodTotalsForService = async (params: {
} }
} }
// 为一组服务补齐缺失的计费明细行,但不会覆盖已有用户数据。
// 适合在首次进入计费页或新增服务后做“按需初始化”。
export const ensurePricingMethodDetailRowsForServices = async (params: { export const ensurePricingMethodDetailRowsForServices = async (params: {
contractId: string contractId: string
serviceIds: Array<string | number> serviceIds: Array<string | number>
@@ -918,6 +1000,7 @@ export const ensurePricingMethodDetailRowsForServices = async (params: {
) )
} }
// 并行汇总多个服务的计费结果,返回以 serviceId 为 key 的 Map。
export const getPricingMethodTotalsForServices = async (params: { export const getPricingMethodTotalsForServices = async (params: {
contractId: string contractId: string
serviceIds: Array<string | number> serviceIds: Array<string | number>
+75
View File
@@ -0,0 +1,75 @@
/**
* 计价法持久化控制
*
* 管理 sessionStorage 中的 skip/force 标记,
* 用于控制计价法组件在清除/重建默认数据时的竞态。
*/
import { readCurrentProjectId } from '@/lib/workspace'
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
export const buildProjectScopedSessionKey = (prefix: string, dbKey: string) =>
`${prefix}${readCurrentProjectId()}:${dbKey}`
/**
* 判断当前是否应跳过持久化写入
* 用于防止组件卸载时覆盖刚被清除的数据
*/
export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean => {
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
/**
* 判断当前是否应强制加载默认数据(忽略已有持久化数据)
* 读取后立即清除标记(一次性)
*/
export const shouldForceDefaultLoad = (dbKey: string): boolean => {
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
/**
* 设置跳过持久化标记
* @param dbKey 存储键
* @param durationMs 有效时长(毫秒),默认 3000ms
*/
export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
const now = Date.now()
sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`)
}
/**
* 设置强制加载默认数据标记
* @param dbKey 存储键
* @param durationMs 有效时长(毫秒),默认 3000ms
*/
export const markForceDefaultLoad = (dbKey: string, durationMs = 3000): void => {
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
sessionStorage.setItem(storageKey, String(Date.now() + durationMs))
}
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more