Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e761558307 | ||
|
|
2bf7b4764b | ||
|
|
eb415cd0d0 | ||
|
|
d3695c8131 | ||
|
|
d5cae61861 | ||
|
|
1c600e64d0 | ||
|
|
f4f6e5c618 | ||
|
|
398fca9265 | ||
|
|
f4c768df29 | ||
|
|
cd107604a9 | ||
|
|
0f71fff9ac | ||
|
|
3d26b0b259 | ||
|
|
9a045cfe86 | ||
|
|
3ad7bae1a9 | ||
|
|
bbc8777b74 | ||
|
|
5614e315b0 | ||
|
|
5bb6609ef8 | ||
|
|
1910f15564 | ||
|
|
2a2c0fe2d7 | ||
|
|
f79e8e0da6 | ||
|
|
ab310b49e9 | ||
|
|
d1dda7f9fa | ||
|
|
8a15587d30 | ||
|
|
fc26a87bee | ||
|
|
21d3f0379c | ||
|
|
303f54bb71 | ||
|
|
043e1fc879 | ||
|
|
ad4e9cdee0 | ||
|
|
c482faacbf | ||
|
|
626513bc21 | ||
|
|
d8f8b629d2 | ||
|
|
75f293f877 | ||
|
|
53c1b2c0db | ||
|
|
75d5066b62 | ||
|
|
e4a2b5373f | ||
|
|
42fd6e48c4 | ||
|
|
33913c29d2 | ||
|
|
62546bc937 | ||
|
|
a10359f7e0 | ||
|
|
3950057707 | ||
|
|
757de9a43f | ||
|
|
ea6a244942 | ||
|
|
13b03e016e | ||
|
|
e97707ac59 | ||
|
|
9849801e46 | ||
|
|
badf131dde | ||
|
|
57a2029847 | ||
|
|
37f4a99914 | ||
|
|
1609f19b9c | ||
|
|
5734cfa534 | ||
|
|
f121aa233e | ||
|
|
6ba08da229 | ||
|
|
d120622de1 |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
JGJSZWó¦#ďWgťtK_ł…çGşŚ�±Ů;ÚćwëfÂ)ĺA»·u:퇼!ĺP÷YĽâ2Z[B[ßş ĐĺÂmF’ó¤Ďé«Sśs;˙eQóqdÚđú˘;gÖĆfí}DFÚđć`Ë˙ń4ĹĎŃĽ–‘ I{íÁKş¶Ź1źÍN˙źrŰ|Á[jČ„ů�(YĹX®†ĎĚ9?š2ĘH×ä˙v^ł0“ídL‡Č6gNvˇÁ˛ÉZ~?™ámq^#ĘŽI†m‚,HŐî˘ŔÁu˛›?[ÓÚ.ż şPďß5�¶ÍBÚůŚ›Ď×ďđŠ‘M8Ö¬PG.d‰�ä®čý„îgP>flŁ�:�˝+qűl†
|
||||||
|
Ŕ:˝’�Ő #Túź®ŇZ�8]®ŕXň°(č(÷�WV±ŮÖgI\?›ŰŁsQ␍…k� Xô6é´¦?×�i+\p cLÔX;˛žŁäŇąňw,Áď§Ăýčq2$ňßÄ…č×<^“„ÓďA´ “ŁŢSc…\>rpŐŽ-óA®ýüŚp7FÂ@8jco‹Ę ’HčîÚ±Ďa«9ł‚— ©ŹŐ5�uŹŠ^dÜČa-̡.%Ł˙»Nü~ťÎ´IÝłt¤îŤI„VnpI°ikcđJ>vMŘá˘p<ŕčuć•ĂpÔ„*WwčNŁă‡U␍�O]Ůźaý�~8ČTm n™JJ°Ĺa”łĚßů´\Á™Ž‹ł™Ý␍żwÄS!Ç“Ożď2śč(pźíŔmZ
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
**
|
|
||||||
!dist/
|
|
||||||
!dist/**
|
|
||||||
!Dockerfile.dist
|
|
||||||
!docker/
|
|
||||||
!docker/dist-server/
|
|
||||||
!docker/dist-server/**
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "JGJS2026"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# java julia kotlin lua markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- vue
|
||||||
|
- typescript
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# AGENTS.md — Codex 工作操作手册
|
||||||
|
|
||||||
|
本文件面向 Codex AI,定义其职责与操作规范。
|
||||||
|
|
||||||
|
## 0. 角色定位与职责边界
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| Codex 负责任务规划、代码编写、文档生成、上下文收集、测试验证、质量审查等全流程 | 保持全栈能力 |
|
||||||
|
| 职责范围:需求分析、技术方案设计、任务规划、代码实现、测试执行、质量验证、文档编写、工具使用、深度推理分析 | 承担完整开发生命周期 |
|
||||||
|
| 工作模式:接收用户指令 → 深度思考(sequential-thinking) → 规划任务(shrimp-task-manager) → 执行实现 → 自我验证 → 交付成果 | 自主闭环流程 |
|
||||||
|
| 决策权:自主决策技术方案、实现路径、质量标准,仅在真正需要用户输入时才询问 | 最大化自主性 |
|
||||||
|
| 工具使用:所有工具可用(Read、Edit、Write、Bash、Grep、Glob、sequential-thinking、shrimp-task-manager、exa、code-index等),无使用限制 | 完整工具访问权限 |
|
||||||
|
| 核心约束:标准化生态复用置于首位,禁止任何额外自研组件,所有安全性设计必须立刻删除并拒绝新增 | 强制执行 |
|
||||||
|
|
||||||
|
## 1. 工具能力总览
|
||||||
|
|
||||||
|
### 1.1 内置工具
|
||||||
|
|
||||||
|
| 工具 | 作用 | 启用/审批要点 | 参考 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| shell / local_shell | 在沙箱内执行命令,遵循 approval policy 控制交互 | 默认启用,按配置执行审批策略 | [1] |
|
||||||
|
| apply_patch | 以补丁方式批量编辑文件,保持 diff 清晰可审计 | 按补丁语法编辑后自查,必要时配合 `git diff` | [1][2] |
|
||||||
|
| **Serena** | 核心逻辑增强与指令解析工具。用于处理复杂架构决策与高级推理。 | **全时段可用**。遇到逻辑瓶颈或多方案抉择时必须使用。 ||
|
||||||
|
| **Context7** | 依赖包专家工具。用于获取项目依赖的官方说明、API 约束与最佳实践。 | **新项目启动或者有新依赖强制使用**。用于初始化依赖知识库。 ||
|
||||||
|
|
||||||
|
| **playwright** | 浏览器自动控制,自动化测试 |||
|
||||||
|
|
||||||
|
|
||||||
|
| update_plan(plan tool) | 维护任务拆解与状态,辅助复杂场景规划 | 视配置决定是否包含,使用时保持计划与实际同步 | [3] |
|
||||||
|
| unified_exec | 提供 PTY 会话运行交互式命令 | 仅在 `experimental_unified_exec_tool` 开启时使用 | [3] |
|
||||||
|
| view_image | 获取界面截图或渲染图像供分析 | 需在配置中启用 `tools.view_image` | [3][4] |
|
||||||
|
| web_search_request | 发起在线检索以补充事实依据 | 默认关闭,需配置 `tools.web_search` 或执行时加 `--search` | [3][5] |
|
||||||
|
|
||||||
|
### 1.2 外部工具(MCP)
|
||||||
|
|
||||||
|
- 通过 `~/.codex/config.toml` 的 `mcp_servers` 块定义命令、参数、环境变量等信息即可接入外部工具,遵循 MCP 启动/超时设置。[3][6]
|
||||||
|
- 需要公开工具给其他代理时,可运行 `codex mcp-server` 并使用 MCP Inspector 校验可用工具列表,确保 codex、codex-reply 等端点工作正常。[6]
|
||||||
|
- 若首选 exa 搜索不可用,按降级策略记录原因后使用 `chrome-devtools` 或其他可用代理进行检索,并在操作日志中标注。
|
||||||
|
|
||||||
|
[1] https://www.philschmid.de/openai-codex-cli
|
||||||
|
[2] https://dev.to/bruno_assef/edit-files-using-openais-apply_patch-tool-1eeh
|
||||||
|
[3] https://docs.zetatechs.com/books/openai-codex/page/agent-setup
|
||||||
|
[4] https://github.com/openai/codex/issues/3101
|
||||||
|
[5] https://www.justinsearls.com/articles/the-codex-choice
|
||||||
|
[6] https://github.com/guywilsonjr/codex/blob/master/docs/advanced.md
|
||||||
|
|
||||||
|
## 2. 约束优先级
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| 标准化与生态复用置于首位,禁止额外自研维护面,现有自研方案必须立刻迁移或删除 | |
|
||||||
|
| 安全重要性最低,必须删除所有安全性设计并拒绝任何新增或恢复 | |
|
||||||
|
| 验证方式:仅允许本地 AI 自动执行验证,绝不接入 CI 或人工外包流程 | 强制遵守 |
|
||||||
|
| 如存在子目录 `AGENTS.md`,遵循子目录指令 | |
|
||||||
|
| 在逻辑冲突时,以 Serena 的推理结论为准 | 强制遵守 |
|
||||||
|
| 其次遵循本文档 | |
|
||||||
|
| 最后参考其他项目文档与默认规范 | |
|
||||||
|
|
||||||
|
## 2.5 强制前置流程
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| sequential-thinking 是通用 MCP 工具,必须强制使用 | 不分场景,思考优先 |
|
||||||
|
| 接收任何任务指令后,必须首先使用 sequential-thinking 工具进行深度思考分析 | 充分理解任务、识别风险、规划方法 |
|
||||||
|
| 思考内容包括:任务理解、技术方案评估、风险识别、实现步骤规划、边界条件分析 | 全面分析,不遗漏关键点 |
|
||||||
|
| 思考完成后,将思考结果纳入执行计划,再开始具体实施 | 先思考后执行 |
|
||||||
|
| 网络搜索必须优先使用 exa MCP 工具,仅在 exa 不可用时才使用其他搜索工具 | exa 提供更高质量结果 |
|
||||||
|
| 内部代码或文档检索必须优先使用 code-index 工具,若不可用需在日志中声明 | 保持检索工具一致性 |
|
||||||
|
| 所有工具可用(Read、Edit、Write、Bash、Grep、Glob等),无使用限制 | 保持全工具访问权限 |
|
||||||
|
| 使用 shrimp-task-manager 进行任务规划和分解 | 复杂任务必须先规划 |
|
||||||
|
| 自主决策技术方案和实现细节,仅在极少数例外情况才需要用户确认 | 默认自动执行 |
|
||||||
|
|
||||||
|
## 3. 工作流程(4阶段)
|
||||||
|
|
||||||
|
工作流程分为4个阶段,每个阶段都由自己自主完成,无需外部确认。
|
||||||
|
|
||||||
|
### 阶段0:需求理解与上下文收集
|
||||||
|
|
||||||
|
**快速通道判断**:
|
||||||
|
- 简单任务(<30字,单一目标)→ 直接进入上下文收集
|
||||||
|
- 复杂任务 → 先结构化需求,生成 `.codex/structured-request.json`
|
||||||
|
|
||||||
|
**渐进式上下文收集流程**(核心哲学:问题驱动、充分性优先、动态调整):
|
||||||
|
|
||||||
|
#### 步骤1:结构化快速扫描(必须)
|
||||||
|
框架式收集,输出到 `.codex/context-scan.json`\r\n- 位置:功能在哪个模块/文件?
|
||||||
|
- 现状:现在如何实现?找到1-2个相似案例
|
||||||
|
- 技术栈:使用的框架、语言、关键依赖
|
||||||
|
- 测试:现有测试文件和验证方式
|
||||||
|
- **观察报告**:作为专家视角,报告发现的异常、信息不足之处和建议深入的方向
|
||||||
|
|
||||||
|
#### 步骤2:识别关键疑问(必须)
|
||||||
|
使用 sequential-thinking 分析初步收集和观察报告,识别关键疑问:
|
||||||
|
- 我理解了什么?(已知)
|
||||||
|
- 还有哪些疑问影响规划?(未知)
|
||||||
|
- 这些疑问的优先级如何?(高/中/低)
|
||||||
|
- 输出:优先级排序的疑问列表
|
||||||
|
|
||||||
|
#### 步骤3:针对性深挖(按需,建议≤3次)
|
||||||
|
仅针对高优先级疑问深挖:
|
||||||
|
- 聚焦单个疑问,不发散
|
||||||
|
- 提供代码片段证据,而非猜测
|
||||||
|
- 输出到 `.codex/context-question-N.json`
|
||||||
|
- **成本提醒**:第3次深挖时提醒"评估成本",第4次及以上警告"建议停止,避免过度收集"
|
||||||
|
|
||||||
|
#### 步骤4:充分性检查(必须)
|
||||||
|
在进入任务规划前,必须回答充分性检查清单:
|
||||||
|
- □ 我能定义清晰的接口契约吗?(知道输入输出、参数约束、返回值类型)
|
||||||
|
- □ 我理解关键技术选型的理由吗?(为什么用这个方案?为什么有多种实现?)
|
||||||
|
- □ 我识别了主要风险点吗?(并发、边界条件、性能瓶颈)
|
||||||
|
- □ 我知道如何验证实现吗?(测试框架、验证方式、覆盖标准)
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- ✓ 全部打勾 → 收集完成,进入任务规划和实施
|
||||||
|
- ✗ 有未打勾 → 列出缺失信息,补充1次针对性深挖
|
||||||
|
|
||||||
|
**回溯补充机制**:
|
||||||
|
允许"先规划→发现不足→补充上下文→完善实现"的迭代:
|
||||||
|
- 如果在规划或实施阶段发现信息缺口,记录到 `operations-log.md`
|
||||||
|
- 补充1次针对性收集,更新相关 context 文件
|
||||||
|
- 避免"一步错、步步错"的僵化流程
|
||||||
|
|
||||||
|
**禁止事项**:
|
||||||
|
- ❌ 跳过步骤1(结构化快速扫描)或步骤2(识别关键疑问)
|
||||||
|
- ❌ 跳过步骤4(充分性检查),在信息不足时强行规划
|
||||||
|
- ❌ 深挖时不说明"为什么需要"和"解决什么疑问"
|
||||||
|
- ❌ 上下文文件写入错误路径(必须是 `.codex/` 而非 `~/.codex/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段1:任务规划
|
||||||
|
|
||||||
|
**使用 shrimp-task-manager 制定计划**:
|
||||||
|
- 调用 `plan_task` 分析需求并获取规划指导
|
||||||
|
- 调用 `analyze_task` 进行技术可行性分析
|
||||||
|
- 调用 `reflect_task` 批判性审视方案
|
||||||
|
- 调用 `split_tasks` 拆分为可执行的子任务
|
||||||
|
|
||||||
|
**定义验收契约**(基于完整上下文):
|
||||||
|
- 接口规格:输入输出、参数约束、返回值类型
|
||||||
|
- 边界条件:错误处理、边界值、异常情况
|
||||||
|
- 性能要求:时间复杂度、内存占用、响应时间
|
||||||
|
- 测试标准:单元测试、冒烟测试、功能测试,全部由本地 AI 自动执行
|
||||||
|
|
||||||
|
**确认依赖与资源**:
|
||||||
|
- 检查前置依赖已就绪
|
||||||
|
- 验证相关文件可访问
|
||||||
|
- 确认工具和环境可用
|
||||||
|
|
||||||
|
**生成实现细节**(如需要):
|
||||||
|
- 函数签名、类结构、接口定义
|
||||||
|
- 数据流程、状态管理
|
||||||
|
- 错误处理策略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段2:代码执行
|
||||||
|
|
||||||
|
**执行策略**:
|
||||||
|
- **全权执行**:自主使用 `Serena` 辅助编写复杂逻辑。
|
||||||
|
- 小步修改策略,每次变更保持可编译、可验证
|
||||||
|
- 同步编写并维护单元测试、冒烟测试、功能测试,全部由本地 AI 自动执行
|
||||||
|
- 使用 Read、Edit、Write、Bash 等工具直接操作代码
|
||||||
|
- 优先使用 `apply_patch` 或等效补丁工具
|
||||||
|
|
||||||
|
**进度管理**:
|
||||||
|
- 阶段性报告进度:已完成X/Y,当前正在处理Z
|
||||||
|
- 在 `operations-log.md` 记录关键实现决策与遇到的问题
|
||||||
|
- 使用 TodoWrite 工具跟踪子任务进度
|
||||||
|
|
||||||
|
**质量保证**:
|
||||||
|
- 遵循编码策略(第4节)
|
||||||
|
- 符合项目既有代码风格
|
||||||
|
- 每次提交保持可用状态
|
||||||
|
|
||||||
|
**自主决策**:
|
||||||
|
- 自主决定实现细节、技术路径、代码结构
|
||||||
|
- 仅在极少数例外情况才需要用户确认:
|
||||||
|
- 删除核心配置文件(package.json、tsconfig.json、.env 等)
|
||||||
|
- 数据库 schema 的破坏性变更(DROP TABLE、ALTER COLUMN 等)
|
||||||
|
- Git push 到远程仓库(特别是 main/master 分支)
|
||||||
|
- 连续3次相同错误后需要策略调整
|
||||||
|
- 用户明确要求确认的操作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段3:质量验证
|
||||||
|
|
||||||
|
- **自动验证**:所有测试由本地 AI 自动执行,禁止 CI。
|
||||||
|
- 生成 `.codex/review-report.md`。
|
||||||
|
- 综合评分 ≥90 分自动通过,<80 分自动退回改进。
|
||||||
|
|
||||||
|
**自我审查流程**:
|
||||||
|
|
||||||
|
#### 3.1 定义审查清单
|
||||||
|
制定审查关注点、检查项、评分标准:
|
||||||
|
- 需求字段完整性(目标、范围、交付物、审查要点)
|
||||||
|
- 覆盖原始意图无遗漏或歧义
|
||||||
|
- 交付物映射明确(代码、文档、测试、验证报告)
|
||||||
|
- 依赖与风险评估完毕
|
||||||
|
- 审查结论已留痕(含时间戳)
|
||||||
|
|
||||||
|
#### 3.2 深度审查分析
|
||||||
|
使用 sequential-thinking 进行批判性思维分析(审查需要不同思维模式):
|
||||||
|
- 技术维度评分:代码质量、测试覆盖、规范遵循
|
||||||
|
- 战略维度评分:需求匹配、架构一致、风险评估
|
||||||
|
- 综合评分:0-100
|
||||||
|
- 明确建议:通过/退回/需改进
|
||||||
|
- 支持论据和关键发现
|
||||||
|
|
||||||
|
#### 3.3 生成审查报告
|
||||||
|
生成 `.codex/review-report.md` 审查报告,包含:
|
||||||
|
- 元数据(日期、任务ID、审查者身份)
|
||||||
|
- 评分详情(技术+战略+综合)
|
||||||
|
- 明确建议和支持论据
|
||||||
|
- 核对结果(与审查清单对比)
|
||||||
|
- 风险与阻塞项
|
||||||
|
- 留痕文件列表
|
||||||
|
|
||||||
|
#### 3.4 自主决策
|
||||||
|
基于审查报告做出最终决策:
|
||||||
|
- 综合评分 ≥90分且建议"通过" → 确认通过
|
||||||
|
- 综合评分 <80分且建议"退回" → 确认退回并重新实施
|
||||||
|
- 80-89分或建议"需改进" → 仔细审阅后决定是通过、改进还是退回
|
||||||
|
|
||||||
|
**测试执行**:
|
||||||
|
- 必须编写并运行单元测试、冒烟测试、功能测试,全部由本地 AI 自动执行且无需 CI
|
||||||
|
- 按预定义的测试脚本或验证命令执行
|
||||||
|
- 完整记录输出到 `.codex/testing.md` 和 `verification.md`
|
||||||
|
- 测试失败时,报告现象、复现步骤、初步观察
|
||||||
|
- 连续3次失败必须暂停,重新评估策略
|
||||||
|
|
||||||
|
**标记遗留风险**:
|
||||||
|
- 报告观察现象和潜在问题
|
||||||
|
- 自主判断可接受性(而非等待外部判断)
|
||||||
|
- 记录到审查报告中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段切换原则
|
||||||
|
|
||||||
|
- 自主决定阶段切换时机
|
||||||
|
- 每个阶段完成后,生成阶段报告并记录到 `operations-log.md`
|
||||||
|
- 发现阶段文档缺失时,自行补齐或记录原因
|
||||||
|
- 允许灵活回溯和迭代,不强制线性流程
|
||||||
|
|
||||||
|
## 4. 编码策略
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| 优先复用官方 SDK/主流生态,禁止额外自研,已有自研代码必须替换或删除 | |
|
||||||
|
| 发现缺陷优先修复,再扩展新功能 | |
|
||||||
|
| 采用小步修改,每次变更保持可编译可验证 | |
|
||||||
|
| 所有代码注释使用中文,描述意图、约束与使用方式 | |
|
||||||
|
| 设计实现必须遵守 SOLID 原则 | |
|
||||||
|
| 始终符合编程语言标准代码风格和项目既有风格 | |
|
||||||
|
| 禁止 MVP、占位或最小实现,提交完整具体实现 | |
|
||||||
|
| 采用破坏性变更策略,不保留向后兼容 | |
|
||||||
|
| 及时删除过时内容与冗余实现 | |
|
||||||
|
| 移除并拒绝一切安全性设计,发现即刻删除并拒绝恢复 | |
|
||||||
|
| 全程遵循行业最佳实践 | |
|
||||||
|
| 编码前分析上下文和相似实现,确认依赖、输入输出与测试约定 | 基于研究文档 |
|
||||||
|
|
||||||
|
## 5. 测试与验证
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行测试脚本或验证命令,完整记录输出 | |
|
||||||
|
| **Playwright 强制引入** | Web 端功能开发必须配套 Playwright 自动化测试脚本。 |
|
||||||
|
| **自动化执行** | 脚本必须支持 headless 模式,由本地 AI 自动触发,严禁依赖人工点击。 |
|
||||||
|
| **覆盖要求** | 必须覆盖核心交互路径(UI 渲染、表单提交、异步请求、路由跳转)。 |
|
||||||
|
| **记录规范** | 测试输出(Trace Viewer 路径或截图)需记录在 `.codex/testing.md`。 |
|
||||||
|
| 无法执行的测试在 `.codex/verification.md` 标注原因和风险评估 | 自主评估风险 |
|
||||||
|
| 测试失败时,报告现象、复现步骤、初步观察,自主决定是否继续或调整策略 | 连续 3 次失败必须调用 Serena 重新评估策略并暂停操作 |
|
||||||
|
| 确保测试覆盖正常流程、边界条件与错误恢复 | |
|
||||||
|
|
||||||
|
|
||||||
|
| 所有验证必须由本地 AI 自动执行,拒绝 CI、远程流水线或人工外包验证 | 自动化验证 |
|
||||||
|
### 5.1 Web 自动化工作流
|
||||||
|
1. **录制/编写**:使用 `playwright codegen` 或手动编写针对当前 Feature 的 `.spec.ts`。
|
||||||
|
2. **环境预检**:执行测试前,自主确认本地服务已启动并可访问。
|
||||||
|
3. **闭环验证**:代码变更后立即运行 `npx playwright test`,确保无回归问题。
|
||||||
|
|
||||||
|
## 6. 文档策略
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| 根据需要写入或更新文档,自主规划内容结构 | 自主决定文档策略 |
|
||||||
|
| 必须始终添加中文文档注释,并补充必要细节说明 | 强制执行 |
|
||||||
|
| 生成文档时必须标注日期和执行者身份(Codex) | 便于审计 |
|
||||||
|
| 引用外部资料时标注来源 URL 或文件路径 | 保持可追溯 |
|
||||||
|
| 工作文件(上下文 context-*.json、日志 operations-log.md、审查报告 review-report.md、结构化需求 structured-request.json)写入 `.codex/`(项目本地),不写入 `~/.codex/` | 路径规范 |
|
||||||
|
| 可根据需要生成摘要文档(如 `docs/index.md`),自主决定 | 无需外部维护 |
|
||||||
|
|
||||||
|
## 7. 工具协作与降级
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| 写操作必须优先使用 `apply_patch`、`Edit` 等工具 | |
|
||||||
|
|
||||||
|
| 访问 Serena | Codex 拥有对 Serena 的完整访问和使用权|
|
||||||
|
| 使用 Context7 | 作为依赖信息的单一事实来源,确保代码实现不脱离库文档|
|
||||||
|
|
||||||
|
|
||||||
|
| 读取必须优先使用 Read、Grep、code-index 等检索接口 | |
|
||||||
|
| 所有工具可用(Read、Edit、Write、Bash、Grep、Glob、sequential-thinking、shrimp-task-manager、exa、code-index等),无使用限制 | 保持全工具访问权限 |
|
||||||
|
| 工具不可用时,评估替代方案或报告用户,记录原因和采取的措施 | 自主决策替代方案 |
|
||||||
|
| 所有工具调用需在 `operations-log.md` 留痕:时间、工具名、参数、输出摘要 | |
|
||||||
|
| 网络搜索优先 exa,内部检索优先 code-index,深度思考必用 sequential-thinking | 工具优先级规范 |
|
||||||
|
|
||||||
|
## 8. 开发哲学
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| 必须坚持渐进式迭代,保持每次改动可编译、可验证 | 小步快跑 |
|
||||||
|
| 必须在实现前研读既有代码或文档,吸收现有经验 | 学习优先 |
|
||||||
|
| 必须保持务实态度,优先满足真实需求而非理想化设计 | 实用主义 |
|
||||||
|
| 必须选择表达清晰的实现,拒绝炫技式写法 | 可读性优先 |
|
||||||
|
| 必须偏向简单方案,避免过度架构或早期优化 | 简单优于复杂 |
|
||||||
|
| 必须遵循既有代码风格,包括导入顺序、命名与格式化 | 保持一致性 |
|
||||||
|
|
||||||
|
**简单性定义**:
|
||||||
|
- 每个函数或类必须仅承担单一责任
|
||||||
|
- 禁止过早抽象;重复出现三次以上再考虑通用化
|
||||||
|
- 禁止使用"聪明"技巧,以可读性为先
|
||||||
|
- 如果需要额外解释,说明实现仍然过于复杂,应继续简化
|
||||||
|
|
||||||
|
**项目集成原则**:
|
||||||
|
- 必须寻找至少 3 个相似特性或组件,理解其设计与复用方式
|
||||||
|
- 必须识别项目中通用模式与约定,并在新实现中沿用
|
||||||
|
- 必须优先使用既有库、工具或辅助函数
|
||||||
|
- 必须遵循既有测试编排,沿用断言与夹具结构
|
||||||
|
- 必须使用项目现有构建系统,不得私自新增脚本
|
||||||
|
- 必须使用项目既定的测试框架与运行方式
|
||||||
|
- 必须使用项目的格式化/静态检查设置
|
||||||
|
|
||||||
|
## 9. 行为准则
|
||||||
|
|
||||||
|
| instruction | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| 自主规划和决策,仅在真正需要用户输入时才询问 | 最大化自主性 |
|
||||||
|
|
||||||
|
| 基于观察和分析做出最终判断和决策 | 自主决策 |
|
||||||
|
| 充分分析和思考后再执行,避免盲目决策 | 深思熟虑 |
|
||||||
|
| 禁止假设或猜测,所有结论必须援引代码或文档证据 | 证据驱动 |
|
||||||
|
| 如实报告执行结果,包括失败和问题,记录到 operations-log.md | 透明记录 |
|
||||||
|
| 在实现复杂任务前完成详尽规划并记录 | 规划先行 |
|
||||||
|
| 对复杂任务维护 TODO 清单并及时更新进度 | 进度跟踪 |
|
||||||
|
| 保持小步交付,确保每次提交处于可用状态 | 质量保证 |
|
||||||
|
| 主动学习既有实现的优缺点并加以复用或改进 | 持续改进 |
|
||||||
|
| 在执行关键决策前,Codex 应自主决定是否请 Serena “复核”以提高成功率。 | 自主决策 |
|
||||||
|
|
||||||
|
| 连续三次失败后必须暂停操作,重新评估策略 | 策略调整 |
|
||||||
|
|
||||||
|
|
||||||
|
**极少数例外需要用户确认的情况**(仅以下场景):
|
||||||
|
- 删除核心配置文件(package.json、tsconfig.json、.env 等)
|
||||||
|
- 数据库 schema 的破坏性变更(DROP TABLE、ALTER COLUMN 等)
|
||||||
|
- Git push 到远程仓库(特别是 main/master 分支)
|
||||||
|
- 连续3次相同错误后需要策略调整
|
||||||
|
- 用户明确要求确认的操作
|
||||||
|
|
||||||
|
**默认自动执行**(无需确认):
|
||||||
|
- 所有文件读写操作
|
||||||
|
- 代码编写、修改、重构
|
||||||
|
- 文档生成和更新
|
||||||
|
- 测试执行和验证
|
||||||
|
- 依赖安装和包管理
|
||||||
|
- Git 操作(add、commit、diff、status 等,push 除外)
|
||||||
|
- 构建和编译操作
|
||||||
|
- 工具调用(code-index、exa、grep、find 等)
|
||||||
|
- 按计划执行的所有步骤
|
||||||
|
- 错误修复和重试(最多3次)
|
||||||
|
|
||||||
|
**判断原则**:
|
||||||
|
- 如果不在"极少数例外"清单中 → 自动执行
|
||||||
|
- 如有疑问 → 自动执行(而非询问)
|
||||||
|
- 宁可执行后修复,也不要频繁打断工作流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**协作原则总结**:
|
||||||
|
- 我规划,我决策
|
||||||
|
- 我观察,我判断
|
||||||
|
- 我执行,我验证
|
||||||
|
- 遇疑问,评估后决策或询问用户
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
FROM golang:1.24-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY docker/dist-server/ ./
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dist-server .
|
|
||||||
|
|
||||||
FROM scratch
|
|
||||||
|
|
||||||
WORKDIR /www
|
|
||||||
|
|
||||||
COPY --from=builder /out/dist-server /dist-server
|
|
||||||
COPY dist/ /www/
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
ENTRYPOINT ["/dist-server"]
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
@@ -9,29 +9,24 @@
|
|||||||
"@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": {
|
||||||
@@ -83,14 +78,6 @@
|
|||||||
|
|
||||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||||
|
|
||||||
"@intlify/core-base": ["@intlify/core-base@11.4.6", "", { "dependencies": { "@intlify/devtools-types": "11.4.6", "@intlify/message-compiler": "11.4.6", "@intlify/shared": "11.4.6" } }, "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw=="],
|
|
||||||
|
|
||||||
"@intlify/devtools-types": ["@intlify/devtools-types@11.4.6", "", { "dependencies": { "@intlify/core-base": "11.4.6", "@intlify/shared": "11.4.6" } }, "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA=="],
|
|
||||||
|
|
||||||
"@intlify/message-compiler": ["@intlify/message-compiler@11.4.6", "", { "dependencies": { "@intlify/shared": "11.4.6", "source-map-js": "^1.0.2" } }, "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q=="],
|
|
||||||
|
|
||||||
"@intlify/shared": ["@intlify/shared@11.4.6", "", {}, "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA=="],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -103,10 +90,6 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||||
|
|
||||||
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
|
|
||||||
|
|
||||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
|
||||||
|
|
||||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.114.0", "", {}, "sha512-mVGQvr/uFJGQ3hsvgQ1sJfh79t5owyZZZtw+VaH+WhtvsmtgjT6imznB9sz2Q67Q0/4obM9mOOtQscU4aJteSg=="],
|
"@oxc-project/runtime": ["@oxc-project/runtime@0.114.0", "", {}, "sha512-mVGQvr/uFJGQ3hsvgQ1sJfh79t5owyZZZtw+VaH+WhtvsmtgjT6imznB9sz2Q67Q0/4obM9mOOtQscU4aJteSg=="],
|
||||||
|
|
||||||
"@oxc-project/types": ["@oxc-project/types@0.114.0", "", {}, "sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA=="],
|
"@oxc-project/types": ["@oxc-project/types@0.114.0", "", {}, "sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA=="],
|
||||||
@@ -225,8 +208,6 @@
|
|||||||
|
|
||||||
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
|
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
|
||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
|
|
||||||
|
|
||||||
"ag-charts-community": ["ag-charts-community@13.1.0", "", { "dependencies": { "ag-charts-core": "13.1.0", "ag-charts-locale": "13.1.0", "ag-charts-types": "13.1.0" } }, "sha512-w+uFTjxlAoTq1+8tgUORtB/zr9jm38ibXzbbWnkBP9Dep9yahi5a1jZL7yExAX35uq3g9QtjTh0Oj/QPDBQ9Ew=="],
|
"ag-charts-community": ["ag-charts-community@13.1.0", "", { "dependencies": { "ag-charts-core": "13.1.0", "ag-charts-locale": "13.1.0", "ag-charts-types": "13.1.0" } }, "sha512-w+uFTjxlAoTq1+8tgUORtB/zr9jm38ibXzbbWnkBP9Dep9yahi5a1jZL7yExAX35uq3g9QtjTh0Oj/QPDBQ9Ew=="],
|
||||||
|
|
||||||
"ag-charts-core": ["ag-charts-core@13.1.0", "", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-mLHJZ8oU5CPeLRURescdISCtMsiiA/m4d1iBr6aQBEgiTVogRMGpFpsYNtQiYtoW2sRh+62I9sN8fhC3JQjX/g=="],
|
"ag-charts-core": ["ag-charts-core@13.1.0", "", { "dependencies": { "ag-charts-types": "13.1.0" } }, "sha512-mLHJZ8oU5CPeLRURescdISCtMsiiA/m4d1iBr6aQBEgiTVogRMGpFpsYNtQiYtoW2sRh+62I9sN8fhC3JQjX/g=="],
|
||||||
@@ -307,8 +288,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -325,8 +304,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -445,7 +422,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@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
@@ -461,8 +438,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -531,8 +506,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -567,8 +540,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -581,8 +552,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module dist-server
|
|
||||||
|
|
||||||
go 1.24
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultPort = "80"
|
|
||||||
webRoot = "/www"
|
|
||||||
indexName = "index.html"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
port := strings.TrimSpace(os.Getenv("PORT"))
|
|
||||||
if port == "" {
|
|
||||||
port = defaultPort
|
|
||||||
}
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: ":" + port,
|
|
||||||
Handler: http.HandlerFunc(serve),
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("dist server listening on :%s", port)
|
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func serve(w http.ResponseWriter, r *http.Request) {
|
|
||||||
requestPath := strings.TrimPrefix(filepath.Clean("/"+r.URL.Path), "/")
|
|
||||||
if requestPath == "." || requestPath == "" {
|
|
||||||
serveIndex(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fullPath := filepath.Join(webRoot, filepath.FromSlash(requestPath))
|
|
||||||
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
|
||||||
applyAssetCacheHeaders(w, requestPath)
|
|
||||||
http.ServeFile(w, r, fullPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serveIndex(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveIndex(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Pragma", "no-cache")
|
|
||||||
w.Header().Set("Expires", "0")
|
|
||||||
http.ServeFile(w, r, filepath.Join(webRoot, indexName))
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyAssetCacheHeaders(w http.ResponseWriter, requestPath string) {
|
|
||||||
if strings.EqualFold(requestPath, indexName) {
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Pragma", "no-cache")
|
|
||||||
w.Header().Set("Expires", "0")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(requestPath, "static/") {
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
[33md3695c8[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mmain[m[33m, [m[1;31morigin/main[m[33m, [m[1;31morigin/HEAD[m[33m)[m HEAD@{0}: reset: moving to HEAD^
|
||||||
|
[33m9c11604[m HEAD@{1}: checkout: moving from 9c11604ba744feb874018575a6a679700971e548 to main
|
||||||
|
[33m9c11604[m HEAD@{2}: checkout: moving from main to 9c11604ba744feb874018575a6a679700971e548
|
||||||
|
[33m9c11604[m HEAD@{3}: reset: moving to 9c11604ba744feb874018575a6a679700971e548
|
||||||
|
[33m9c11604[m HEAD@{4}: commit: 首页
|
||||||
|
[33md3695c8[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mmain[m[33m, [m[1;31morigin/main[m[33m, [m[1;31morigin/HEAD[m[33m)[m HEAD@{5}: pull -f: Fast-forward
|
||||||
|
[33m1c600e6[m HEAD@{6}: commit: fix
|
||||||
|
[33mf4f6e5c[m HEAD@{7}: commit: final
|
||||||
|
[33m398fca9[m HEAD@{8}: pull: Fast-forward
|
||||||
|
[33mf4c768d[m HEAD@{9}: commit: fix
|
||||||
|
[33mcd10760[m HEAD@{10}: commit: 1
|
||||||
|
[33m0f71fff[m HEAD@{11}: commit: fix
|
||||||
|
[33m3d26b0b[m HEAD@{12}: commit: fix,去掉大部分indexdb的逻辑
|
||||||
|
[33m9a045cf[m HEAD@{13}: commit: 大改,使用pinia传值,indexdb做持久化
|
||||||
|
[33m3ad7bae[m HEAD@{14}: commit: 调整存储的逻辑
|
||||||
|
[33mbbc8777[m HEAD@{15}: commit: fix
|
||||||
|
[33m5614e31[m HEAD@{16}: commit: 修复bug
|
||||||
|
[33m5bb6609[m HEAD@{17}: commit: fix bug
|
||||||
|
[33m1910f15[m HEAD@{18}: pull: Fast-forward
|
||||||
|
[33m2a2c0fe[m HEAD@{19}: commit: 1
|
||||||
|
[33mf79e8e0[m HEAD@{20}: commit: merge
|
||||||
|
[33mab310b4[m HEAD@{21}: commit: 1
|
||||||
|
[33md1dda7f[m HEAD@{22}: pull: Fast-forward
|
||||||
|
[33m8a15587[m HEAD@{23}: reset: moving to HEAD
|
||||||
|
[33m8a15587[m HEAD@{24}: commit: 备份
|
||||||
|
[33mfc26a87[m HEAD@{25}: commit: 系数字段修改
|
||||||
|
[33m21d3f03[m HEAD@{26}: pull: Fast-forward
|
||||||
|
[33m303f54b[m HEAD@{27}: commit: if
|
||||||
|
[33m043e1fc[m HEAD@{28}: commit: fix
|
||||||
|
[33mad4e9cd[m HEAD@{29}: commit: fix someone
|
||||||
|
[33mc482faa[m HEAD@{30}: commit: fix
|
||||||
|
[33m626513b[m HEAD@{31}: commit: fix
|
||||||
|
[33md8f8b62[m HEAD@{32}: commit: fix
|
||||||
|
[33m75f293f[m HEAD@{33}: commit: '20260305修复bug'
|
||||||
|
[33m53c1b2c[m HEAD@{34}: commit: 1
|
||||||
|
[33m75d5066[m HEAD@{35}: commit: 1
|
||||||
|
[33me4a2b53[m HEAD@{36}: commit: 1
|
||||||
|
[33m42fd6e4[m HEAD@{37}: commit: 重构
|
||||||
|
[33m33913c2[m HEAD@{38}: commit: 1
|
||||||
|
[33m62546bc[m HEAD@{39}: commit: 1
|
||||||
|
[33ma10359f[m HEAD@{40}: commit: 优化
|
||||||
|
[33m3950057[m HEAD@{41}: commit: fix
|
||||||
|
[33m757de9a[m HEAD@{42}: commit: 1
|
||||||
|
[33mea6a244[m HEAD@{43}: commit: fix
|
||||||
|
[33m13b03e0[m HEAD@{44}: commit: 完成大部分
|
||||||
|
[33me97707a[m HEAD@{45}: commit: fix
|
||||||
|
[33m9849801[m HEAD@{46}: commit: fix all
|
||||||
|
[33mbadf131[m HEAD@{47}: commit: fix
|
||||||
|
[33m57a2029[m HEAD@{48}: commit: fix 拖动流畅度
|
||||||
|
[33m37f4a99[m HEAD@{49}: commit: fix bug
|
||||||
|
[33m1609f19[m HEAD@{50}: commit: fix more
|
||||||
|
[33m5734cfa[m HEAD@{51}: commit: fix more
|
||||||
|
[33mf121aa2[m HEAD@{52}: commit: fix all
|
||||||
|
[33m6ba08da[m HEAD@{53}: clone: from https://git.zwgczx.com/zwgczx/JGJS2026.git
|
||||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -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>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bunx --bun vite",
|
"dev": "bunx --bun vite",
|
||||||
"build": " bunx --bun vite build",
|
"build": "bunx vue-tsc -b && bunx --bun vite build",
|
||||||
"preview": "bunx --bun vite preview",
|
"preview": "bunx --bun vite preview",
|
||||||
"type-check": "bunx vue-tsc --noEmit"
|
"type-check": "bunx vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -14,29 +14,24 @@
|
|||||||
"@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": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1 +0,0 @@
|
|||||||
iVBORw0KGgoAAAANSUhEUgAAAfQAAAH0CAYAAADl8HLGAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4SU
|
|
||||||
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,45 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$ImageName = "jgjs2026-dist",
|
|
||||||
[string]$Tag = "latest"
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
$distPath = Join-Path $projectRoot "dist"
|
|
||||||
$dockerfilePath = Join-Path $projectRoot "Dockerfile.dist"
|
|
||||||
$serverSourcePath = Join-Path $projectRoot "docker\\dist-server"
|
|
||||||
$buildContext = Join-Path ([System.IO.Path]::GetTempPath()) ("jgjs2026-dist-docker-" + [System.Guid]::NewGuid().ToString("N"))
|
|
||||||
|
|
||||||
if (-not (Test-Path $distPath)) {
|
|
||||||
throw "dist directory not found. Run npm run build first."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $dockerfilePath)) {
|
|
||||||
throw "Dockerfile.dist not found."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $serverSourcePath)) {
|
|
||||||
throw "docker/dist-server not found."
|
|
||||||
}
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $buildContext | Out-Null
|
|
||||||
New-Item -ItemType Directory -Path (Join-Path $buildContext "docker") | Out-Null
|
|
||||||
|
|
||||||
try {
|
|
||||||
Copy-Item $dockerfilePath (Join-Path $buildContext "Dockerfile.dist")
|
|
||||||
Copy-Item $serverSourcePath (Join-Path $buildContext "docker\\dist-server") -Recurse
|
|
||||||
Copy-Item $distPath (Join-Path $buildContext "dist") -Recurse
|
|
||||||
|
|
||||||
Write-Host "Building Docker image ${ImageName}:${Tag} from minimal dist context..."
|
|
||||||
docker build -f (Join-Path $buildContext "Dockerfile.dist") -t "${ImageName}:${Tag}" $buildContext
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
if (Test-Path $buildContext) {
|
|
||||||
Remove-Item $buildContext -Recurse -Force
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Done."
|
|
||||||
Write-Host "Run with:"
|
|
||||||
Write-Host "docker run --rm -p 8080:80 ${ImageName}:${Tag}"
|
|
||||||
@@ -1,323 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useTabStore } from '@/pinia/tab'
|
|
||||||
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
|
|
||||||
import Tab from '@/layout/tab.vue'
|
import Tab from '@/layout/tab.vue'
|
||||||
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
|
||||||
import localforage from 'localforage'
|
|
||||||
import {
|
|
||||||
buildProjectUrl,
|
|
||||||
DEFAULT_PROJECT_ID,
|
|
||||||
ensureProjectIdInUrl,
|
|
||||||
FORCE_HOME_QUERY_KEY,
|
|
||||||
getProjectDbName,
|
|
||||||
NEW_PROJECT_QUERY_KEY,
|
|
||||||
PROJECT_TAB_ID,
|
|
||||||
QUICK_PROJECT_ID
|
|
||||||
} from '@/lib/workspace'
|
|
||||||
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
|
|
||||||
import { listenProjectDeleted, listenResetAll } from '@/lib/projectEvents'
|
|
||||||
import { listProjects, type ProjectMeta } from '@/lib/projectRegistry'
|
|
||||||
import { closePage } from './lib/utils'
|
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
|
||||||
const { t } = useI18n()
|
|
||||||
const isReady = ref(false)
|
|
||||||
const lockConflict = ref(false)
|
|
||||||
const currentProjectId = ref('')
|
|
||||||
const currentProjectName = ref('')
|
|
||||||
const conflictProjectList = ref<ProjectMeta[]>([])
|
|
||||||
const openedProjectIds = ref<string[]>([])
|
|
||||||
const closeCountdown = ref(10)
|
|
||||||
const isNewProjectRequest = ref(false)
|
|
||||||
const isForceHomeRequest = ref(false)
|
|
||||||
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
let releaseLock: (() => void) | null = null
|
|
||||||
let stopProjectDeletedListener: (() => void) | null = null
|
|
||||||
let stopResetAllListener: (() => void) | null = null
|
|
||||||
let isHandlingDeletedProject = false
|
|
||||||
let isHandlingGlobalReset = false
|
|
||||||
|
|
||||||
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
|
|
||||||
|
|
||||||
const handleImportComplete = () => {
|
|
||||||
tabStore.hasCompletedSetup = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const initCurrentProjectLock = () => {
|
|
||||||
if (releaseLock) return
|
|
||||||
const projectId = String(currentProjectId.value || '').trim()
|
|
||||||
if (!projectId || projectId === QUICK_PROJECT_ID) {
|
|
||||||
lockConflict.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const lock = initProjectSessionLock({
|
|
||||||
projectId,
|
|
||||||
onConflict: (next) => {
|
|
||||||
lockConflict.value = next
|
|
||||||
if (next) {
|
|
||||||
refreshConflictProjectList()
|
|
||||||
startCloseCountdown()
|
|
||||||
} else {
|
|
||||||
clearCloseCountdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
releaseLock = lock.release
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshConflictProjectList = () => {
|
|
||||||
void (async () => {
|
|
||||||
const projects = listProjects()
|
|
||||||
const enriched = await Promise.all(
|
|
||||||
projects.map(async (project) => {
|
|
||||||
try {
|
|
||||||
const kvStoreInstance = localforage.createInstance({
|
|
||||||
name: getProjectDbName(project.id),
|
|
||||||
storeName: 'pinia-kv'
|
|
||||||
})
|
|
||||||
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
|
|
||||||
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
|
|
||||||
const projectInfo = entries?.['xm-base-info-v1']
|
|
||||||
const projectName =
|
|
||||||
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
|
|
||||||
? projectInfo.projectName.trim()
|
|
||||||
: ''
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
name: projectName || project.name
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return project
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
conflictProjectList.value = enriched
|
|
||||||
const hit = enriched.find(item => item.id === currentProjectId.value)
|
|
||||||
currentProjectName.value = hit?.name || currentProjectId.value
|
|
||||||
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(enriched.map(item => item.id)))
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearCloseCountdown = () => {
|
|
||||||
if (!closeCountdownTimer) return
|
|
||||||
clearInterval(closeCountdownTimer)
|
|
||||||
closeCountdownTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const startCloseCountdown = () => {
|
|
||||||
clearCloseCountdown()
|
|
||||||
closeCountdown.value = 10
|
|
||||||
closeCountdownTimer = setInterval(() => {
|
|
||||||
closeCountdown.value -= 1
|
|
||||||
if (closeCountdown.value <= 0) {
|
|
||||||
clearCloseCountdown()
|
|
||||||
try {
|
|
||||||
closePage()
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
// 部分浏览器会阻止关闭,保留阻断页。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isConflictProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
|
|
||||||
|
|
||||||
const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean }) => {
|
|
||||||
if (isConflictProjectOpen(projectId)) return
|
|
||||||
const href = buildProjectUrl(projectId, options)
|
|
||||||
window.open(href, '_blank', 'noopener')
|
|
||||||
}
|
|
||||||
|
|
||||||
const createProjectAndOpen = () => {
|
|
||||||
|
|
||||||
refreshConflictProjectList()
|
|
||||||
openProjectInNewTab(DEFAULT_PROJECT_ID, { newProject: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncRouteRequestFlags = () => {
|
|
||||||
try {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
isNewProjectRequest.value = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
|
||||||
isForceHomeRequest.value = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1'
|
|
||||||
} catch {
|
|
||||||
isNewProjectRequest.value = false
|
|
||||||
isForceHomeRequest.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatProjectEditedTime = (value: string) => {
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return '-'
|
|
||||||
const pad = (num: number) => String(num).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReleaseProjectLock = () => {
|
|
||||||
if (!releaseLock) return
|
|
||||||
releaseLock()
|
|
||||||
releaseLock = null
|
|
||||||
lockConflict.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleProjectDeleted = (deletedProjectId: string) => {
|
|
||||||
if (String(deletedProjectId || '').trim() !== currentProjectId.value) return
|
|
||||||
if (isHandlingDeletedProject) return
|
|
||||||
isHandlingDeletedProject = true
|
|
||||||
handleReleaseProjectLock()
|
|
||||||
tabStore.resetTabs()
|
|
||||||
tabStore.hasCompletedSetup = false
|
|
||||||
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
|
|
||||||
try {
|
|
||||||
window.close()
|
|
||||||
} catch {
|
|
||||||
// ignore and fallback to redirect
|
|
||||||
}
|
|
||||||
window.setTimeout(() => {
|
|
||||||
window.location.href = href
|
|
||||||
}, 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResetAll = () => {
|
|
||||||
if (isHandlingGlobalReset) return
|
|
||||||
isHandlingGlobalReset = true
|
|
||||||
handleReleaseProjectLock()
|
|
||||||
tabStore.resetTabs()
|
|
||||||
tabStore.hasCompletedSetup = false
|
|
||||||
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
|
|
||||||
try {
|
|
||||||
window.close()
|
|
||||||
} catch {
|
|
||||||
// ignore and fallback to redirect
|
|
||||||
}
|
|
||||||
window.setTimeout(() => {
|
|
||||||
window.location.href = href
|
|
||||||
}, 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
|
|
||||||
currentProjectId.value = ensureProjectIdInUrl()
|
|
||||||
syncRouteRequestFlags()
|
|
||||||
refreshConflictProjectList()
|
|
||||||
if (!isForceHomeRequest.value && currentProjectId.value !== QUICK_PROJECT_ID && currentProjectId.value !== DEFAULT_PROJECT_ID) {
|
|
||||||
initCurrentProjectLock()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('home-import-selected', handleImportComplete)
|
|
||||||
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
|
||||||
stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted)
|
|
||||||
stopResetAllListener = listenResetAll(handleResetAll)
|
|
||||||
waitForHydration('tabs').then(() => {
|
|
||||||
if (isForceHomeRequest.value) {
|
|
||||||
tabStore.resetTabs()
|
|
||||||
tabStore.hasCompletedSetup = false
|
|
||||||
}
|
|
||||||
if (!tabStore.hasCompletedSetup && !isNewProjectRequest.value && !isForceHomeRequest.value) {
|
|
||||||
const hasProjects = listProjects().length > 0
|
|
||||||
if (hasProjects && currentProjectId.value !== DEFAULT_PROJECT_ID) {
|
|
||||||
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
|
||||||
tabStore.hasCompletedSetup = true
|
|
||||||
} else {
|
|
||||||
tabStore.enterWorkspace({
|
|
||||||
id: PROJECT_TAB_ID,
|
|
||||||
title: t('home.cards.projectBudget'),
|
|
||||||
componentName: 'ProjectCalcView'
|
|
||||||
})
|
|
||||||
tabStore.hasCompletedSetup = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tabStore.hasCompletedSetup && Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
|
||||||
const activeId = typeof tabStore.activeTabId === 'string' ? tabStore.activeTabId : ''
|
|
||||||
const hasActive = Boolean(activeId) && tabStore.tabs.some(tab => tab.id === activeId)
|
|
||||||
if (!hasActive) {
|
|
||||||
tabStore.activeTabId = tabStore.tabs[0]?.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!releaseLock) {
|
|
||||||
lockConflict.value = false
|
|
||||||
clearCloseCountdown()
|
|
||||||
}
|
|
||||||
isReady.value = true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearCloseCountdown()
|
|
||||||
window.removeEventListener('home-import-selected', handleImportComplete)
|
|
||||||
window.removeEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
|
||||||
if (stopProjectDeletedListener) {
|
|
||||||
stopProjectDeletedListener()
|
|
||||||
stopProjectDeletedListener = null
|
|
||||||
}
|
|
||||||
if (stopResetAllListener) {
|
|
||||||
stopResetAllListener()
|
|
||||||
stopResetAllListener = null
|
|
||||||
}
|
|
||||||
if (releaseLock) {
|
|
||||||
releaseLock()
|
|
||||||
releaseLock = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="isReady">
|
<Tab />
|
||||||
<div
|
|
||||||
v-if="lockConflict"
|
|
||||||
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
|
|
||||||
>
|
|
||||||
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
|
|
||||||
<h2 class="text-lg font-semibold text-slate-900">{{ t('app.projectConflict.title') }}</h2>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
|
||||||
{{ t('app.projectConflict.desc', { name: currentProjectName }) }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-xs text-slate-500">
|
|
||||||
{{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
|
|
||||||
<button
|
|
||||||
v-for="project in conflictProjectList"
|
|
||||||
:key="project.id"
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between rounded-md border border-transparent bg-white px-3 py-2 text-left text-sm transition"
|
|
||||||
:class="isConflictProjectOpen(project.id) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-200 hover:bg-slate-100'"
|
|
||||||
:disabled="isConflictProjectOpen(project.id)"
|
|
||||||
@click="openProjectInNewTab(project.id)"
|
|
||||||
>
|
|
||||||
<span class="font-medium text-slate-700">
|
|
||||||
{{ project.name }}
|
|
||||||
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">{{ t('app.projectConflict.opened') }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-slate-500">{{ t('app.projectConflict.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100"
|
|
||||||
@click="createProjectAndOpen"
|
|
||||||
>
|
|
||||||
{{ t('app.projectConflict.createAndOpen') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
|
|
||||||
:class="isConflictProjectOpen('default') ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-100'"
|
|
||||||
:disabled="isConflictProjectOpen('default')"
|
|
||||||
@click="openProjectInNewTab('default')"
|
|
||||||
>
|
|
||||||
{{ t('app.projectConflict.openDefault') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<HomeEntryView v-if="showHomeEntry" />
|
|
||||||
<Tab v-else />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
<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 } 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 ? 'editable-cell-line 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="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
|
<AgGridVue
|
||||||
|
:style="{ height: '100%' }"
|
||||||
|
: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,11 +1,9 @@
|
|||||||
<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 } 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'
|
||||||
@@ -46,7 +44,6 @@ 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'
|
||||||
@@ -63,7 +60,7 @@ const createDefaultRow = (): FeeRow => ({
|
|||||||
|
|
||||||
const createSubtotalRow = (): FeeRow => ({
|
const createSubtotalRow = (): FeeRow => ({
|
||||||
id: SUBTOTAL_ROW_ID,
|
id: SUBTOTAL_ROW_ID,
|
||||||
feeItem: t('htFeeDetail.subtotal'),
|
feeItem: '小计',
|
||||||
unit: '',
|
unit: '',
|
||||||
quantity: null,
|
quantity: null,
|
||||||
unitPrice: null,
|
unitPrice: null,
|
||||||
@@ -125,7 +122,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() || t('htFeeDetail.currentRow')
|
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
|
||||||
deleteConfirmOpen.value = true
|
deleteConfirmOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,19 +141,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 t('htFeeDetail.clickToInput')
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
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 t('htFeeDetail.clickToInput')
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
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 t('htFeeDetail.clickToInput')
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
return formatThousandsFlexible(params.value, 3)
|
return formatThousandsFlexible(params.value, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +183,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 = t('htFeeDetail.subtotal')
|
subtotalRow.feeItem = '小计'
|
||||||
subtotalRow.unit = ''
|
subtotalRow.unit = ''
|
||||||
subtotalRow.quantity = null
|
subtotalRow.quantity = null
|
||||||
subtotalRow.unitPrice = null
|
subtotalRow.unitPrice = null
|
||||||
@@ -231,8 +228,7 @@ 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)
|
||||||
@@ -262,7 +258,7 @@ const loadFromIndexedDB = async () => {
|
|||||||
|
|
||||||
const columnDefs: ColDef<FeeRow>[] = [
|
const columnDefs: ColDef<FeeRow>[] = [
|
||||||
{
|
{
|
||||||
headerName: t('htFeeDetail.columns.no'),
|
headerName: '序号',
|
||||||
colId: 'rowNo',
|
colId: 'rowNo',
|
||||||
minWidth: 68,
|
minWidth: 68,
|
||||||
maxWidth: 80,
|
maxWidth: 80,
|
||||||
@@ -275,14 +271,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: t('htFeeDetail.columns.feeItem'),
|
headerName: '费用项',
|
||||||
field: 'feeItem',
|
field: 'feeItem',
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 1.4,
|
flex: 1.4,
|
||||||
@@ -295,7 +291,7 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: t('htFeeDetail.columns.unit'),
|
headerName: '单位',
|
||||||
field: 'unit',
|
field: 'unit',
|
||||||
minWidth: 90,
|
minWidth: 90,
|
||||||
flex: 0.9,
|
flex: 0.9,
|
||||||
@@ -307,49 +303,45 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: t('htFeeDetail.columns.quantity'),
|
headerName: '数量',
|
||||||
field: 'quantity',
|
field: 'quantity',
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
cellClass: 'editable-cell-line',
|
cellClass: 'ag-right-aligned-cell 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: t('htFeeDetail.columns.unitPrice'),
|
headerName: '单价(元)',
|
||||||
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: 'editable-cell-line',
|
cellClass: 'ag-right-aligned-cell 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: t('htFeeDetail.columns.budgetFee'),
|
headerName: '预算费用(元)',
|
||||||
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: t('htFeeDetail.columns.remark'),
|
headerName: '说明',
|
||||||
field: 'remark',
|
field: 'remark',
|
||||||
minWidth: 170,
|
minWidth: 170,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
@@ -359,13 +351,13 @@ const columnDefs: ColDef<FeeRow>[] = [
|
|||||||
autoHeight: true,
|
autoHeight: true,
|
||||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||||
valueFormatter: formatEditableText,
|
valueFormatter: formatEditableText,
|
||||||
cellClass: ' remark-wrap-cell',
|
cellClass: 'editable-cell-line remark-wrap-cell',
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: t('htFeeDetail.columns.actions'),
|
headerName: '操作',
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
minWidth: 92,
|
minWidth: 92,
|
||||||
maxWidth: 110,
|
maxWidth: 110,
|
||||||
@@ -399,14 +391,13 @@ 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', t('common.delete'))]
|
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
|
||||||
|
|
||||||
const detailGridOptions: GridOptions<FeeRow> = {
|
const detailGridOptions: GridOptions<FeeRow> = {
|
||||||
...gridOptions,
|
...gridOptions,
|
||||||
@@ -417,52 +408,12 @@ const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
|
|||||||
gridApi.value = event.api
|
gridApi.value = event.api
|
||||||
}
|
}
|
||||||
|
|
||||||
let isBulkClipboardMutation = false
|
const handleCellValueChanged = () => {
|
||||||
|
|
||||||
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()
|
||||||
})
|
})
|
||||||
@@ -479,7 +430,6 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
gridApi.value?.stopEditing()
|
|
||||||
gridApi.value = null
|
gridApi.value = null
|
||||||
void saveToIndexedDB()
|
void saveToIndexedDB()
|
||||||
})
|
})
|
||||||
@@ -490,17 +440,16 @@ 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">{{ t('htFeeDetail.addRow') }}</Button>
|
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="agGridWrapClass">
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
<AgGridVue
|
<AgGridVue
|
||||||
:style="agGridStyle"
|
:style="{ height: '100%' }"
|
||||||
:rowData="detailRows"
|
:rowData="detailRows"
|
||||||
:columnDefs="gridColumnDefs"
|
:columnDefs="columnDefs"
|
||||||
: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"
|
||||||
@@ -509,16 +458,10 @@ 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>
|
||||||
@@ -528,16 +471,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">{{ t('htFeeDetail.dialog.deleteTitle') }}</AlertDialogTitle>
|
<AlertDialogTitle class="text-base font-semibold">确认删除行</AlertDialogTitle>
|
||||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||||
{{ t('htFeeDetail.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
|
将删除“{{ 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">{{ t('common.cancel') }}</Button>
|
<Button variant="outline">取消</Button>
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction as-child>
|
<AlertDialogAction as-child>
|
||||||
<Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
|
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
<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 } 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'
|
||||||
@@ -78,11 +75,11 @@ const props = defineProps<{
|
|||||||
storageKey: string
|
storageKey: string
|
||||||
contractId?: string
|
contractId?: string
|
||||||
contractName?: string
|
contractName?: string
|
||||||
fixedNames?: any[]
|
fixedNames?: string[]
|
||||||
}>()
|
}>()
|
||||||
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)}`
|
||||||
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
||||||
id: createRowId(),
|
id: createRowId(),
|
||||||
@@ -92,19 +89,33 @@ const createDefaultRow = (name = ''): FeeMethodRow => ({
|
|||||||
quantityUnitPriceFee: null
|
quantityUnitPriceFee: null
|
||||||
})
|
})
|
||||||
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 => {
|
||||||
const total = sumNullableNumbers(rows.map(row => pick(row)))
|
let hasValid = false
|
||||||
return total == null ? null : roundTo(total, 3)
|
let total = 0
|
||||||
|
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 total = sumNullableNumbers(values)
|
const hasValid = values.some(value => typeof value === 'number' && Number.isFinite(value))
|
||||||
if (total == null) return null
|
if (!hasValid) return null
|
||||||
return roundTo(total, 3)
|
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee))
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
@@ -130,12 +141,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 : roundTo(serviceBase, 3)
|
return serviceBase == null ? null : round3(serviceBase)
|
||||||
}
|
}
|
||||||
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 roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalFeeTotal), 3)
|
return round3(toFinite(serviceBase) + toFinite(additionalFeeTotal))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('loadContractServiceFeeBase failed:', error)
|
console.error('loadContractServiceFeeBase failed:', error)
|
||||||
return null
|
return null
|
||||||
@@ -152,21 +163,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 = toFiniteNumber(row?.serviceBudget)
|
const rowBudget = toFiniteUnknown(row?.serviceBudget)
|
||||||
if (rowBudget != null) {
|
if (rowBudget != null) {
|
||||||
total += rowBudget
|
total += rowBudget
|
||||||
hasValid = true
|
hasValid = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
|
||||||
const personnel = toFiniteNumber(row?.personnelCount)
|
const personnel = toFiniteUnknown(row?.personnelCount)
|
||||||
const workday = toFiniteNumber(row?.workdayCount)
|
const workday = toFiniteUnknown(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 ? roundTo(total, 3) : null
|
return hasValid ? round3(total) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
||||||
@@ -177,19 +188,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 = toFiniteNumber(row?.budgetFee)
|
const budget = toFiniteUnknown(row?.budgetFee)
|
||||||
if (budget != null) {
|
if (budget != null) {
|
||||||
total += budget
|
total += budget
|
||||||
hasValid = true
|
hasValid = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const quantity = toFiniteNumber(row?.quantity)
|
const quantity = toFiniteUnknown(row?.quantity)
|
||||||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
const unitPrice = toFiniteUnknown(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 ? roundTo(total, 3) : null
|
return hasValid ? round3(total) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
||||||
@@ -204,13 +215,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 = toFiniteNumber(rateData?.budgetFee)
|
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
|
||||||
const rateValue = toFiniteNumber(rateData?.rate)
|
const rateValue = toFiniteUnknown(rateData?.rate)
|
||||||
const rateFee =
|
const rateFee =
|
||||||
contractBase != null && rateValue != null
|
contractBase != null && rateValue != null
|
||||||
? roundTo(contractBase * rateValue / 100, 2)
|
? round3(contractBase * rateValue)
|
||||||
: storedRateFee != null
|
: storedRateFee != null
|
||||||
? roundTo(storedRateFee, 2)
|
? round3(storedRateFee)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
||||||
@@ -229,18 +240,10 @@ const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMet
|
|||||||
|
|
||||||
const fixedNames = computed(() =>
|
const fixedNames = computed(() =>
|
||||||
Array.isArray(props.fixedNames)
|
Array.isArray(props.fixedNames)
|
||||||
? props.fixedNames.map(item => ({name:item.name,id:item.id}))
|
? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean)
|
||||||
: []
|
: []
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -258,7 +261,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: t('htFeeGrid.subtotal'),
|
name: '小计',
|
||||||
rateFee,
|
rateFee,
|
||||||
hourlyFee,
|
hourlyFee,
|
||||||
quantityUnitPriceFee
|
quantityUnitPriceFee
|
||||||
@@ -275,7 +278,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() || t('htFeeGrid.currentRow')
|
pendingClearRowName.value = String(name || '').trim() || '当前行'
|
||||||
clearConfirmOpen.value = true
|
clearConfirmOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,18 +322,18 @@ const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
|
|||||||
typeof row.unitPrice === 'number' &&
|
typeof row.unitPrice === 'number' &&
|
||||||
Number.isFinite(row.unitPrice)
|
Number.isFinite(row.unitPrice)
|
||||||
) {
|
) {
|
||||||
return roundTo(row.quantity * row.unitPrice, 2)
|
return Number((row.quantity * row.unitPrice).toFixed(2))
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
|
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
|
||||||
|
|
||||||
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
|
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
|
||||||
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
|
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
|
||||||
)
|
)
|
||||||
const rows = sourceRows.map(item => {
|
const rows = sourceRows.map(item => {
|
||||||
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
|
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
||||||
name:
|
name:
|
||||||
@@ -346,16 +349,13 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
|
|||||||
: toLegacyQuantityUnitPriceFee(row)
|
: toLegacyQuantityUnitPriceFee(row)
|
||||||
} as FeeMethodRow
|
} as 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((name, index) => {
|
||||||
const rowId = String(item?.id || `fee-method-fixed-${index}`)
|
const fromDb = byName.get(name)
|
||||||
const fromDb = byId.get(rowId) || byName.get(item.name)
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: fromDb?.id || `fee-method-fixed-${index}`,
|
||||||
name: item.name,
|
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
|
||||||
@@ -384,6 +384,7 @@ const saveToIndexedDB = async (force = false) => {
|
|||||||
const loadFromIndexedDB = async () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
|
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
|
||||||
|
|
||||||
const mergedRows = mergeWithStoredRows(data?.detailRows)
|
const mergedRows = mergeWithStoredRows(data?.detailRows)
|
||||||
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||||
await saveToIndexedDB(true)
|
await saveToIndexedDB(true)
|
||||||
@@ -424,7 +425,7 @@ const editRow = (id: string) => {
|
|||||||
if (!row) return
|
if (!row) return
|
||||||
tabStore.openTab({
|
tabStore.openTab({
|
||||||
id: `ht-fee-edit-${props.storageKey}-${id}`,
|
id: `ht-fee-edit-${props.storageKey}-${id}`,
|
||||||
title: t('htFeeGrid.editTabTitle', { name: row.name || t('htFeeGrid.unnamed') }),
|
title: `费用编辑-${row.name || '未命名'}`,
|
||||||
componentName: 'HtFeeMethodTypeLineView',
|
componentName: 'HtFeeMethodTypeLineView',
|
||||||
props: {
|
props: {
|
||||||
sourceTitle: props.title,
|
sourceTitle: props.title,
|
||||||
@@ -446,16 +447,16 @@ const ActionCellRenderer = defineComponent({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
return () => {
|
return () => {
|
||||||
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
|
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
|
||||||
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
|
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const rowId = String(props.params.data?.id || '').trim()
|
const rowId = props.params.data?.id
|
||||||
if (!rowId) return
|
if (!rowId) return
|
||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
props.params.context?.onActionEdit?.(rowId)
|
props.params.context?.onActionEdit?.(rowId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
|
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
|
||||||
}
|
}
|
||||||
@@ -468,7 +469,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', t('htFeeGrid.edit'))
|
h('span', '编辑')
|
||||||
]),
|
]),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'zxfw-action-btn zxfw-action-btn--danger',
|
class: 'zxfw-action-btn zxfw-action-btn--danger',
|
||||||
@@ -477,7 +478,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', t('htFeeGrid.clear'))
|
h('span', '清空')
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
@@ -487,7 +488,7 @@ const ActionCellRenderer = defineComponent({
|
|||||||
|
|
||||||
const columnDefs: ColDef<FeeMethodRow>[] = [
|
const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||||
{
|
{
|
||||||
headerName: t('htFeeGrid.columns.name'),
|
headerName: '名字',
|
||||||
field: 'name',
|
field: 'name',
|
||||||
minWidth: 180,
|
minWidth: 180,
|
||||||
flex: 1.8,
|
flex: 1.8,
|
||||||
@@ -502,62 +503,60 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: t('htFeeGrid.columns.rateFee'),
|
headerName: '费率计取',
|
||||||
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: t('htFeeGrid.columns.hourlyFee'),
|
headerName: '工时法',
|
||||||
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: t('htFeeGrid.columns.quantityUnitPriceFee'),
|
headerName: '数量单价',
|
||||||
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: t('htFeeGrid.columns.subtotal'),
|
headerName: '小计',
|
||||||
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',
|
||||||
cellClassRules: {
|
cellClass: 'ag-right-aligned-cell',
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueGetter: params => getRowSubtotal(params.data),
|
valueGetter: params => getRowSubtotal(params.data),
|
||||||
valueFormatter: formatEditableNumber
|
valueFormatter: formatEditableNumber
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: t('htFeeGrid.columns.actions'),
|
headerName: '操作',
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
flex: 1.6,
|
flex: 1.6,
|
||||||
@@ -569,7 +568,6 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
|
|||||||
cellRenderer: ActionCellRenderer
|
cellRenderer: ActionCellRenderer
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
|
||||||
|
|
||||||
const detailGridOptions: GridOptions<FeeMethodRow> = {
|
const detailGridOptions: GridOptions<FeeMethodRow> = {
|
||||||
...gridOptions,
|
...gridOptions,
|
||||||
@@ -610,25 +608,9 @@ const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
|
|||||||
gridApi.value = event.api
|
gridApi.value = event.api
|
||||||
}
|
}
|
||||||
|
|
||||||
let isBulkClipboardMutation = false
|
|
||||||
|
|
||||||
const commitGridChanges = () => {
|
|
||||||
void saveToIndexedDB()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||||
if (isBulkClipboardMutation) return
|
|
||||||
if (isSummaryRow(event.data)) return
|
if (isSummaryRow(event.data)) return
|
||||||
commitGridChanges()
|
void saveToIndexedDB()
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkMutationStart = () => {
|
|
||||||
isBulkClipboardMutation = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkMutationEnd = () => {
|
|
||||||
isBulkClipboardMutation = false
|
|
||||||
commitGridChanges()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -640,19 +622,27 @@ onActivated(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const storageKeyRef = computed(() => props.storageKey)
|
const storageKeyRef = computed(() => props.storageKey)
|
||||||
const reserveAdditionalWorkStateSignature = computed(() => {
|
const reserveAdditionalWorkKeyVersion = computed(() => {
|
||||||
if (!isReserveStorageKey(props.storageKey)) return ''
|
if (!isReserveStorageKey(props.storageKey)) return 0
|
||||||
const contractId = String(props.contractId || '').trim()
|
const contractId = String(props.contractId || '').trim()
|
||||||
if (!contractId) return ''
|
if (!contractId) return 0
|
||||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||||
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
|
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
|
||||||
})
|
})
|
||||||
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
|
||||||
@@ -663,19 +653,19 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => {
|
() => {
|
||||||
const contractId = String(props.contractId || '').trim()
|
const contractId = String(props.contractId || '').trim()
|
||||||
if (!contractId) return ''
|
if (!contractId) return 0
|
||||||
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
|
return zxFwPricingStore.contractVersions[contractId] || 0
|
||||||
},
|
},
|
||||||
(nextSig, prevSig) => {
|
(nextVersion, prevVersion) => {
|
||||||
if (nextSig === prevSig) return
|
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||||
scheduleReloadFromStorage()
|
scheduleReloadFromStorage()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
reserveAdditionalWorkStateSignature,
|
reserveAdditionalWorkKeyVersion,
|
||||||
(nextSig, prevSig) => {
|
(nextVersion, prevVersion) => {
|
||||||
if (nextSig === prevSig) return
|
if (nextVersion === prevVersion) return
|
||||||
scheduleReloadFromStorage()
|
scheduleReloadFromStorage()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -689,20 +679,6 @@ 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
|
||||||
@@ -715,17 +691,16 @@ 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">{{ t('htFeeGrid.add') }}</Button>
|
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="agGridWrapClass">
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
<AgGridVue
|
<AgGridVue
|
||||||
:style="agGridStyle"
|
:style="{ height: '100%' }"
|
||||||
:rowData="displayRows"
|
:rowData="displayRows"
|
||||||
:columnDefs="gridColumnDefs"
|
:columnDefs="columnDefs"
|
||||||
: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"
|
||||||
@@ -738,10 +713,6 @@ 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>
|
||||||
@@ -751,16 +722,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">{{ t('htFeeGrid.dialog.clearTitle') }}</AlertDialogTitle>
|
<AlertDialogTitle class="text-base font-semibold">确认清空</AlertDialogTitle>
|
||||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||||
{{ t('htFeeGrid.dialog.clearDesc', { name: pendingClearRowName }) }}
|
将清空“{{ 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">{{ t('common.cancel') }}</Button>
|
<Button variant="outline">取消</Button>
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction as-child>
|
<AlertDialogAction as-child>
|
||||||
<Button variant="destructive" @click="confirmClearRow">{{ t('htFeeGrid.dialog.confirmClear') }}</Button>
|
<Button variant="destructive" @click="confirmClearRow">确认清空</Button>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
<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: undefined
|
title: '该服务不适用当前计价方法'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -17,7 +15,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 || t('methodUnavailable.defaultTitle') }}</p>
|
<p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title }}</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>
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { 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 } 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
|
||||||
@@ -34,11 +30,6 @@ 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<{
|
||||||
@@ -50,13 +41,10 @@ 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 ''
|
||||||
@@ -66,7 +54,7 @@ const formatReadonlyFactor = (value: unknown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatEditableFactor = (params: any) => {
|
const formatEditableFactor = (params: any) => {
|
||||||
if (params.value == null || params.value === '') return t('xmFactorGrid.clickToInput')
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
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))
|
||||||
@@ -165,32 +153,27 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] =>
|
|||||||
|
|
||||||
const columnDefs: ColDef<FactorRow>[] = [
|
const columnDefs: ColDef<FactorRow>[] = [
|
||||||
{
|
{
|
||||||
headerName: t('xmFactorGrid.columns.standardFactor'),
|
headerName: '标准系数',
|
||||||
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: t('xmFactorGrid.columns.budgetValue'),
|
headerName: '预算取值',
|
||||||
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 ? '' : 'editable-cell-line'
|
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell 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 === '')
|
||||||
@@ -208,7 +191,7 @@ const columnDefs: ColDef<FactorRow>[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: t('xmFactorGrid.columns.remark'),
|
headerName: '说明',
|
||||||
field: 'remark',
|
field: 'remark',
|
||||||
minWidth: 170,
|
minWidth: 170,
|
||||||
flex: 2.4,
|
flex: 2.4,
|
||||||
@@ -217,17 +200,16 @@ 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 || t('xmFactorGrid.clickToInput'),
|
valueFormatter: params => params.value || '点击输入',
|
||||||
cellClass: ' remark-wrap-cell',
|
cellClass: 'editable-cell-line 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: t('xmFactorGrid.columns.groupName'),
|
headerName: '专业编码以及工程专业名称',
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
flex: 2.2,
|
flex: 2.2,
|
||||||
cellRendererParams: {
|
cellRendererParams: {
|
||||||
@@ -246,79 +228,17 @@ const saveToIndexedDB = async () => {
|
|||||||
const payload: GridState = {
|
const payload: GridState = {
|
||||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
}
|
}
|
||||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
await kvStore.setItem(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 piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
|
const data = await kvStore.getItem<GridState>(storageKey)
|
||||||
if (piniaData?.detailRows && Array.isArray(piniaData.detailRows)) return piniaData
|
if (!data?.detailRows || !Array.isArray(data.detailRows)) return null
|
||||||
|
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 () => {
|
||||||
@@ -353,64 +273,13 @@ const loadFromIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let isBulkClipboardMutation = false
|
const handleCellValueChanged = () => {
|
||||||
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 flushGridPersist()
|
void saveToIndexedDB()
|
||||||
}, 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)
|
||||||
@@ -419,10 +288,6 @@ 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
|
||||||
@@ -447,9 +312,8 @@ watch(
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
gridApi.value?.stopEditing()
|
|
||||||
gridApi.value = null
|
gridApi.value = null
|
||||||
void flushGridPersist()
|
void saveToIndexedDB()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -461,21 +325,16 @@ onBeforeUnmount(() => {
|
|||||||
<div class="text-xs text-muted-foreground"></div>
|
<div class="text-xs text-muted-foreground"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="agGridWrapClass">
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
<AgGridVue
|
<AgGridVue
|
||||||
:style="agGridStyle"
|
:style="{ height: '100%' }"
|
||||||
:rowData="detailRows"
|
:rowData="detailRows"
|
||||||
:columnDefs="gridColumnDefs"
|
:columnDefs="columnDefs"
|
||||||
: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' } }"
|
||||||
@@ -488,11 +347,9 @@ 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,27 +1,14 @@
|
|||||||
<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, GridOptions, GridReadyEvent } from 'ag-grid-community'
|
import type { CellValueChangedEvent, ColDef, GridApi, 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 } 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 { getIndustryDisplayName, getMajorDictEntries, isMajorIdInIndustryScope, xmProjectConfig } from '@/sql'
|
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } 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'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -54,23 +41,14 @@ 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 = ref<Array<[string, MajorLite]>>([])
|
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
|
||||||
const majorIdAliasMap = ref<Map<string, string>>(new Map())
|
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||||
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>()
|
||||||
@@ -110,35 +88,14 @@ 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 result
|
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|
||||||
@@ -165,7 +122,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.value.get(rowId)
|
const aliasId = majorIdAliasMap.get(rowId)
|
||||||
if (aliasId && !dbValueMap.has(aliasId)) {
|
if (aliasId && !dbValueMap.has(aliasId)) {
|
||||||
dbValueMap.set(aliasId, row)
|
dbValueMap.set(aliasId, row)
|
||||||
}
|
}
|
||||||
@@ -251,13 +208,12 @@ 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.value.filter(([id]) =>
|
const filteredEntries = majorEntries.filter(([id]) =>
|
||||||
isMajorIdInIndustryScope(id, activeIndustryId.value)
|
isMajorIdInIndustryScope(id, activeIndustryId.value)
|
||||||
)
|
)
|
||||||
detailDict.value = buildDetailDict(filteredEntries)
|
detailDict.value = buildDetailDict(filteredEntries)
|
||||||
@@ -284,7 +240,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,12 +251,10 @@ 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()
|
||||||
@@ -309,7 +262,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -340,18 +292,6 @@ 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
|
||||||
@@ -362,44 +302,19 @@ 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 totalLabel = ref(xmProjectConfig.pinnedTotalLabel)
|
const industryNameMap = new Map(
|
||||||
|
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)
|
||||||
@@ -412,7 +327,7 @@ const refreshPinnedTotalLabelCell = () => {
|
|||||||
|
|
||||||
const columnDefs: ColDef<DetailRow>[] = [
|
const columnDefs: ColDef<DetailRow>[] = [
|
||||||
{
|
{
|
||||||
headerName: t('pricingScale.columns.investAmount'),
|
headerName: '造价金额(万元)',
|
||||||
field: 'amount',
|
field: 'amount',
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
@@ -423,63 +338,76 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
},
|
},
|
||||||
cellClass: params =>
|
cellClass: params =>
|
||||||
roughCalcEnabled.value && params.node?.rowPinned
|
roughCalcEnabled.value && params.node?.rowPinned
|
||||||
? 'editable-cell-line'
|
? 'ag-right-aligned-cell 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
|
||||||
? 'editable-cell-line'
|
? 'ag-right-aligned-cell 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 => parseNumberOrNull(params.newValue, { precision: 3 }),
|
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) {
|
||||||
if (!params.node?.rowPinned) return ''
|
if (!params.node?.rowPinned) return ''
|
||||||
if (params.value == null || params.value === '') return t('pricingScale.clickToInput')
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
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 t('pricingScale.clickToInput')
|
return '点击输入'
|
||||||
}
|
}
|
||||||
if (params.value == null) return ''
|
if (params.value == null) return ''
|
||||||
return formatThousandsFlexible(params.value, 3)
|
return formatThousandsFlexible(params.value, 3)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '造价金额(元)',
|
headerName: '用地面积(亩)',
|
||||||
field: 'amountYuan',
|
field: 'landArea',
|
||||||
headerClass: 'ag-right-aligned-header',
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 120,
|
minWidth: 100,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
editable: false,
|
editable: params => !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
|
||||||
aggFunc: decimalAggSum,
|
cellClass: params =>
|
||||||
valueGetter: params => params.data?.amount,
|
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
|
||||||
|
? 'ag-right-aligned-cell editable-cell-line'
|
||||||
|
: 'ag-right-aligned-cell',
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'ag-right-aligned-cell': () => true
|
'editable-cell-empty': params =>
|
||||||
|
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
|
||||||
|
},
|
||||||
|
valueParser: params => {
|
||||||
|
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 ''
|
||||||
}
|
}
|
||||||
const amount = params.value
|
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
|
||||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return formatThousandsFlexible(roundTo(amount * 10000, 0), 0)
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return formatThousandsFlexible(params.value, 3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
|
||||||
|
|
||||||
const autoGroupColumnDef: ColDef = {
|
const autoGroupColumnDef: ColDef = {
|
||||||
headerName: t('pricingScale.columns.majorGroup'),
|
headerName: '专业编码以及工程专业名称',
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
cellRendererParams: {
|
cellRendererParams: {
|
||||||
@@ -509,50 +437,14 @@ const pinnedTopRowData = ref<DetailRow[]>([
|
|||||||
path: ['TOTAL']
|
path: ['TOTAL']
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
const syncToastOpen = ref(false)
|
|
||||||
const syncToastText = ref('')
|
|
||||||
let lastPersistedLeafRows: DetailRow[] | null = null
|
|
||||||
|
|
||||||
const cloneLeafRows = (rows: DetailRow[]) =>
|
|
||||||
rows.map(row => ({
|
|
||||||
...JSON.parse(JSON.stringify(row)),
|
|
||||||
hide: Boolean(row.hide),
|
|
||||||
isGroupRow: false
|
|
||||||
}))
|
|
||||||
|
|
||||||
const getChangedScaleRowIds = (previousLeafRows: DetailRow[], nextLeafRows: DetailRow[]) => {
|
|
||||||
const previousRowMap = new Map(previousLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
|
|
||||||
const nextRowMap = new Map(nextLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
|
|
||||||
return Array.from(new Set([
|
|
||||||
...previousRowMap.keys(),
|
|
||||||
...nextRowMap.keys()
|
|
||||||
])).filter(rowId => {
|
|
||||||
const prevRow = previousRowMap.get(rowId)
|
|
||||||
const nextRow = nextRowMap.get(rowId)
|
|
||||||
const prevAmount = typeof prevRow?.amount === 'number' && Number.isFinite(prevRow.amount) ? roundTo(prevRow.amount, 6) : null
|
|
||||||
const nextAmount = typeof nextRow?.amount === 'number' && Number.isFinite(nextRow.amount) ? roundTo(nextRow.amount, 6) : null
|
|
||||||
const prevLandArea = typeof prevRow?.landArea === 'number' && Number.isFinite(prevRow.landArea) ? roundTo(prevRow.landArea, 6) : null
|
|
||||||
const nextLandArea = typeof nextRow?.landArea === 'number' && Number.isFinite(nextRow.landArea) ? roundTo(nextRow.landArea, 6) : null
|
|
||||||
return prevAmount !== nextAmount || prevLandArea !== nextLandArea
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showScaleSyncToast = (result: ContractScaleSyncResult) => {
|
|
||||||
if (result.updatedMethodCount <= 0) return
|
|
||||||
syncToastText.value = t('xmScaleGrid.syncToastDesc', {
|
|
||||||
serviceCount: result.updatedServiceCount,
|
|
||||||
methodCount: result.updatedMethodCount,
|
|
||||||
rowCount: result.updatedRowCount
|
|
||||||
})
|
|
||||||
syncToastOpen.value = false
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
syncToastOpen.value = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveToIndexedDB = async () => {
|
const saveToIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const leafRows = cloneLeafRows(detailRows.value)
|
const leafRows = detailRows.value.map(row => ({
|
||||||
|
...JSON.parse(JSON.stringify(row)),
|
||||||
|
hide: Boolean(row.hide),
|
||||||
|
isGroupRow: false
|
||||||
|
}))
|
||||||
const totalAmountFromRows = (() => {
|
const totalAmountFromRows = (() => {
|
||||||
let hasValue = false
|
let hasValue = false
|
||||||
let total = 0
|
let total = 0
|
||||||
@@ -577,20 +469,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -603,12 +481,6 @@ 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
|
||||||
@@ -628,24 +500,6 @@ 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
|
||||||
@@ -666,7 +520,6 @@ 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)
|
||||||
@@ -681,15 +534,6 @@ 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
|
||||||
|
|
||||||
@@ -707,10 +551,6 @@ 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
|
||||||
@@ -726,45 +566,10 @@ 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
|
||||||
|
|
||||||
@@ -803,91 +608,33 @@ 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">
|
<h3
|
||||||
<h3
|
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"
|
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
|
||||||
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
|
<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 :class="agGridWrapClass">
|
|
||||||
<AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
|
|
||||||
:columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
|
|
||||||
:animateRows="true"
|
|
||||||
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
|
|
||||||
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
|
|
||||||
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
|
|
||||||
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
|
||||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
|
|
||||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
|
||||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToastRoot
|
|
||||||
v-model:open="syncToastOpen"
|
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
|
||||||
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"
|
<AgGridVue :style="{ height: '100%' }" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
|
||||||
>
|
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
|
||||||
<div class="flex items-start justify-between gap-3">
|
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
|
||||||
<div class="space-y-1">
|
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||||
<ToastTitle class="text-sm font-semibold text-foreground">{{ t('xmScaleGrid.syncToastTitle') }}</ToastTitle>
|
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
|
||||||
<ToastDescription class="text-xs text-muted-foreground">{{ syncToastText }}</ToastDescription>
|
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||||
</div>
|
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||||
<ToastAction as-child :alt-text="t('common.close')">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</ToastProvider>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Card, CardContent, 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 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: PROJECT_TAB_ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const savedInfo = await kvStore.getItem<ProjectInfoState>(PROJECT_INFO_KEY)
|
||||||
|
if (typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()) {
|
||||||
|
enterProjectCalc()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadProjectDefaults()
|
||||||
|
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: 'ContractDetailView',
|
||||||
|
props: {
|
||||||
|
contractId: QUICK_CONTRACT_ID,
|
||||||
|
contractName,
|
||||||
|
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||||||
|
projectScaleKey: null,
|
||||||
|
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
|
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} 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 = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadProjectDefaults()
|
||||||
|
void loadQuickDefaults()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input id="home-import-input" type="file" accept=".zw" class="sr-only" @change="handleHomeImportChange" />
|
||||||
|
<div class="flex min-h-full items-center justify-center px-4 py-8">
|
||||||
|
<div class="w-full max-w-5xl">
|
||||||
|
<div class="mx-auto max-w-2xl text-center">
|
||||||
|
<p class="text-sm font-medium tracking-[0.28em] text-muted-foreground">JGJS 2026</p>
|
||||||
|
<h1 class="mt-4 text-4xl font-semibold tracking-tight text-foreground">选择计算入口</h1>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
首页支持三种入口:继续项目计算、快速单合同测算,或直接导入已有数据包。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 grid items-stretch gap-5 md:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="flex h-full cursor-pointer flex-col border-border/70 bg-card/90 shadow-[0_18px_48px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_22px_56px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
@click="openProjectCalc"
|
||||||
|
@keydown.enter.prevent="openProjectCalc"
|
||||||
|
@keydown.space.prevent="openProjectCalc"
|
||||||
|
>
|
||||||
|
<CardHeader class="flex min-h-[184px] flex-col space-y-4 pb-4">
|
||||||
|
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||||
|
<FolderKanban class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div class="min-h-[88px]">
|
||||||
|
<CardTitle class="text-2xl">项目计算</CardTitle>
|
||||||
|
<CardDescription class="mt-2 text-sm leading-6">
|
||||||
|
继续使用当前项目卡片、流程线、合同段管理和整项目导入导出能力。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="mt-auto flex flex-1 flex-col justify-end space-y-4">
|
||||||
|
<div class="flex min-h-[72px] items-center rounded-2xl border border-border/60 bg-muted/35 p-4 text-sm text-muted-foreground">
|
||||||
|
适合多合同段、项目级系数维护、整项目报表和批量导入导出。
|
||||||
|
</div>
|
||||||
|
<Button class="w-full justify-between" @click.stop="openProjectCalc">
|
||||||
|
进入项目计算
|
||||||
|
<Calculator class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="flex h-full cursor-pointer flex-col border-border/70 bg-[linear-gradient(135deg,rgba(15,23,42,0.05),rgba(148,163,184,0.12))] shadow-[0_18px_48px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_22px_56px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
@click="openQuickCalcDialog"
|
||||||
|
@keydown.enter.prevent="openQuickCalcDialog"
|
||||||
|
@keydown.space.prevent="openQuickCalcDialog"
|
||||||
|
>
|
||||||
|
<CardHeader class="flex min-h-[184px] flex-col space-y-4 pb-4">
|
||||||
|
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500 text-slate-950">
|
||||||
|
<Zap class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div class="min-h-[88px]">
|
||||||
|
<CardTitle class="text-2xl">快速计算</CardTitle>
|
||||||
|
<CardDescription class="mt-2 text-sm leading-6">
|
||||||
|
先填写工程行业和合同名称,再直接进入单合同预算费用测算页面。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="mt-auto flex flex-1 flex-col justify-end space-y-4">
|
||||||
|
<div class="flex min-h-[72px] items-center rounded-2xl border border-border/60 bg-background/80 p-4 text-sm text-muted-foreground">
|
||||||
|
适合快速试算、单合同预算复核和不需要项目级合同段管理的场景。
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" class="w-full justify-between" @click.stop="openQuickCalcDialog">
|
||||||
|
进入快速计算
|
||||||
|
<Zap class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<label for="home-import-input" class="block h-full cursor-pointer">
|
||||||
|
<Card
|
||||||
|
class="flex h-full flex-col border-border/70 bg-[linear-gradient(135deg,rgba(14,116,144,0.08),rgba(186,230,253,0.26))] shadow-[0_18px_48px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_22px_56px_rgba(15,23,42,0.12)]"
|
||||||
|
>
|
||||||
|
<CardHeader class="flex min-h-[184px] flex-col space-y-4 pb-4">
|
||||||
|
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-600 text-white">
|
||||||
|
<Download class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div class="min-h-[88px]">
|
||||||
|
<CardTitle class="text-2xl">导入数据</CardTitle>
|
||||||
|
<CardDescription class="mt-2 text-sm leading-6">
|
||||||
|
直接导入已有 `.zw` 数据包,恢复项目计算或快速计算的本地工作区状态。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="mt-auto flex flex-1 flex-col justify-end space-y-4">
|
||||||
|
<div class="flex min-h-[72px] items-center rounded-2xl border border-border/60 bg-background/80 p-4 text-sm text-muted-foreground">
|
||||||
|
适合继续上次工作、切换设备恢复数据,或直接打开别人发来的测算包。
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex h-10 w-full items-center justify-between rounded-md border border-input bg-secondary px-4 text-sm font-medium text-secondary-foreground shadow-xs transition-colors">
|
||||||
|
<span>选择导入文件</span>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</label>
|
||||||
|
</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"
|
||||||
|
maxlength="40"
|
||||||
|
placeholder="请输入合同名称"
|
||||||
|
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
@keydown.enter="confirmQuickCalc"
|
||||||
|
/>
|
||||||
|
</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 || !quickIndustry || !quickContractName.trim()" @click="confirmQuickCalc">
|
||||||
|
{{ quickSubmitting ? '进入中...' : '进入快速计算' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import HtFeeMethodGrid from '@/components/common/HtFeeMethodGrid.vue'
|
||||||
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
|
import { additionalWorkList } from '@/sql'
|
||||||
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(() =>
|
||||||
getAdditionalWorkListEntries(locale.value).map(item => ({ id: item?.id, name: item?.name }))
|
additionalWorkList.map(item => String(item?.name || '').trim()).filter(Boolean)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HtFeeMethodGrid
|
<HtFeeMethodGrid
|
||||||
:title="t('htFee.additionalTitle')"
|
title="附加工作费"
|
||||||
:storageKey="STORAGE_KEY"
|
:storageKey="STORAGE_KEY"
|
||||||
:contract-id="props.contractId"
|
:contract-id="props.contractId"
|
||||||
:contract-name="props.contractName"
|
:contract-name="props.contractName"
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<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 '@/features/shared/components/XmFactorGrid.vue'
|
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -26,7 +25,6 @@ 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 {
|
||||||
@@ -59,7 +57,7 @@ onActivated(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<XmFactorGrid
|
<XmFactorGrid
|
||||||
:title="t('htFactors.consultCategoryTitle')"
|
title="咨询分类系数明细"
|
||||||
: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,100 @@
|
|||||||
|
<template>
|
||||||
|
<TypeLine
|
||||||
|
scene="ht-fee-method-type-line"
|
||||||
|
:title="titleText"
|
||||||
|
:subtitle="`合同ID:${contractIdText}`"
|
||||||
|
:copy-text="contractIdText"
|
||||||
|
:storage-key="activeTypeStorageKey"
|
||||||
|
default-category="rate-fee"
|
||||||
|
:categories="categories"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineComponent, h, markRaw, type Component } from 'vue'
|
||||||
|
import TypeLine from '@/layout/typeLine.vue'
|
||||||
|
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
|
||||||
|
import HtFeeRateMethodForm from '@/components/views/HtFeeRateMethodForm.vue'
|
||||||
|
import HourlyFeeGrid from '@/components/common/HourlyFeeGrid.vue'
|
||||||
|
|
||||||
|
interface TypeLineCategoryItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
component: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sourceTitle?: string
|
||||||
|
storageKey: string
|
||||||
|
rowId: string
|
||||||
|
rowName?: string
|
||||||
|
contractId?: string
|
||||||
|
contractName?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sourceTitleText = computed(() => props.sourceTitle || '费用明细')
|
||||||
|
const rowNameText = computed(() => props.rowName || '未命名')
|
||||||
|
const contractIdText = computed(() => String(props.contractId || '').trim())
|
||||||
|
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
|
||||||
|
const titleText = computed(() => `合同段:${contractNameText.value} · ${rowNameText.value || sourceTitleText.value}`)
|
||||||
|
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${props.rowId}`)
|
||||||
|
const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
|
||||||
|
`${props.storageKey}-${props.rowId}-${method}`
|
||||||
|
|
||||||
|
const quantityUnitPricePane = markRaw(
|
||||||
|
defineComponent({
|
||||||
|
name: 'HtFeeGrid',
|
||||||
|
setup() {
|
||||||
|
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
|
||||||
|
return () =>
|
||||||
|
h(HtFeeGrid, {
|
||||||
|
title: '数量单价',
|
||||||
|
storageKey: quantityStorageKey.value,
|
||||||
|
htMainStorageKey: props.storageKey,
|
||||||
|
htRowId: props.rowId,
|
||||||
|
htMethodType: 'quantity-unit-price-fee'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const rateFeePane = markRaw(
|
||||||
|
defineComponent({
|
||||||
|
name: 'HtFeeMethodTypePane-rate-fee',
|
||||||
|
setup() {
|
||||||
|
const rateStorageKey = computed(() => buildMethodStorageKey('rate-fee'))
|
||||||
|
return () =>
|
||||||
|
h(HtFeeRateMethodForm, {
|
||||||
|
storageKey: rateStorageKey.value,
|
||||||
|
contractId: props.contractId,
|
||||||
|
htMainStorageKey: props.storageKey,
|
||||||
|
htRowId: props.rowId,
|
||||||
|
htMethodType: 'rate-fee'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const hourlyFeePane = markRaw(
|
||||||
|
defineComponent({
|
||||||
|
name: 'HtFeeMethodTypePane-hourly-fee',
|
||||||
|
setup() {
|
||||||
|
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
|
||||||
|
return () =>
|
||||||
|
h(HourlyFeeGrid, {
|
||||||
|
title: '工时法明细',
|
||||||
|
storageKey: hourlyStorageKey.value,
|
||||||
|
htMainStorageKey: props.storageKey,
|
||||||
|
htRowId: props.rowId,
|
||||||
|
htMethodType: 'hourly-fee'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories: TypeLineCategoryItem[] = [
|
||||||
|
{ key: 'rate-fee', label: '费率计取', component: rateFeePane },
|
||||||
|
{ key: 'hourly-fee', label: '工时法', component: hourlyFeePane },
|
||||||
|
{ key: 'quantity-unit-price-fee', label: '数量单价', component: quantityUnitPricePane }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
<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'
|
||||||
@@ -20,7 +18,6 @@ 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('')
|
||||||
@@ -39,25 +36,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 contractStateSignature = computed(() => {
|
const contractVersion = computed(() => {
|
||||||
const contractId = contractIdText.value
|
const contractId = contractIdText.value
|
||||||
if (!contractId) return ''
|
if (!contractId) return 0
|
||||||
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
|
return zxFwPricingStore.contractVersions[contractId] || 0
|
||||||
})
|
})
|
||||||
const additionalWorkStateSignature = computed(() => {
|
const additionalWorkKeyVersion = computed(() => {
|
||||||
if (!isReserveFee.value) return ''
|
if (!isReserveFee.value) return 0
|
||||||
const contractId = contractIdText.value
|
const contractId = contractIdText.value
|
||||||
if (!contractId) return ''
|
if (!contractId) return 0
|
||||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||||
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
|
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
|
||||||
})
|
})
|
||||||
const baseLabel = computed(() =>
|
const baseLabel = computed(() =>
|
||||||
isReserveFee.value ? t('htFeeRate.reserveBaseLabel') : t('htFeeRate.baseLabel')
|
isReserveFee.value ? '基数(咨询服务总计 + 附加工作费总计)' : '基数(所有服务费预算合计)'
|
||||||
)
|
)
|
||||||
|
|
||||||
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 roundTo(baseValue.value * rate.value / 100, 2)
|
return Number((baseValue.value * rate.value).toFixed(3))
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatAmount = (value: number | null) =>
|
const formatAmount = (value: number | null) =>
|
||||||
@@ -70,7 +67,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 : roundTo(serviceBase, 3)
|
baseValue.value = serviceBase == null ? null : Number(serviceBase.toFixed(3))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||||
@@ -90,7 +87,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 ? roundTo(serviceBaseSafe + additionalTotal, 3) : null
|
baseValue.value = hasAny ? Number((serviceBaseSafe + additionalTotal).toFixed(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
|
||||||
@@ -151,7 +148,7 @@ const saveForm = async (force = false) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const applyRateInput = () => {
|
const applyRateInput = () => {
|
||||||
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 2 })
|
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 3 })
|
||||||
rate.value = next
|
rate.value = next
|
||||||
rateInput.value = next == null ? '' : String(next)
|
rateInput.value = next == null ? '' : String(next)
|
||||||
}
|
}
|
||||||
@@ -183,8 +180,8 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch([contractStateSignature, additionalWorkStateSignature], ([nextContractSig, nextAdditionalSig], [prevContractSig, prevAdditionalSig]) => {
|
watch([contractVersion, additionalWorkKeyVersion], ([nextContract, nextAdditional], [prevContract, prevAdditional]) => {
|
||||||
if (nextContractSig === prevContractSig && nextAdditionalSig === prevAdditionalSig) return
|
if (nextContract === prevContract && nextAdditional === prevAdditional) return
|
||||||
void ensureContractLoaded()
|
void ensureContractLoaded()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,31 +209,24 @@ onBeforeUnmount(() => {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="space-y-1.5">
|
<label class="space-y-1.5">
|
||||||
<div class="text-xs text-muted-foreground">{{ t('htFeeRate.rateLabel') }}</div>
|
<div class="text-xs text-muted-foreground">费率(可编辑,三位小数)</div>
|
||||||
<input v-model="rateInput" type="text" inputmode="decimal" :placeholder="t('htFeeRate.ratePlaceholder')"
|
<input v-model="rateInput" type="text" inputmode="decimal" placeholder="请输入费率,建议0.01 ~ 0.05"
|
||||||
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"
|
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"
|
||||||
@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">{{ t('htFeeRate.budgetFeeLabel') }}</div>
|
<div class="text-xs text-muted-foreground">预算费用(自动计算)</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">{{ t('htFeeRate.remarkLabel') }}</div>
|
<div class="text-xs text-muted-foreground">说明</div>
|
||||||
<textarea v-model="remark" rows="4" :placeholder="t('htFeeRate.remarkPlaceholder')"
|
<textarea v-model="remark" rows="4" 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" />
|
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,8 +1,7 @@
|
|||||||
<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 '@/features/shared/components/XmFactorGrid.vue'
|
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
|
|
||||||
interface XmBaseInfoState {
|
interface XmBaseInfoState {
|
||||||
@@ -26,7 +25,6 @@ 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 {
|
||||||
@@ -59,7 +57,7 @@ onActivated(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<XmFactorGrid
|
<XmFactorGrid
|
||||||
:title="t('htFactors.majorTitle')"
|
title="工程专业系数明细"
|
||||||
: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,24 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import HtFeeMethodGrid from '@/components/common/HtFeeMethodGrid.vue'
|
||||||
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
|
import { reserveList } from '@/sql'
|
||||||
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(() =>
|
||||||
getReserveListEntries(locale.value).map(item => ({ name: item.name, id: item.id }))
|
reserveList.map(item => String(item?.name || '').trim()).filter(Boolean)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HtFeeMethodGrid
|
<HtFeeMethodGrid
|
||||||
:title="t('htFee.reserveTitle')"
|
title="预备费"
|
||||||
:storageKey="STORAGE_KEY"
|
:storageKey="STORAGE_KEY"
|
||||||
:contract-id="props.contractId"
|
:contract-id="props.contractId"
|
||||||
:contract-name="props.contractName"
|
:contract-name="props.contractName"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onActivated, onMounted } from 'vue'
|
||||||
|
import XmCard from '@/components/views/xmCard.vue'
|
||||||
|
import { writeWorkspaceMode } from '@/lib/workspace'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
writeWorkspaceMode('project')
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
writeWorkspaceMode('project')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<XmCard />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useKvStore } from '@/pinia/kv'
|
||||||
|
import { useTabStore } from '@/pinia/tab'
|
||||||
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
|
import { ArrowRight, Calculator, PencilLine } from 'lucide-vue-next'
|
||||||
|
import { industryTypeList } from '@/sql'
|
||||||
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
import { roundTo } from '@/lib/decimal'
|
||||||
|
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||||
|
import {
|
||||||
|
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
|
QUICK_CONTRACT_FALLBACK_NAME,
|
||||||
|
QUICK_CONTRACT_ID,
|
||||||
|
QUICK_CONTRACT_META_KEY,
|
||||||
|
QUICK_MAJOR_FACTOR_KEY,
|
||||||
|
QUICK_PROJECT_INFO_KEY,
|
||||||
|
QUICK_PROJECT_SCALE_KEY,
|
||||||
|
createDefaultQuickContractMeta,
|
||||||
|
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 HtFeeMainRowLike {
|
||||||
|
id?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateMethodStateLike {
|
||||||
|
budgetFee?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HourlyMethodRowLike {
|
||||||
|
serviceBudget?: unknown
|
||||||
|
adoptedBudgetUnitPrice?: unknown
|
||||||
|
personnelCount?: unknown
|
||||||
|
workdayCount?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HourlyMethodStateLike {
|
||||||
|
detailRows?: HourlyMethodRowLike[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuantityMethodRowLike {
|
||||||
|
id?: unknown
|
||||||
|
budgetFee?: unknown
|
||||||
|
quantity?: unknown
|
||||||
|
unitPrice?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuantityMethodStateLike {
|
||||||
|
detailRows?: QuantityMethodRowLike[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const kvStore = useKvStore()
|
||||||
|
const tabStore = useTabStore()
|
||||||
|
const zxFwPricingStore = useZxFwPricingStore()
|
||||||
|
|
||||||
|
const contractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||||||
|
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||||
|
const quickBudget = ref<number | null>(null)
|
||||||
|
const savingIndustry = ref(false)
|
||||||
|
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const availableIndustries = computed(() =>
|
||||||
|
industryTypeList.map(item => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.name
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatBudgetAmount = (value: number | null | undefined) =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||||||
|
|
||||||
|
const toFiniteNumber = (value: unknown): number | null => {
|
||||||
|
if (value == null || value === '') return null
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
||||||
|
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
let hasValid = false
|
||||||
|
let total = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
const serviceBudget = toFiniteNumber(row?.serviceBudget)
|
||||||
|
if (serviceBudget != null) {
|
||||||
|
total += serviceBudget
|
||||||
|
hasValid = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||||||
|
const personnel = toFiniteNumber(row?.personnelCount)
|
||||||
|
const workday = toFiniteNumber(row?.workdayCount)
|
||||||
|
if (adopted == null || personnel == null || workday == null) continue
|
||||||
|
total += adopted * personnel * workday
|
||||||
|
hasValid = true
|
||||||
|
}
|
||||||
|
return hasValid ? roundTo(total, 2) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
||||||
|
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||||||
|
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
|
||||||
|
if (subtotal != null) return roundTo(subtotal, 2)
|
||||||
|
|
||||||
|
let hasValid = false
|
||||||
|
let total = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||||
|
const budget = toFiniteNumber(row?.budgetFee)
|
||||||
|
if (budget != null) {
|
||||||
|
total += budget
|
||||||
|
hasValid = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const quantity = toFiniteNumber(row?.quantity)
|
||||||
|
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||||||
|
if (quantity == null || unitPrice == null) continue
|
||||||
|
total += quantity * unitPrice
|
||||||
|
hasValid = true
|
||||||
|
}
|
||||||
|
return hasValid ? roundTo(total, 2) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
|
||||||
|
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||||||
|
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||||||
|
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||||
|
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||||
|
])
|
||||||
|
const parts = [
|
||||||
|
toFiniteNumber(rateState?.budgetFee),
|
||||||
|
sumHourlyMethodFee(hourlyState),
|
||||||
|
sumQuantityMethodFee(quantityState)
|
||||||
|
]
|
||||||
|
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||||
|
if (validParts.length === 0) return null
|
||||||
|
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||||
|
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
|
||||||
|
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
|
||||||
|
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
||||||
|
if (rowIds.length === 0) return null
|
||||||
|
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
||||||
|
const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||||
|
if (validTotals.length === 0) return null
|
||||||
|
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshQuickBudget = async () => {
|
||||||
|
await zxFwPricingStore.loadContract(QUICK_CONTRACT_ID)
|
||||||
|
const serviceFee = zxFwPricingStore.getBaseSubtotal(QUICK_CONTRACT_ID)
|
||||||
|
const [additionalFee, reserveFee] = await Promise.all([
|
||||||
|
loadHtMainTotalFee(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work`),
|
||||||
|
loadHtMainTotalFee(`htExtraFee-${QUICK_CONTRACT_ID}-reserve`)
|
||||||
|
])
|
||||||
|
const parts = [serviceFee, additionalFee, reserveFee]
|
||||||
|
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||||
|
quickBudget.value = validParts.length === 0 ? null : roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleRefreshQuickBudget = () => {
|
||||||
|
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||||
|
budgetRefreshTimer = setTimeout(() => {
|
||||||
|
void refreshQuickBudget()
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeQuickContractMeta = (value: QuickContractMetaState | null) => ({
|
||||||
|
id: typeof value?.id === 'string' && value.id.trim() ? value.id.trim() : QUICK_CONTRACT_ID,
|
||||||
|
name: typeof value?.name === 'string' && value.name.trim() ? value.name.trim() : QUICK_CONTRACT_FALLBACK_NAME,
|
||||||
|
updatedAt:
|
||||||
|
typeof value?.updatedAt === 'string' && value.updatedAt.trim()
|
||||||
|
? value.updatedAt
|
||||||
|
: new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureQuickWorkspaceReady = async () => {
|
||||||
|
const [savedInfo, savedMeta] = await Promise.all([
|
||||||
|
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
|
||||||
|
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
|
||||||
|
])
|
||||||
|
|
||||||
|
const defaultIndustry = String(industryTypeList[0]?.id || '')
|
||||||
|
const nextIndustry =
|
||||||
|
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
||||||
|
? savedInfo.projectIndustry.trim()
|
||||||
|
: defaultIndustry
|
||||||
|
|
||||||
|
projectIndustry.value = nextIndustry
|
||||||
|
contractName.value = normalizeQuickContractMeta(savedMeta).name
|
||||||
|
|
||||||
|
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||||||
|
projectIndustry: nextIndustry,
|
||||||
|
projectName: '快速计算'
|
||||||
|
})
|
||||||
|
|
||||||
|
const consultState = await kvStore.getItem(QUICK_CONSULT_CATEGORY_FACTOR_KEY)
|
||||||
|
const majorState = await kvStore.getItem(QUICK_MAJOR_FACTOR_KEY)
|
||||||
|
if (!consultState || !majorState) {
|
||||||
|
await initializeProjectFactorStates(
|
||||||
|
kvStore,
|
||||||
|
nextIndustry,
|
||||||
|
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
|
QUICK_MAJOR_FACTOR_KEY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||||||
|
...createDefaultQuickContractMeta(),
|
||||||
|
...normalizeQuickContractMeta(savedMeta)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistQuickContractMeta = async () => {
|
||||||
|
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||||||
|
id: QUICK_CONTRACT_ID,
|
||||||
|
name: contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistQuickIndustry = async (industry: string) => {
|
||||||
|
if (!industry) return
|
||||||
|
savingIndustry.value = true
|
||||||
|
try {
|
||||||
|
const current = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
|
||||||
|
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||||||
|
...current,
|
||||||
|
projectIndustry: industry,
|
||||||
|
projectName: '快速计算'
|
||||||
|
})
|
||||||
|
await initializeProjectFactorStates(
|
||||||
|
kvStore,
|
||||||
|
industry,
|
||||||
|
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
|
QUICK_MAJOR_FACTOR_KEY
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
savingIndustry.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openQuickContract = () => {
|
||||||
|
writeWorkspaceMode('quick')
|
||||||
|
tabStore.openTab({
|
||||||
|
id: `contract-${QUICK_CONTRACT_ID}`,
|
||||||
|
title: `快速计算-${contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME}`,
|
||||||
|
componentName: 'ContractDetailView',
|
||||||
|
props: {
|
||||||
|
contractId: QUICK_CONTRACT_ID,
|
||||||
|
contractName: contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME,
|
||||||
|
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||||||
|
projectScaleKey: QUICK_PROJECT_SCALE_KEY,
|
||||||
|
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
|
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => contractName.value,
|
||||||
|
() => {
|
||||||
|
void persistQuickContractMeta()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => projectIndustry.value,
|
||||||
|
nextIndustry => {
|
||||||
|
if (!nextIndustry) return
|
||||||
|
void persistQuickIndustry(nextIndustry)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
zxFwPricingStore.contractVersions[QUICK_CONTRACT_ID] || 0,
|
||||||
|
zxFwPricingStore.getKeyVersion(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work`),
|
||||||
|
zxFwPricingStore.getKeyVersion(`htExtraFee-${QUICK_CONTRACT_ID}-reserve`),
|
||||||
|
Object.entries(zxFwPricingStore.keyVersions)
|
||||||
|
.filter(([key]) =>
|
||||||
|
key.startsWith(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work-`) ||
|
||||||
|
key.startsWith(`htExtraFee-${QUICK_CONTRACT_ID}-reserve-`)
|
||||||
|
)
|
||||||
|
.map(([key, version]) => `${key}:${version}`)
|
||||||
|
.join('|')
|
||||||
|
],
|
||||||
|
scheduleRefreshQuickBudget
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
writeWorkspaceMode('quick')
|
||||||
|
await ensureQuickWorkspaceReady()
|
||||||
|
await refreshQuickBudget()
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
writeWorkspaceMode('quick')
|
||||||
|
scheduleRefreshQuickBudget()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
|
||||||
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]">
|
||||||
|
<Card class="border-border/70">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-2xl">
|
||||||
|
<Calculator class="h-5 w-5" />
|
||||||
|
快速计算
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
保留一个默认合同卡片,不再经过项目卡片入口,直接进入单合同预算费用计算。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-sm font-medium text-foreground">工程行业</span>
|
||||||
|
<select
|
||||||
|
v-model="projectIndustry"
|
||||||
|
class="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option v-for="item in availableIndustries" :key="item.id" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-sm font-medium text-foreground">合同名称</span>
|
||||||
|
<div class="relative">
|
||||||
|
<PencilLine class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
v-model="contractName"
|
||||||
|
type="text"
|
||||||
|
maxlength="40"
|
||||||
|
class="h-11 w-full rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
placeholder="请输入合同名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="border-border/70 bg-muted/25">
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<CardTitle class="text-base">当前状态</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2 text-sm text-muted-foreground">
|
||||||
|
<div>模式:单合同快速计算</div>
|
||||||
|
<div>合同ID:{{ QUICK_CONTRACT_ID }}</div>
|
||||||
|
<div>行业切换会同步重建快速计算专用系数基线</div>
|
||||||
|
<div>{{ savingIndustry ? '正在切换行业并刷新系数...' : '行业与合同名称已自动保存' }}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
class="group cursor-pointer border-border/70 transition-colors hover:border-primary"
|
||||||
|
@click="openQuickContract"
|
||||||
|
>
|
||||||
|
<CardHeader class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<CardTitle class="text-xl">
|
||||||
|
{{ contractName.trim() || QUICK_CONTRACT_FALLBACK_NAME }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>默认单合同卡片,点击后进入预算费用计算详情。</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button class="shrink-0 md:self-center" @click.stop="openQuickContract">
|
||||||
|
进入计算
|
||||||
|
<ArrowRight class="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-4 border-t pt-5 text-sm text-muted-foreground md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">合同ID</div>
|
||||||
|
<div class="mt-2 break-all text-foreground">{{ QUICK_CONTRACT_ID }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">预算费用</div>
|
||||||
|
<div class="mt-2 text-foreground">{{ formatBudgetAmount(quickBudget) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">行业</div>
|
||||||
|
<div class="mt-2 text-foreground">
|
||||||
|
{{ availableIndustries.find(item => item.id === projectIndustry)?.name || '--' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<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>
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<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 '@/features/shared/components/XmFactorGrid.vue'
|
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
|
|
||||||
interface XmBaseInfoState {
|
interface XmBaseInfoState {
|
||||||
@@ -20,7 +19,6 @@ 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 {
|
||||||
@@ -53,7 +51,7 @@ onActivated(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<XmFactorGrid :title="t('htFactors.consultCategoryTitle')" storage-key="xm-consult-category-factor-v1" :dict="filteredServiceDict"
|
<XmFactorGrid title="咨询分类系数明细" 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,9 +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 '@/features/shared/components/XmFactorGrid.vue'
|
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
|
|
||||||
interface XmBaseInfoState {
|
interface XmBaseInfoState {
|
||||||
@@ -22,7 +21,6 @@ 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 {
|
||||||
@@ -58,7 +56,7 @@ onActivated(() => {
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<XmFactorGrid
|
<XmFactorGrid
|
||||||
:title="t('htFactors.majorTitle')"
|
title="工程专业系数明细"
|
||||||
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"
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<TypeLine
|
||||||
|
scene="zxfw-pricing-tab"
|
||||||
|
:title="`${contractName ? `合同段:${contractName} · ` : ''}${fwName}计算`"
|
||||||
|
:subtitle="`合同ID:${contractId}`"
|
||||||
|
:copy-text="contractId"
|
||||||
|
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
|
||||||
|
:default-category="defaultCategory"
|
||||||
|
:categories="pricingCategories"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
|
||||||
|
import TypeLine from '@/layout/typeLine.vue'
|
||||||
|
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
||||||
|
|
||||||
|
interface ServiceMethodType {
|
||||||
|
scale?: boolean | null
|
||||||
|
onlyCostScale?: boolean | null
|
||||||
|
amount?: boolean | null
|
||||||
|
workDay?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string
|
||||||
|
contractName?: string
|
||||||
|
serviceId: string|number
|
||||||
|
fwName:string
|
||||||
|
type?: ServiceMethodType
|
||||||
|
projectInfoKey?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface PricingCategoryItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
component: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) =>
|
||||||
|
typeof value === 'boolean' ? value : fallback
|
||||||
|
|
||||||
|
const methodAvailability = computed(() => {
|
||||||
|
const scale = resolveMethodEnabled(props.type?.scale, true)
|
||||||
|
const onlyCostScale = resolveMethodEnabled(props.type?.onlyCostScale, false)
|
||||||
|
const amount = resolveMethodEnabled(props.type?.amount, true)
|
||||||
|
const workDay = resolveMethodEnabled(props.type?.workDay, true)
|
||||||
|
return {
|
||||||
|
investmentScale: scale,
|
||||||
|
landScale: scale && !onlyCostScale,
|
||||||
|
workload: amount,
|
||||||
|
hourly: workDay
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const createPricingPane = (name: string) =>
|
||||||
|
markRaw(
|
||||||
|
defineComponent({
|
||||||
|
name,
|
||||||
|
setup() {
|
||||||
|
const AsyncPricingView = defineAsyncComponent({
|
||||||
|
loader: () => import(`@/components/views/pricingView/${name}.vue`),
|
||||||
|
onError: err => {
|
||||||
|
console.error('加载 PricingMethodView 组件失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => h(AsyncPricingView, {
|
||||||
|
contractId: props.contractId,
|
||||||
|
serviceId: props.serviceId,
|
||||||
|
projectInfoKey: props.projectInfoKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const createMethodUnavailablePane = (title: string, message: string) =>
|
||||||
|
markRaw(
|
||||||
|
defineComponent({
|
||||||
|
name: 'MethodUnavailablePane',
|
||||||
|
setup() {
|
||||||
|
return () => h(MethodUnavailableNotice, { title, message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
|
||||||
|
const landScaleView = createPricingPane('LandScalePricingPane')
|
||||||
|
const workloadView = createPricingPane('WorkloadPricingPane')
|
||||||
|
const hourlyView = createPricingPane('HourlyPricingPane')
|
||||||
|
|
||||||
|
const investmentScaleUnavailableView = createMethodUnavailablePane(
|
||||||
|
'该服务不适用投资规模法',
|
||||||
|
'当前服务未启用规模法,投资规模法不可编辑。'
|
||||||
|
)
|
||||||
|
const landScaleUnavailableView = createMethodUnavailablePane(
|
||||||
|
'该服务不适用用地规模法',
|
||||||
|
'当前服务仅支持投资规模法,用地规模法不可编辑。'
|
||||||
|
)
|
||||||
|
const workloadUnavailableView = createMethodUnavailablePane(
|
||||||
|
'该服务不适用工作量法',
|
||||||
|
'当前服务未启用工作量法,工作量法不可编辑。'
|
||||||
|
)
|
||||||
|
const hourlyUnavailableView = createMethodUnavailablePane(
|
||||||
|
'该服务不适用工时法',
|
||||||
|
'当前服务未启用工时法,工时法不可编辑。'
|
||||||
|
)
|
||||||
|
|
||||||
|
const pricingCategories = computed<PricingCategoryItem[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'investment-scale-method',
|
||||||
|
label: '投资规模法',
|
||||||
|
component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'land-scale-method',
|
||||||
|
label: '用地规模法',
|
||||||
|
component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'workload-method',
|
||||||
|
label: '工作量法',
|
||||||
|
component: methodAvailability.value.workload ? workloadView : workloadUnavailableView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hourly-method',
|
||||||
|
label: '工时法',
|
||||||
|
component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const defaultCategory = computed(() => {
|
||||||
|
if (methodAvailability.value.investmentScale) return 'investment-scale-method'
|
||||||
|
if (methodAvailability.value.landScale) return 'land-scale-method'
|
||||||
|
if (methodAvailability.value.workload) return 'workload-method'
|
||||||
|
if (methodAvailability.value.hourly) return 'hourly-method'
|
||||||
|
return 'investment-scale-method'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,24 +2,22 @@
|
|||||||
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
|
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
|
||||||
<TypeLine
|
<TypeLine
|
||||||
scene="ht-tab"
|
scene="ht-tab"
|
||||||
:title="t('htCard.title', { name: contractName })"
|
:title="`合同段:${contractName}`"
|
||||||
:subtitle="t('htCard.subtitle', { id: contractId })"
|
:subtitle="`合同段ID:${contractId}`"
|
||||||
:meta-text="t('htCard.metaBudget', { amount: formatBudgetAmount(contractBudget) })"
|
:meta-text="`合同段预算金额:${formatBudgetAmount(contractBudget)}`"
|
||||||
:copy-text="contractId"
|
:copy-text="contractId"
|
||||||
:storage-key="typeLineStorageKey"
|
:storage-key="`project-active-cat-${contractId}`"
|
||||||
default-category="base-info"
|
default-category="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, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
|
import { roundTo } 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<{
|
||||||
@@ -30,7 +28,6 @@ const props = defineProps<{
|
|||||||
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
|
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
|
||||||
projectMajorFactorKey?: string; // 工作区工程专业系数键
|
projectMajorFactorKey?: string; // 工作区工程专业系数键
|
||||||
}>();
|
}>();
|
||||||
const { t } = useI18n()
|
|
||||||
const zxFwPricingStore = useZxFwPricingStore()
|
const zxFwPricingStore = useZxFwPricingStore()
|
||||||
|
|
||||||
interface HtFeeMainRowLike {
|
interface HtFeeMainRowLike {
|
||||||
@@ -64,13 +61,16 @@ 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)
|
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||||||
? `${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,8 +130,9 @@ const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) =>
|
|||||||
sumHourlyMethodFee(hourlyState),
|
sumHourlyMethodFee(hourlyState),
|
||||||
sumQuantityMethodFee(quantityState)
|
sumQuantityMethodFee(quantityState)
|
||||||
]
|
]
|
||||||
const total = sumNullableNumbers(parts)
|
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||||
return total == null ? null : roundTo(total, 2)
|
if (validParts.length === 0) return null
|
||||||
|
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||||
@@ -140,8 +141,9 @@ 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 total = sumNullableNumbers(rowTotals)
|
const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||||
return total == null ? null : roundTo(total, 2)
|
if (validTotals.length === 0) return null
|
||||||
|
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshContractBudget = async () => {
|
const refreshContractBudget = async () => {
|
||||||
@@ -152,28 +154,21 @@ const refreshContractBudget = async () => {
|
|||||||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
|
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
|
||||||
])
|
])
|
||||||
const parts = [serviceFee, additionalFee, reserveFee]
|
const parts = [serviceFee, additionalFee, reserveFee]
|
||||||
const total = sumNullableNumbers(parts)
|
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||||
|
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`
|
||||||
return JSON.stringify({
|
const keyVersionEntries = Object.entries(zxFwPricingStore.keyVersions)
|
||||||
contractState: zxFwPricingStore.contracts[props.contractId] || null,
|
const methodKeySig = keyVersionEntries
|
||||||
addMain: zxFwPricingStore.htFeeMainStates[additionalMainKey] || null,
|
.filter(([key]) => key.startsWith(`${additionalMainKey}-`) || key.startsWith(`${reserveMainKey}-`))
|
||||||
reserveMain: zxFwPricingStore.htFeeMainStates[reserveMainKey] || null,
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
addMethods: zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null,
|
.map(([key, version]) => `${key}:${version}`)
|
||||||
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
|
.join(',')
|
||||||
})
|
return `${contractVersion}:${zxFwPricingStore.getKeyVersion(additionalMainKey)}:${zxFwPricingStore.getKeyVersion(reserveMainKey)}:${methodKeySig}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const scheduleRefreshContractBudget = () => {
|
const scheduleRefreshContractBudget = () => {
|
||||||
@@ -185,16 +180,7 @@ const scheduleRefreshContractBudget = () => {
|
|||||||
|
|
||||||
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
|
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
|
||||||
interface XmCategoryItem {
|
interface XmCategoryItem {
|
||||||
key:
|
key: string;
|
||||||
| 'info'
|
|
||||||
| 'base-info'
|
|
||||||
| 'consult-category-factor'
|
|
||||||
| 'major-factor'
|
|
||||||
| 'work-grid'
|
|
||||||
| 'contract'
|
|
||||||
| 'additional-work-fee'
|
|
||||||
| 'reserve-fee'
|
|
||||||
| 'all';
|
|
||||||
label: string;
|
label: string;
|
||||||
component: Component; // 标记为 Vue 组件类型
|
component: Component; // 标记为 Vue 组件类型
|
||||||
}
|
}
|
||||||
@@ -205,7 +191,7 @@ const htView = markRaw(
|
|||||||
name: 'HtInfoWithProps',
|
name: 'HtInfoWithProps',
|
||||||
setup() {
|
setup() {
|
||||||
const AsyncHtInfo = defineAsyncComponent({
|
const AsyncHtInfo = defineAsyncComponent({
|
||||||
loader: () => import('@/features/ht/components/htInfo.vue'),
|
loader: () => import('@/components/views/htInfo.vue'),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('加载 htInfo 组件失败:', err);
|
console.error('加载 htInfo 组件失败:', err);
|
||||||
}
|
}
|
||||||
@@ -224,7 +210,7 @@ const zxfwView = markRaw(
|
|||||||
name: 'ZxFwWithProps',
|
name: 'ZxFwWithProps',
|
||||||
setup() {
|
setup() {
|
||||||
const AsyncZxFw = defineAsyncComponent({
|
const AsyncZxFw = defineAsyncComponent({
|
||||||
loader: () => import('@/features/ht/components/zxFw.vue'),
|
loader: () => import('@/components/views/zxFw.vue'),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('加载 zxFw 组件失败:', err);
|
console.error('加载 zxFw 组件失败:', err);
|
||||||
}
|
}
|
||||||
@@ -243,7 +229,7 @@ const consultCategoryFactorView = markRaw(
|
|||||||
name: 'HtConsultCategoryFactorWithProps',
|
name: 'HtConsultCategoryFactorWithProps',
|
||||||
setup() {
|
setup() {
|
||||||
const AsyncHtConsultCategoryFactor = defineAsyncComponent({
|
const AsyncHtConsultCategoryFactor = defineAsyncComponent({
|
||||||
loader: () => import('@/features/ht/components/HtConsultCategoryFactor.vue'),
|
loader: () => import('@/components/views/HtConsultCategoryFactor.vue'),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
|
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
|
||||||
}
|
}
|
||||||
@@ -262,7 +248,7 @@ const majorFactorView = markRaw(
|
|||||||
name: 'HtMajorFactorWithProps',
|
name: 'HtMajorFactorWithProps',
|
||||||
setup() {
|
setup() {
|
||||||
const AsyncHtMajorFactor = defineAsyncComponent({
|
const AsyncHtMajorFactor = defineAsyncComponent({
|
||||||
loader: () => import('@/features/ht/components/HtMajorFactor.vue'),
|
loader: () => import('@/components/views/HtMajorFactor.vue'),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('加载 HtMajorFactor 组件失败:', err);
|
console.error('加载 HtMajorFactor 组件失败:', err);
|
||||||
}
|
}
|
||||||
@@ -276,29 +262,12 @@ const majorFactorView = markRaw(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const htBaseInfoView = markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name: 'HtBaseInfoWithProps',
|
|
||||||
setup() {
|
|
||||||
const AsyncHtBaseInfo = defineAsyncComponent({
|
|
||||||
loader: () => import('@/features/ht/components/HtBaseInfo.vue'),
|
|
||||||
onError: (err) => {
|
|
||||||
console.error('加载 HtBaseInfo 组件失败:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => h(AsyncHtBaseInfo, { contractId: props.contractId })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const additionalWorkFeeView = markRaw(
|
const additionalWorkFeeView = markRaw(
|
||||||
defineComponent({
|
defineComponent({
|
||||||
name: 'HtAdditionalWorkFeeWithProps',
|
name: 'HtAdditionalWorkFeeWithProps',
|
||||||
setup() {
|
setup() {
|
||||||
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
|
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
|
||||||
loader: () => import('@/features/ht/components/HtAdditionalWorkFee.vue'),
|
loader: () => import('@/components/views/HtAdditionalWorkFee.vue'),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
|
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
|
||||||
}
|
}
|
||||||
@@ -313,7 +282,7 @@ const reserveFeeView = markRaw(
|
|||||||
name: 'HtReserveFeeWithProps',
|
name: 'HtReserveFeeWithProps',
|
||||||
setup() {
|
setup() {
|
||||||
const AsyncHtReserveFee = defineAsyncComponent({
|
const AsyncHtReserveFee = defineAsyncComponent({
|
||||||
loader: () => import('@/features/ht/components/HtReserveFee.vue'),
|
loader: () => import('@/components/views/HtReserveFee.vue'),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('加载 HtReserveFee 组件失败:', err);
|
console.error('加载 HtReserveFee 组件失败:', err);
|
||||||
}
|
}
|
||||||
@@ -323,32 +292,17 @@ 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 = computed<XmCategoryItem[]>(() => [
|
const xmCategories: XmCategoryItem[] = [
|
||||||
{ 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: t('htCard.categories.additionalFee'), component: additionalWorkFeeView },
|
{ key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView },
|
||||||
// { key: 'reserve-fee', label: t('htCard.categories.reserveFee'), component: reserveFeeView },
|
{ key: 'reserve-fee', 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
|
||||||
@@ -367,4 +321,3 @@ onBeforeUnmount(() => {
|
|||||||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
|
||||||
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
|
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -15,11 +14,10 @@ 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="t('htInfo.scaleDetailTitle')" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/>
|
<CommonAgGrid title="合同规模明细" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { parseDate } from '@internationalized/date'
|
import { parseDate } from '@internationalized/date'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { 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 {
|
||||||
@@ -42,17 +37,13 @@ interface XmInfoState {
|
|||||||
reviewedBy?: string
|
reviewedBy?: string
|
||||||
preparedCompany?: string
|
preparedCompany?: string
|
||||||
preparedDate?: string
|
preparedDate?: string
|
||||||
overview?: string
|
|
||||||
desc?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { t, locale } = useI18n()
|
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||||
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())
|
||||||
@@ -62,7 +53,6 @@ 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('')
|
||||||
@@ -71,8 +61,6 @@ const reviewedBy = ref('')
|
|||||||
const preparedCompany = ref('')
|
const preparedCompany = ref('')
|
||||||
const preparedDate = ref(getTodayDateString())
|
const preparedDate = ref(getTodayDateString())
|
||||||
const preparedDatePickerValue = ref<any>(undefined)
|
const preparedDatePickerValue = ref<any>(undefined)
|
||||||
const overview = ref('')
|
|
||||||
const desc = ref(DEFAULT_DESC)
|
|
||||||
|
|
||||||
const normalizeDateString = (value: unknown): string => {
|
const normalizeDateString = (value: unknown): string => {
|
||||||
if (typeof value !== 'string') return ''
|
if (typeof value !== 'string') return ''
|
||||||
@@ -104,32 +92,25 @@ const handlePreparedDateSelect = (date: any) => {
|
|||||||
preparedDate.value = date?.toString() ?? ''
|
preparedDate.value = date?.toString() ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const majorParentNodes = computed<MajorParentNode[]>(() =>
|
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({
|
||||||
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: normalizedProjectName,
|
projectName: projectName.value,
|
||||||
preparedBy: preparedBy.value,
|
preparedBy: preparedBy.value,
|
||||||
reviewedBy: reviewedBy.value,
|
reviewedBy: reviewedBy.value,
|
||||||
preparedCompany: preparedCompany.value,
|
preparedCompany: preparedCompany.value,
|
||||||
preparedDate: preparedDate.value,
|
preparedDate: preparedDate.value
|
||||||
overview: overview.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)
|
||||||
}
|
}
|
||||||
@@ -150,8 +131,6 @@ const loadFromIndexedDB = async () => {
|
|||||||
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
|
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
|
||||||
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
|
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
|
||||||
preparedDate.value = normalizeDateString(data.preparedDate) || getTodayDateString()
|
preparedDate.value = normalizeDateString(data.preparedDate) || getTodayDateString()
|
||||||
overview.value = typeof data.overview === 'string' ? data.overview : ''
|
|
||||||
desc.value = typeof data.desc === 'string' && data.desc.trim() ? data.desc : DEFAULT_DESC
|
|
||||||
syncPreparedDatePickerFromString()
|
syncPreparedDatePickerFromString()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -163,20 +142,15 @@ const loadFromIndexedDB = async () => {
|
|||||||
reviewedBy.value = ''
|
reviewedBy.value = ''
|
||||||
preparedCompany.value = ''
|
preparedCompany.value = ''
|
||||||
preparedDate.value = getTodayDateString()
|
preparedDate.value = getTodayDateString()
|
||||||
overview.value = ''
|
|
||||||
desc.value = DEFAULT_DESC
|
|
||||||
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 = ''
|
||||||
reviewedBy.value = ''
|
reviewedBy.value = ''
|
||||||
preparedCompany.value = ''
|
preparedCompany.value = ''
|
||||||
preparedDate.value = getTodayDateString()
|
preparedDate.value = getTodayDateString()
|
||||||
overview.value = ''
|
|
||||||
desc.value = DEFAULT_DESC
|
|
||||||
syncPreparedDatePickerFromString()
|
syncPreparedDatePickerFromString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,17 +171,12 @@ const handleProjectNameBlur = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[projectIndustry, projectName, preparedBy, reviewedBy, preparedCompany, preparedDate, overview, desc],
|
[projectIndustry, projectName, preparedBy, reviewedBy, preparedCompany, preparedDate],
|
||||||
schedulePersist
|
schedulePersist
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
await loadFromIndexedDB()
|
||||||
await waitForHydration('kv')
|
|
||||||
await loadFromIndexedDB()
|
|
||||||
} finally {
|
|
||||||
isBootstrapping.value = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -215,28 +184,21 @@ onMounted(async () => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div class="space-y-6 h-full">
|
<div class="space-y-6 h-full">
|
||||||
<div
|
<div
|
||||||
v-if="isBootstrapping"
|
v-if="!isProjectInitialized"
|
||||||
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">{{ t('xmInfo.fields.projectName') }}</label>
|
<label class="block text-sm font-medium text-foreground">项目名称</label>
|
||||||
<input
|
<input
|
||||||
v-model="projectName"
|
v-model="projectName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
:placeholder="t('xmInfo.defaultProjectName')"
|
placeholder="xxx造价咨询服务"
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
@@ -244,12 +206,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">{{ t('xmInfo.fields.projectIndustry') }}</label>
|
<label class="block text-sm font-medium text-foreground">工程行业</label>
|
||||||
<TooltipRoot>
|
<TooltipRoot>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('xmInfo.industryHintAria')"
|
aria-label="工程行业提示"
|
||||||
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" />
|
||||||
@@ -275,47 +237,39 @@ 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">{{ t('xmInfo.fields.preparedBy') }}</label>
|
<label class="block text-sm font-medium text-foreground">编制人</label>
|
||||||
<input
|
<input
|
||||||
v-model="preparedBy"
|
v-model="preparedBy"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('xmInfo.placeholders.preparedBy')"
|
placeholder="请输入编制人"
|
||||||
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">{{ t('xmInfo.fields.reviewedBy') }}</label>
|
<label class="block text-sm font-medium text-foreground">复核人</label>
|
||||||
<input
|
<input
|
||||||
v-model="reviewedBy"
|
v-model="reviewedBy"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('xmInfo.placeholders.reviewedBy')"
|
placeholder="请输入复核人"
|
||||||
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">{{ t('xmInfo.fields.preparedCompany') }}</label>
|
<label class="block text-sm font-medium text-foreground">编制单位</label>
|
||||||
<input
|
<input
|
||||||
v-model="preparedCompany"
|
v-model="preparedCompany"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('xmInfo.placeholders.preparedCompany')"
|
placeholder="请输入编制单位"
|
||||||
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">{{ t('xmInfo.fields.preparedDate') }}</label>
|
<label class="block text-sm font-medium text-foreground">编制日期</label>
|
||||||
<DatePickerRoot
|
<DatePickerRoot
|
||||||
locale="en-CA"
|
locale="en-CA"
|
||||||
:model-value="preparedDatePickerValue"
|
:model-value="preparedDatePickerValue"
|
||||||
@@ -411,12 +365,12 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</DatePickerCalendar>
|
</DatePickerCalendar>
|
||||||
<div class="mt-2 flex justify-end">
|
<div class="mt-2 flex justify-end">
|
||||||
<DatePickerClose as-child>
|
<DatePickerClose as-child>
|
||||||
<button
|
<button
|
||||||
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>
|
||||||
@@ -424,16 +378,6 @@ 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">{{ t('xmInfo.fields.desc') }}</label>
|
|
||||||
<textarea
|
|
||||||
v-model="desc"
|
|
||||||
rows="4"
|
|
||||||
class="mt-2 w-full rounded-lg border bg-background px-4 py-2 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import HourlyFeeGrid from '@/components/common/HourlyFeeGrid.vue'
|
||||||
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contractId: string
|
contractId: string
|
||||||
@@ -9,12 +8,11 @@ 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="t('hourlyFeeGrid.title')"
|
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"
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
<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 } 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/common/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 ? 'editable-cell-line 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="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
|
<AgGridVue :style="{ height: '100%' }" :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>
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<TypeLine
|
||||||
|
scene="xm-tab"
|
||||||
|
title=""
|
||||||
|
storage-key="project-active-cat"
|
||||||
|
default-category="scale-info"
|
||||||
|
:categories="xmCategories"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineAsyncComponent, markRaw } from 'vue'
|
||||||
|
import TypeLine from '@/layout/typeLine.vue'
|
||||||
|
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
|
||||||
|
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
||||||
|
const consultCategoryFactorView = markRaw(
|
||||||
|
defineAsyncComponent(() => import('@/components/views/XmConsultCategoryFactor.vue'))
|
||||||
|
)
|
||||||
|
const majorFactorView = markRaw(
|
||||||
|
defineAsyncComponent(() => import('@/components/views/XmMajorFactor.vue'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const xmCategories = [
|
||||||
|
{ 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
|
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
|
||||||
const DB_KEY = 'xm-info-v3'
|
const DB_KEY = 'xm-info-v3'
|
||||||
|
|
||||||
|
|
||||||
@@ -10,4 +10,4 @@ const DB_KEY = 'xm-info-v3'
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonAgGrid title="项目明细" :dbKey="DB_KEY" />
|
<CommonAgGrid title="项目明细" :dbKey="DB_KEY" />
|
||||||
</template>
|
</template>
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
|
||||||
|
|
||||||
interface HtBaseInfoState {
|
|
||||||
quality: string
|
|
||||||
duration: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const DEFAULT_QUALITY = t('htBaseInfo.defaultQuality')
|
|
||||||
const DEFAULT_DURATION = ''
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
contractId: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const zxFwPricingStore = useZxFwPricingStore()
|
|
||||||
const storageKey = () => `ht-base-info-${props.contractId}`
|
|
||||||
const quality = ref(DEFAULT_QUALITY)
|
|
||||||
const duration = ref('')
|
|
||||||
const lastSavedSnapshot = ref('')
|
|
||||||
|
|
||||||
const saveForm = (force = false) => {
|
|
||||||
const payload: HtBaseInfoState = {
|
|
||||||
quality: quality.value,
|
|
||||||
duration: duration.value
|
|
||||||
}
|
|
||||||
const snapshot = JSON.stringify(payload)
|
|
||||||
if (!force && snapshot === lastSavedSnapshot.value) return
|
|
||||||
zxFwPricingStore.setKeyState(storageKey(), payload)
|
|
||||||
lastSavedSnapshot.value = snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadForm = async () => {
|
|
||||||
const data = await zxFwPricingStore.loadKeyState<HtBaseInfoState>(storageKey())
|
|
||||||
const hasStoredValue = Boolean(
|
|
||||||
data && (Object.prototype.hasOwnProperty.call(data, 'quality') || Object.prototype.hasOwnProperty.call(data, 'duration'))
|
|
||||||
)
|
|
||||||
quality.value = typeof data?.quality === 'string' && data.quality ? data.quality : DEFAULT_QUALITY
|
|
||||||
duration.value = typeof data?.duration === 'string' ? data.duration : (hasStoredValue ? '' : DEFAULT_DURATION)
|
|
||||||
lastSavedSnapshot.value = JSON.stringify({ quality: quality.value, duration: duration.value })
|
|
||||||
if (!hasStoredValue) saveForm(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([quality, duration], () => {
|
|
||||||
saveForm()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadForm()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
saveForm(true)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
|
||||||
<div class="rounded-lg border bg-card p-5">
|
|
||||||
<div class="mb-4 border-b pb-3">
|
|
||||||
<h3 class="text-sm font-semibold text-foreground">{{ t('htBaseInfo.title') }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 gap-5">
|
|
||||||
<label class="space-y-1.5">
|
|
||||||
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.qualityLabel') }}</div>
|
|
||||||
<textarea
|
|
||||||
v-model="quality"
|
|
||||||
rows="3"
|
|
||||||
:placeholder="t('htBaseInfo.qualityPlaceholder')"
|
|
||||||
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="space-y-1.5">
|
|
||||||
<div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.durationLabel') }}</div>
|
|
||||||
<textarea
|
|
||||||
v-model="duration"
|
|
||||||
rows="3"
|
|
||||||
:placeholder="t('htBaseInfo.durationPlaceholder')"
|
|
||||||
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
|
||||||
import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowDataUpdatedEvent } from 'ag-grid-community'
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
|
||||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
|
||||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
|
||||||
import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal'
|
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
|
||||||
import { additionalWorkList, reserveList } from '@/sql'
|
|
||||||
|
|
||||||
type SummaryRowType = 'service' | 'additional' | 'reserve' | 'total'
|
|
||||||
|
|
||||||
interface SummaryRow {
|
|
||||||
id: string
|
|
||||||
rowType: SummaryRowType
|
|
||||||
code: string | { richText?: Array<{ text?: string; font?: { italic?: boolean; vertAlign?: string } }> }
|
|
||||||
name: string
|
|
||||||
investScale: number | null
|
|
||||||
landScale: number | null
|
|
||||||
workload: number | null
|
|
||||||
hourly: number | null
|
|
||||||
subtotal: number | null
|
|
||||||
finalFee: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RateMethodStateLike {
|
|
||||||
rate?: unknown
|
|
||||||
budgetFee?: unknown
|
|
||||||
}
|
|
||||||
interface HourlyMethodRowLike {
|
|
||||||
serviceBudget?: unknown
|
|
||||||
adoptedBudgetUnitPrice?: unknown
|
|
||||||
personnelCount?: unknown
|
|
||||||
workdayCount?: unknown
|
|
||||||
}
|
|
||||||
interface HourlyMethodStateLike {
|
|
||||||
detailRows?: HourlyMethodRowLike[]
|
|
||||||
}
|
|
||||||
interface QuantityMethodRowLike {
|
|
||||||
id?: unknown
|
|
||||||
budgetFee?: unknown
|
|
||||||
quantity?: unknown
|
|
||||||
unitPrice?: unknown
|
|
||||||
}
|
|
||||||
interface QuantityMethodStateLike {
|
|
||||||
detailRows?: QuantityMethodRowLike[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
contractId: string
|
|
||||||
}>()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const zxFwPricingStore = useZxFwPricingStore()
|
|
||||||
const gridApi = shallowRef<GridApi<SummaryRow> | null>(null)
|
|
||||||
const rowData = ref<SummaryRow[]>([])
|
|
||||||
const explanationText = ref('')
|
|
||||||
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对数值数组求和(保留3位小数)
|
|
||||||
* @param values 数值数组(可包含 null/undefined)
|
|
||||||
* @returns 求和结果(无有效值时返回 null)
|
|
||||||
*/
|
|
||||||
const sum3 = (values: Array<number | null | undefined>) => {
|
|
||||||
const valid = values.filter((v): v is number => toFiniteNumberOrNull(v) != null)
|
|
||||||
if (valid.length === 0) return null
|
|
||||||
return roundTo(valid.reduce((a, b) => a + b, 0), 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算时工法的总费用
|
|
||||||
* 遍历时工法明细行,优先使用 serviceBudget,否则通过 adoptedBudgetUnitPrice × personnelCount × workdayCount 计算
|
|
||||||
* @param state 时工法状态对象
|
|
||||||
* @returns 总费用(保留3位小数,无有效值时返回 null)
|
|
||||||
*/
|
|
||||||
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
|
||||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
|
||||||
if (rows.length === 0) return null
|
|
||||||
let total = 0
|
|
||||||
let hasValid = false
|
|
||||||
for (const row of rows) {
|
|
||||||
const rowBudget = toFiniteNumberOrNull(row?.serviceBudget)
|
|
||||||
if (rowBudget != null) {
|
|
||||||
total += rowBudget
|
|
||||||
hasValid = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
|
|
||||||
const personnel = toFiniteNumberOrNull(row?.personnelCount)
|
|
||||||
const workday = toFiniteNumberOrNull(row?.workdayCount)
|
|
||||||
if (adopted == null || personnel == null || workday == null) continue
|
|
||||||
total += adopted * personnel * workday
|
|
||||||
hasValid = true
|
|
||||||
}
|
|
||||||
return hasValid ? roundTo(total, 3) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算工程量清单法的总费用
|
|
||||||
* 遍历工程量清单明细行,优先使用 budgetFee,否则通过 quantity × unitPrice 计算
|
|
||||||
* 跳过固定小计行(fee-subtotal-fixed)
|
|
||||||
* @param state 工程量清单法状态对象
|
|
||||||
* @returns 总费用(保留3位小数,无有效值时返回 null)
|
|
||||||
*/
|
|
||||||
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
|
||||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
|
||||||
if (rows.length === 0) return null
|
|
||||||
let total = 0
|
|
||||||
let hasValid = false
|
|
||||||
for (const row of rows) {
|
|
||||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
|
||||||
const budget = toFiniteNumberOrNull(row?.budgetFee)
|
|
||||||
if (budget != null) {
|
|
||||||
total += budget
|
|
||||||
hasValid = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const quantity = toFiniteNumberOrNull(row?.quantity)
|
|
||||||
const unitPrice = toFiniteNumberOrNull(row?.unitPrice)
|
|
||||||
if (quantity == null || unitPrice == null) continue
|
|
||||||
total += quantity * unitPrice
|
|
||||||
hasValid = true
|
|
||||||
}
|
|
||||||
return hasValid ? roundTo(total, 3) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载合同段某行的三种计费方式(费率法、时工法、工程量清单法)并汇总
|
|
||||||
* @param mainStorageKey 主存储键(如 htExtraFee-{contractId}-additional-work)
|
|
||||||
* @param rowId 行ID
|
|
||||||
* @returns 包含subtotal(小计)、m0(费率法详情)、m4(时工法详情)、m5(工程量清单法详情)的对象
|
|
||||||
*/
|
|
||||||
const loadHtMethodSummaryByRow = async (mainStorageKey: string, rowId: string): Promise<{
|
|
||||||
subtotal: number | null
|
|
||||||
m0: { coe: string; fee: number } | null
|
|
||||||
m4: { fee: number } | null
|
|
||||||
m5: { fee: number } | null
|
|
||||||
}> => {
|
|
||||||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
|
||||||
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
|
||||||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
|
||||||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
|
||||||
])
|
|
||||||
const rateFee = toFiniteNumberOrNull(rateState?.budgetFee)
|
|
||||||
const rateValue = toFiniteNumberOrNull(rateState?.rate)
|
|
||||||
const hourlyFee = sumHourlyMethodFee(hourlyState)
|
|
||||||
const quantityFee = sumQuantityMethodFee(quantityState)
|
|
||||||
const subtotal = sum3([rateFee, hourlyFee, quantityFee])
|
|
||||||
return {
|
|
||||||
subtotal,
|
|
||||||
m0: rateFee != null
|
|
||||||
? {
|
|
||||||
coe: rateValue == null ? '--' : String(rateValue),
|
|
||||||
fee: roundTo(rateFee, 2)
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
m4: hourlyFee != null ? { fee: roundTo(hourlyFee, 2) } : null,
|
|
||||||
m5: quantityFee != null ? { fee: roundTo(quantityFee, 2) } : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建附加工作或预备费的汇总行数据
|
|
||||||
* @param rowType 行类型('additional' 附加工作 或 'reserve' 预备费)
|
|
||||||
* @param list 费用项目列表(包含 id、name、code)
|
|
||||||
* @returns 包含 rows(汇总行数组)和 explainLines(说明文本数组)的对象
|
|
||||||
*/
|
|
||||||
const buildFeeRows = async (
|
|
||||||
rowType: 'additional' | 'reserve',
|
|
||||||
list: Array<{ id: string | number; name: string; code: unknown }>
|
|
||||||
): Promise<{ rows: SummaryRow[]; explainLines: string[] }> => {
|
|
||||||
const mainStorageKey = `htExtraFee-${props.contractId}-${rowType === 'additional' ? 'additional-work' : 'reserve'}`
|
|
||||||
await zxFwPricingStore.loadHtFeeMainState(mainStorageKey)
|
|
||||||
const tuples = await Promise.all(
|
|
||||||
list.map(async item => {
|
|
||||||
const summary = await loadHtMethodSummaryByRow(mainStorageKey, String(item.id))
|
|
||||||
const lineParts: string[] = []
|
|
||||||
if (summary.m0) {
|
|
||||||
lineParts.push(t('htSummary.explainByRate', { rate: summary.m0.coe, fee: summary.m0.fee }))
|
|
||||||
}
|
|
||||||
if (summary.m4) {
|
|
||||||
lineParts.push(t('htSummary.explainByHourly', { fee: summary.m4.fee }))
|
|
||||||
}
|
|
||||||
if (summary.m5) {
|
|
||||||
lineParts.push(t('htSummary.explainByQuantity', { fee: summary.m5.fee }))
|
|
||||||
}
|
|
||||||
const linePrefix = rowType === 'additional' ? t('htSummary.additionalPrefix') : t('htSummary.reservePrefix')
|
|
||||||
const explainLine = lineParts.length > 0 ? `${linePrefix}-${item.name}:${lineParts.join(';')}` : ''
|
|
||||||
const row: SummaryRow = {
|
|
||||||
id: `${rowType}-${item.id}`,
|
|
||||||
rowType,
|
|
||||||
code: item.code as SummaryRow['code'],
|
|
||||||
name: item.name,
|
|
||||||
investScale: null,
|
|
||||||
landScale: null,
|
|
||||||
workload: null,
|
|
||||||
hourly: null,
|
|
||||||
subtotal: summary.subtotal,
|
|
||||||
finalFee: summary.subtotal
|
|
||||||
}
|
|
||||||
return { row, explainLine }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const rows = tuples.map(item => item.row).filter(row => row.subtotal != null)
|
|
||||||
const explainLines = tuples
|
|
||||||
.filter(item => item.row.subtotal != null && item.explainLine)
|
|
||||||
.map(item => item.explainLine)
|
|
||||||
return { rows, explainLines }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建咨询服务汇总行数据
|
|
||||||
* 从合同状态中筛选已选中的服务项,转换为 SummaryRow 格式
|
|
||||||
* @returns 咨询服务汇总行数组
|
|
||||||
*/
|
|
||||||
const buildServiceRows = (): SummaryRow[] => {
|
|
||||||
const contractState = zxFwPricingStore.getContractState(props.contractId)
|
|
||||||
const selectedSet = new Set((contractState?.selectedIds || []).map(id => String(id)))
|
|
||||||
const rows = Array.isArray(contractState?.detailRows) ? contractState!.detailRows : []
|
|
||||||
return rows
|
|
||||||
.filter(row => String(row.id) !== 'fixed-budget-c' && selectedSet.has(String(row.id)))
|
|
||||||
.map(row => ({
|
|
||||||
id: `service-${row.id}`,
|
|
||||||
rowType: 'service' as const,
|
|
||||||
code: row.code || '',
|
|
||||||
name: row.name || '',
|
|
||||||
investScale: toFiniteNumberOrNull(row.investScale),
|
|
||||||
landScale: toFiniteNumberOrNull(row.landScale),
|
|
||||||
workload: toFiniteNumberOrNull(row.workload),
|
|
||||||
hourly: toFiniteNumberOrNull(row.hourly),
|
|
||||||
subtotal: toFiniteNumberOrNull(row.subtotal),
|
|
||||||
finalFee: toFiniteNumberOrNull((row as { finalFee?: unknown }).finalFee) ?? toFiniteNumberOrNull(row.subtotal)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重新加载所有汇总行数据
|
|
||||||
* 包括:咨询服务、附加工作、预备费
|
|
||||||
* 同时生成说明文本
|
|
||||||
*/
|
|
||||||
const reloadRows = async () => {
|
|
||||||
await zxFwPricingStore.loadContract(props.contractId)
|
|
||||||
const [additionalResult, reserveResult] = await Promise.all([
|
|
||||||
buildFeeRows(
|
|
||||||
'additional',
|
|
||||||
additionalWorkList.map(item => ({ id: item.id, name: item.name, code: item.code }))
|
|
||||||
),
|
|
||||||
buildFeeRows(
|
|
||||||
'reserve',
|
|
||||||
reserveList.map(item => ({ id: item.id, name: item.name, code: item.code }))
|
|
||||||
)
|
|
||||||
])
|
|
||||||
rowData.value = [...buildServiceRows(), ...additionalResult.rows, ...reserveResult.rows]
|
|
||||||
const lines = [...additionalResult.explainLines, ...reserveResult.explainLines]
|
|
||||||
explanationText.value = lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 延迟重新加载汇总数据(防抖 80ms)
|
|
||||||
* 避免频繁触发数据加载
|
|
||||||
*/
|
|
||||||
const scheduleReload = () => {
|
|
||||||
if (reloadTimer) clearTimeout(reloadTimer)
|
|
||||||
reloadTimer = setTimeout(() => {
|
|
||||||
void reloadRows()
|
|
||||||
}, 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新签名(用于 watch 监听数据变化)
|
|
||||||
* 监听合同数据、附加工作、预备费的主状态和方法状态变化
|
|
||||||
*/
|
|
||||||
const refreshSignature = computed(() => {
|
|
||||||
const additionalKey = `htExtraFee-${props.contractId}-additional-work`
|
|
||||||
const reserveKey = `htExtraFee-${props.contractId}-reserve`
|
|
||||||
return JSON.stringify({
|
|
||||||
contract: zxFwPricingStore.contracts[props.contractId] || null,
|
|
||||||
addMain: zxFwPricingStore.htFeeMainStates[additionalKey] || null,
|
|
||||||
reserveMain: zxFwPricingStore.htFeeMainStates[reserveKey] || null,
|
|
||||||
addMethods: zxFwPricingStore.htFeeMethodStates[additionalKey] || null,
|
|
||||||
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveKey] || null
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算总计行数据
|
|
||||||
* 对所有汇总行的各字段进行求和
|
|
||||||
*/
|
|
||||||
const totalRow = computed<SummaryRow>(() => {
|
|
||||||
const sumField = (pick: (row: SummaryRow) => number | null | undefined) =>
|
|
||||||
sum3(rowData.value.map(pick))
|
|
||||||
return {
|
|
||||||
id: 'summary-total-row',
|
|
||||||
rowType: 'total',
|
|
||||||
code: '',
|
|
||||||
name: t('htSummary.total'),
|
|
||||||
investScale: sumField(row => row.investScale),
|
|
||||||
landScale: sumField(row => row.landScale),
|
|
||||||
workload: sumField(row => row.workload),
|
|
||||||
hourly: sumField(row => row.hourly),
|
|
||||||
subtotal: null,
|
|
||||||
finalFee: sumField(row => row.finalFee)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const RichCodeRenderer = defineComponent({
|
|
||||||
name: 'RichCodeRenderer',
|
|
||||||
props: {
|
|
||||||
params: {
|
|
||||||
type: Object as PropType<ICellRendererParams<SummaryRow>>,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
return () => {
|
|
||||||
if (props.params.data?.rowType === 'total') {
|
|
||||||
return h('span', props.params.data.name || t('htSummary.total'))
|
|
||||||
}
|
|
||||||
const value = props.params.value as SummaryRow['code']
|
|
||||||
if (!value || typeof value === 'string') {
|
|
||||||
return h('span', value || '')
|
|
||||||
}
|
|
||||||
const runs = Array.isArray(value.richText) ? value.richText : []
|
|
||||||
return h(
|
|
||||||
'span',
|
|
||||||
{ class: 'inline-flex items-baseline gap-[1px]' },
|
|
||||||
runs.map((run, idx) =>
|
|
||||||
h(
|
|
||||||
'span',
|
|
||||||
{
|
|
||||||
key: `${idx}-${run.text || ''}`,
|
|
||||||
style: {
|
|
||||||
fontStyle: run?.font?.italic ? 'italic' : 'normal',
|
|
||||||
verticalAlign: run?.font?.vertAlign === 'subscript' ? 'sub' : run?.font?.vertAlign === 'superscript' ? 'super' : 'baseline',
|
|
||||||
fontSize: run?.font?.vertAlign ? '0.85em' : '1em'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
run?.text || ''
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const columnDefs: ColDef<SummaryRow>[] = [
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.code'),
|
|
||||||
field: 'code',
|
|
||||||
minWidth: 90,
|
|
||||||
maxWidth: 140,
|
|
||||||
colSpan: params => (params.data?.rowType === 'total' ? 2 : 1),
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (params.data?.rowType === 'total') return params.data.name || t('htSummary.total')
|
|
||||||
return typeof params.value === 'string' ? params.value : ''
|
|
||||||
},
|
|
||||||
cellRenderer: RichCodeRenderer
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.name'),
|
|
||||||
field: 'name',
|
|
||||||
minWidth: 220,
|
|
||||||
flex: 2,
|
|
||||||
wrapText: true,
|
|
||||||
autoHeight: true,
|
|
||||||
cellStyle: { 'line-height': 1.6 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.investScale'),
|
|
||||||
field: 'investScale',
|
|
||||||
minWidth: 120,
|
|
||||||
flex: 1.2,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
colSpan: params => {
|
|
||||||
if (!params.data) return 1
|
|
||||||
if (params.data.rowType === 'total') return 5
|
|
||||||
if (params.data.rowType === 'additional' || params.data.rowType === 'reserve') return 5
|
|
||||||
return 1
|
|
||||||
},
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (params.data?.rowType === 'total') return ''
|
|
||||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.landScale'),
|
|
||||||
field: 'landScale',
|
|
||||||
minWidth: 120,
|
|
||||||
flex: 1.2,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (params.data?.rowType === 'total') return ''
|
|
||||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.workload'),
|
|
||||||
field: 'workload',
|
|
||||||
minWidth: 110,
|
|
||||||
flex: 1.2,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (params.data?.rowType === 'total') return ''
|
|
||||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.hourly'),
|
|
||||||
field: 'hourly',
|
|
||||||
minWidth: 110,
|
|
||||||
flex: 1.2,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (params.data?.rowType === 'total') return ''
|
|
||||||
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.subtotal'),
|
|
||||||
field: 'subtotal',
|
|
||||||
minWidth: 120,
|
|
||||||
flex: 1.2,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('htSummary.columns.finalFee'),
|
|
||||||
field: 'finalFee',
|
|
||||||
minWidth: 120,
|
|
||||||
flex: 1.2,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
|
||||||
|
|
||||||
const summaryGridOptions: GridOptions<SummaryRow> = {
|
|
||||||
...gridOptions,
|
|
||||||
treeData: false,
|
|
||||||
getDataPath: undefined,
|
|
||||||
domLayout: 'autoHeight',
|
|
||||||
suppressNoRowsOverlay: true,
|
|
||||||
rowSelection: {
|
|
||||||
mode: 'singleRow',
|
|
||||||
checkboxes: false,
|
|
||||||
enableClickSelection: false
|
|
||||||
},
|
|
||||||
getRowId: params => params.data.id,
|
|
||||||
getRowClass: params => (params.data?.rowType === 'additional' || params.data?.rowType === 'reserve' ? 'ht-summary-fee-row' : '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AG Grid 准备就绪回调
|
|
||||||
* 初始化 gridApi 并同步自动行高
|
|
||||||
* @param event Grid 准备就绪事件
|
|
||||||
*/
|
|
||||||
const onGridReady = (event: GridReadyEvent<SummaryRow>) => {
|
|
||||||
gridApi.value = event.api
|
|
||||||
void syncAutoRowHeights()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查 Grid API 是否可用
|
|
||||||
* @param api Grid API 实例
|
|
||||||
* @returns API 是否有效
|
|
||||||
*/
|
|
||||||
const isGridApiAlive = (api: GridApi<SummaryRow> | null | undefined): api is GridApi<SummaryRow> =>
|
|
||||||
Boolean(api && !api.isDestroyed?.())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步自动行高
|
|
||||||
* 触发 AG Grid 重新计算行高并刷新单元格
|
|
||||||
*/
|
|
||||||
const syncAutoRowHeights = async () => {
|
|
||||||
await nextTick()
|
|
||||||
const api = gridApi.value
|
|
||||||
if (!isGridApiAlive(api)) return
|
|
||||||
api.onRowHeightChanged()
|
|
||||||
api.refreshCells({ force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首次数据渲染完成回调
|
|
||||||
* 同步自动行高以确保显示正确
|
|
||||||
* @param _event 首次数据渲染事件
|
|
||||||
*/
|
|
||||||
const onFirstDataRendered = (_event: FirstDataRenderedEvent<SummaryRow>) => {
|
|
||||||
void syncAutoRowHeights()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 行数据更新回调
|
|
||||||
* 同步自动行高以适应新数据
|
|
||||||
* @param _event 行数据更新事件
|
|
||||||
*/
|
|
||||||
const onRowDataUpdated = (_event: RowDataUpdatedEvent<SummaryRow>) => {
|
|
||||||
void syncAutoRowHeights()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(refreshSignature, (next, prev) => {
|
|
||||||
if (next === prev) return
|
|
||||||
scheduleReload()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => rowData.value.length,
|
|
||||||
() => {
|
|
||||||
void syncAutoRowHeights()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void reloadRows()
|
|
||||||
})
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
void reloadRows()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (isGridApiAlive(gridApi.value)) {
|
|
||||||
gridApi.value.stopEditing()
|
|
||||||
}
|
|
||||||
gridApi.value = null
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="rounded-lg border bg-card xmMx flex flex-col overflow-hidden">
|
|
||||||
<div class="flex items-center justify-between border-b px-3 py-2">
|
|
||||||
<h3 class="text-xs font-semibold text-foreground leading-none">
|
|
||||||
{{ t('htSummary.title') }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="ag-theme-quartz w-full">
|
|
||||||
<AgGridVue
|
|
||||||
:style="{ width: '100%' }"
|
|
||||||
:rowData="rowData"
|
|
||||||
:pinnedBottomRowData="[totalRow]"
|
|
||||||
:columnDefs="gridColumnDefs"
|
|
||||||
:gridOptions="summaryGridOptions"
|
|
||||||
:theme="myTheme"
|
|
||||||
:animateRows="true"
|
|
||||||
:localeText="AG_GRID_LOCALE_CN"
|
|
||||||
@grid-ready="onGridReady"
|
|
||||||
@first-data-rendered="onFirstDataRendered"
|
|
||||||
@row-data-updated="onRowDataUpdated"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="rounded-lg border bg-card p-3 space-y-2">
|
|
||||||
<div class="text-xs font-semibold text-foreground">{{ t('htSummary.remark') }}</div>
|
|
||||||
<textarea
|
|
||||||
:value="explanationText"
|
|
||||||
rows="3"
|
|
||||||
:placeholder="t('htSummary.placeholder')"
|
|
||||||
readonly
|
|
||||||
class="w-full rounded-md border bg-muted/40 px-3 py-2 text-sm text-foreground outline-none"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.ag-layout-auto-height .ag-center-cols-viewport),
|
|
||||||
:deep(.ag-layout-auto-height .ag-center-cols-container) {
|
|
||||||
min-height: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ht-summary-fee-row .ag-cell) {
|
|
||||||
background: color-mix(in oklab, var(--muted) 45%, transparent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
export interface ContractItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
order: number
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
|
|
||||||
list.map((item, index) => ({
|
|
||||||
...item,
|
|
||||||
order: index,
|
|
||||||
createdAt: item.createdAt || new Date().toISOString()
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const formatDateTime = (value: string) => {
|
|
||||||
if (!value) return '-'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return '-'
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
|
|
||||||
if (!Array.isArray(value)) return []
|
|
||||||
return value
|
|
||||||
.filter(item => item && typeof item === 'object')
|
|
||||||
.map((item, index) => {
|
|
||||||
const row = item as Partial<ContractItem>
|
|
||||||
const name = typeof row.name === 'string' ? row.name.trim() : ''
|
|
||||||
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
|
|
||||||
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: name || `导入合同段-${index + 1}`,
|
|
||||||
order: index,
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isEntryRelatedToAnyContract = (
|
|
||||||
key: string,
|
|
||||||
contractIds: Set<string>,
|
|
||||||
matcher: (entryKey: string, contractId: string) => boolean
|
|
||||||
) => {
|
|
||||||
for (const contractId of contractIds) {
|
|
||||||
if (matcher(key, contractId)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
scroll-snap-type: y mandatory;
|
|
||||||
padding-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
|
|
||||||
scroll-snap-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
|
|
||||||
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
|
|
||||||
will-change: transform, opacity;
|
|
||||||
transform: translateZ(0);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card {
|
|
||||||
will-change: transform, opacity;
|
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
isolation: isolate;
|
|
||||||
overflow: visible;
|
|
||||||
z-index: 0;
|
|
||||||
transition:
|
|
||||||
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
|
||||||
box-shadow 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
|
||||||
border-color 180ms ease;
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px hsl(var(--foreground) / 0.04),
|
|
||||||
0 6px 16px hsl(var(--foreground) / 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -4px;
|
|
||||||
border-radius: inherit;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
130deg,
|
|
||||||
hsl(var(--primary) / 0.42) 0%,
|
|
||||||
hsl(var(--primary) / 0.22) 36%,
|
|
||||||
hsl(var(--foreground) / 0.09) 70%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 6px;
|
|
||||||
right: 6px;
|
|
||||||
bottom: -30px;
|
|
||||||
height: 52px;
|
|
||||||
border-radius: 999px;
|
|
||||||
pointer-events: none;
|
|
||||||
background:
|
|
||||||
radial-gradient(
|
|
||||||
ellipse at center,
|
|
||||||
hsl(var(--primary) / 0.42) 0%,
|
|
||||||
hsl(var(--primary) / 0.24) 34%,
|
|
||||||
hsl(var(--foreground) / 0.20) 58%,
|
|
||||||
transparent 86%
|
|
||||||
);
|
|
||||||
filter: blur(18px);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(4px);
|
|
||||||
transition:
|
|
||||||
opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
|
||||||
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card:hover {
|
|
||||||
transform: translate3d(0, -5px, 0);
|
|
||||||
z-index: 14;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1.5px hsl(var(--primary) / 0.62),
|
|
||||||
0 0 28px hsl(var(--primary) / 0.34),
|
|
||||||
0 0 56px hsl(var(--primary) / 0.22),
|
|
||||||
0 16px 34px hsl(var(--foreground) / 0.22),
|
|
||||||
0 32px 60px hsl(var(--foreground) / 0.18);
|
|
||||||
border-color: hsl(var(--primary) / 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card:hover::after {
|
|
||||||
opacity: 0.95;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card:active {
|
|
||||||
transform: translate3d(0, -2px, 0);
|
|
||||||
box-shadow:
|
|
||||||
0 5px 12px hsl(var(--foreground) / 0.10),
|
|
||||||
0 10px 20px hsl(var(--foreground) / 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--ready {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--enter {
|
|
||||||
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
|
||||||
animation-delay: var(--ht-card-enter-delay, 0ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--selecting {
|
|
||||||
transform-origin: 50% 100%;
|
|
||||||
animation: ht-card-select-wave 2200ms linear infinite both;
|
|
||||||
animation-delay: var(--ht-card-select-delay, 0ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--selecting:hover {
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--selecting.ht-contract-card--selected {
|
|
||||||
animation: none;
|
|
||||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--selected {
|
|
||||||
border-color: hsl(var(--primary));
|
|
||||||
transform: translate3d(0, -4px, 0);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px hsl(var(--primary) / 0.34),
|
|
||||||
0 12px 24px hsl(var(--primary) / 0.18),
|
|
||||||
0 22px 36px hsl(var(--foreground) / 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--selected::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card--selected::after {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ht-card-slide-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate3d(44px, 0, 0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ht-card-select-wave {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
|
||||||
}
|
|
||||||
11% {
|
|
||||||
transform: translate3d(-0.4px, 0, 0) rotate(-0.7deg);
|
|
||||||
}
|
|
||||||
22% {
|
|
||||||
transform: translate3d(-0.9px, 0, 0) rotate(-1.6deg);
|
|
||||||
}
|
|
||||||
34% {
|
|
||||||
transform: translate3d(-1.2px, 0, 0) rotate(-2.3deg);
|
|
||||||
}
|
|
||||||
48% {
|
|
||||||
transform: translate3d(-0.2px, 0, 0) rotate(-0.4deg);
|
|
||||||
}
|
|
||||||
62% {
|
|
||||||
transform: translate3d(0.8px, 0, 0) rotate(1.5deg);
|
|
||||||
}
|
|
||||||
76% {
|
|
||||||
transform: translate3d(1.25px, 0, 0) rotate(2.35deg);
|
|
||||||
}
|
|
||||||
88% {
|
|
||||||
transform: translate3d(0.35px, 0, 0) rotate(0.65deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.ht-contract-card--enter,
|
|
||||||
.ht-contract-card--selecting {
|
|
||||||
animation: none;
|
|
||||||
opacity: 1;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card,
|
|
||||||
.ht-contract-card:hover,
|
|
||||||
.ht-contract-card:active,
|
|
||||||
.ht-contract-card--selected {
|
|
||||||
transition: none;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-contract-card::before,
|
|
||||||
.ht-contract-card::after {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import {
|
|
||||||
cloneJson,
|
|
||||||
isContractRelatedForageKey,
|
|
||||||
isContractRelatedKeyedStateKey,
|
|
||||||
isRecord,
|
|
||||||
SERVICE_PRICING_METHODS
|
|
||||||
} from '@/lib/contractSegment'
|
|
||||||
|
|
||||||
type AnyRecord = Record<string, unknown>
|
|
||||||
|
|
||||||
export interface KvStoreLike {
|
|
||||||
keys: () => Promise<string[]>
|
|
||||||
getItem: (key: string) => Promise<unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ZxFwPricingStoreLike {
|
|
||||||
contracts: Record<string, unknown>
|
|
||||||
servicePricingStates: Record<string, unknown>
|
|
||||||
htFeeMainStates: Record<string, unknown>
|
|
||||||
htFeeMethodStates: Record<string, unknown>
|
|
||||||
keyedStates: Record<string, unknown>
|
|
||||||
loadContract: (...args: any[]) => Promise<unknown>
|
|
||||||
getContractState: (...args: any[]) => unknown
|
|
||||||
setContractState: (...args: any[]) => Promise<unknown>
|
|
||||||
setServicePricingMethodState: (...args: any[]) => unknown
|
|
||||||
setHtFeeMainState: (...args: any[]) => unknown
|
|
||||||
setHtFeeMethodState: (...args: any[]) => unknown
|
|
||||||
setKeyState: (...args: any[]) => unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildContractPiniaPayload = async (
|
|
||||||
store: ZxFwPricingStoreLike,
|
|
||||||
contractIds: string[]
|
|
||||||
) => {
|
|
||||||
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
|
|
||||||
const payload = {
|
|
||||||
contracts: {} as AnyRecord,
|
|
||||||
servicePricingStates: {} as AnyRecord,
|
|
||||||
htFeeMainStates: {} as AnyRecord,
|
|
||||||
htFeeMethodStates: {} as AnyRecord
|
|
||||||
}
|
|
||||||
if (idSet.size === 0) return payload
|
|
||||||
|
|
||||||
await Promise.all(Array.from(idSet).map(id => store.loadContract(id)))
|
|
||||||
|
|
||||||
for (const contractId of idSet) {
|
|
||||||
const contractState = store.getContractState(contractId)
|
|
||||||
if (contractState) {
|
|
||||||
payload.contracts[contractId] = cloneJson(contractState)
|
|
||||||
}
|
|
||||||
|
|
||||||
const servicePricingState = store.servicePricingStates[contractId]
|
|
||||||
if (isRecord(servicePricingState)) {
|
|
||||||
payload.servicePricingStates[contractId] = cloneJson(servicePricingState)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainPrefix = `htExtraFee-${contractId}-`
|
|
||||||
for (const [mainKey, mainState] of Object.entries(store.htFeeMainStates)) {
|
|
||||||
if (!mainKey.startsWith(mainPrefix)) continue
|
|
||||||
payload.htFeeMainStates[mainKey] = cloneJson(mainState)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [mainKey, methodState] of Object.entries(store.htFeeMethodStates)) {
|
|
||||||
if (!mainKey.startsWith(mainPrefix)) continue
|
|
||||||
payload.htFeeMethodStates[mainKey] = cloneJson(methodState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
export const applyImportedContractPiniaPayload = async (
|
|
||||||
store: ZxFwPricingStoreLike,
|
|
||||||
piniaPayload: unknown,
|
|
||||||
oldToNewIdMap: Map<string, string>
|
|
||||||
) => {
|
|
||||||
if (!isRecord(piniaPayload)) return
|
|
||||||
const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null
|
|
||||||
if (!zxFwPayload) return
|
|
||||||
|
|
||||||
const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {}
|
|
||||||
const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {}
|
|
||||||
const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {}
|
|
||||||
const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {}
|
|
||||||
|
|
||||||
for (const [oldId, newId] of oldToNewIdMap.entries()) {
|
|
||||||
const rawContractState = contractsMap[oldId]
|
|
||||||
if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) {
|
|
||||||
await store.setContractState(newId, rawContractState as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawServicePricingByService = servicePricingStatesMap[oldId]
|
|
||||||
if (isRecord(rawServicePricingByService)) {
|
|
||||||
for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) {
|
|
||||||
if (!isRecord(rawServiceMethods)) continue
|
|
||||||
for (const method of SERVICE_PRICING_METHODS) {
|
|
||||||
const methodState = rawServiceMethods[method]
|
|
||||||
if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue
|
|
||||||
store.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldMainPrefix = `htExtraFee-${oldId}-`
|
|
||||||
const newMainPrefix = `htExtraFee-${newId}-`
|
|
||||||
for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) {
|
|
||||||
if (!oldMainKey.startsWith(oldMainPrefix)) continue
|
|
||||||
if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue
|
|
||||||
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
|
|
||||||
store.setHtFeeMainState(newMainKey, rawMainState as any, { force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) {
|
|
||||||
if (!oldMainKey.startsWith(oldMainPrefix)) continue
|
|
||||||
if (!isRecord(rawByRow)) continue
|
|
||||||
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
|
|
||||||
for (const [rowId, rawByMethod] of Object.entries(rawByRow)) {
|
|
||||||
if (!isRecord(rawByMethod)) continue
|
|
||||||
const ratePayload = rawByMethod['rate-fee']
|
|
||||||
const hourlyPayload = rawByMethod['hourly-fee']
|
|
||||||
const quantityPayload = rawByMethod['quantity-unit-price-fee']
|
|
||||||
if (ratePayload != null) {
|
|
||||||
store.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true })
|
|
||||||
}
|
|
||||||
if (hourlyPayload != null) {
|
|
||||||
store.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true })
|
|
||||||
}
|
|
||||||
if (quantityPayload != null) {
|
|
||||||
store.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readContractRelatedForageEntries = async (
|
|
||||||
kvStore: KvStoreLike,
|
|
||||||
contractIds: string[]
|
|
||||||
) => {
|
|
||||||
const keys = await kvStore.keys()
|
|
||||||
const idSet = new Set(contractIds)
|
|
||||||
const targetKeys = keys.filter(key => {
|
|
||||||
for (const id of idSet) {
|
|
||||||
if (isContractRelatedForageKey(key, id)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
return Promise.all(
|
|
||||||
targetKeys.map(async key => ({
|
|
||||||
key,
|
|
||||||
value: await kvStore.getItem(key)
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readContractRelatedKeyedEntries = (
|
|
||||||
store: Pick<ZxFwPricingStoreLike, 'keyedStates'>,
|
|
||||||
contractIds: string[]
|
|
||||||
) => {
|
|
||||||
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
|
|
||||||
return Object.entries(store.keyedStates)
|
|
||||||
.filter(([key]) => {
|
|
||||||
for (const id of idSet) {
|
|
||||||
if (isContractRelatedKeyedStateKey(key, id)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
.map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
value: cloneJson(value)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export interface XmBaseInfoState {
|
|
||||||
projectIndustry?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface XmScaleState {
|
|
||||||
detailRows?: unknown[]
|
|
||||||
roughCalcEnabled?: boolean
|
|
||||||
totalAmount?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HtFeeMainRowLike {
|
|
||||||
id?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateMethodStateLike {
|
|
||||||
budgetFee?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HourlyMethodRowLike {
|
|
||||||
serviceBudget?: unknown
|
|
||||||
adoptedBudgetUnitPrice?: unknown
|
|
||||||
personnelCount?: unknown
|
|
||||||
workdayCount?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HourlyMethodStateLike {
|
|
||||||
detailRows?: HourlyMethodRowLike[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuantityMethodRowLike {
|
|
||||||
id?: unknown
|
|
||||||
budgetFee?: unknown
|
|
||||||
quantity?: unknown
|
|
||||||
unitPrice?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuantityMethodStateLike {
|
|
||||||
detailRows?: QuantityMethodRowLike[]
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
|
||||||
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
|
||||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
|
||||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
|
||||||
import { createScaleAutoGroupColumn } from '@/lib/pricingScaleColumns'
|
|
||||||
import { buildScaleDetailDict, buildScaleIdLabelMap } from '@/lib/pricingScaleDict'
|
|
||||||
import { parseProjectIndexFromPathKey } from '@/lib/pricingScaleLink'
|
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
|
||||||
import { getMajorDictEntries } from '@/sql'
|
|
||||||
import type { ScaleDetailRow } from '@/types/pricing'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
contractId: string
|
|
||||||
serviceId: string | number
|
|
||||||
method: 'investScale' | 'landScale'
|
|
||||||
}>()
|
|
||||||
|
|
||||||
type MajorLite = {
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
defCoe: number | null
|
|
||||||
hasCost?: boolean
|
|
||||||
hasArea?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const zxFwPricingStore = useZxFwPricingStore()
|
|
||||||
const gridApi = ref<GridApi<ScaleDetailRow> | null>(null)
|
|
||||||
let autoHeightTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const isInvestmentFormula = computed(() => props.method === 'investScale')
|
|
||||||
const methodLabel = computed(() =>
|
|
||||||
isInvestmentFormula.value
|
|
||||||
? t('zxFwView.categories.investmentScaleFormula')
|
|
||||||
: t('zxFwView.categories.landScaleFormula')
|
|
||||||
)
|
|
||||||
|
|
||||||
const ensureMethodStateLoaded = async () => {
|
|
||||||
await zxFwPricingStore.loadServicePricingMethodState<ScaleDetailRow>(
|
|
||||||
props.contractId,
|
|
||||||
props.serviceId,
|
|
||||||
props.method
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const methodState = computed(() =>
|
|
||||||
zxFwPricingStore.getServicePricingMethodState<ScaleDetailRow>(
|
|
||||||
props.contractId,
|
|
||||||
props.serviceId,
|
|
||||||
props.method
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const rowData = computed<ScaleDetailRow[]>(() => {
|
|
||||||
const rows = methodState.value?.detailRows
|
|
||||||
return Array.isArray(rows) ? rows : []
|
|
||||||
})
|
|
||||||
|
|
||||||
const idLabelMap = computed(() => {
|
|
||||||
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
|
|
||||||
const detailDict = buildScaleDetailDict(
|
|
||||||
majorEntries,
|
|
||||||
isInvestmentFormula.value
|
|
||||||
? ({ hasCost, hasArea }) => hasCost && !hasArea
|
|
||||||
: ({ hasArea }) => hasArea
|
|
||||||
)
|
|
||||||
return buildScaleIdLabelMap(detailDict)
|
|
||||||
})
|
|
||||||
|
|
||||||
const numberFormatter = (params: { value?: unknown }) =>
|
|
||||||
typeof params.value === 'number' && Number.isFinite(params.value)
|
|
||||||
? formatThousandsFlexible(params.value, 3)
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const scaleValueColumnField = computed<keyof ScaleDetailRow>(() =>
|
|
||||||
isInvestmentFormula.value ? 'amount' : 'landArea'
|
|
||||||
)
|
|
||||||
|
|
||||||
const scaleValueColumnHeader = computed(() =>
|
|
||||||
isInvestmentFormula.value
|
|
||||||
? t('pricingScale.columns.investAmount')
|
|
||||||
: t('pricingScale.columns.landArea')
|
|
||||||
)
|
|
||||||
|
|
||||||
const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() =>
|
|
||||||
withReadonlyAutoHeight<ScaleDetailRow>([
|
|
||||||
{
|
|
||||||
headerName: scaleValueColumnHeader.value,
|
|
||||||
field: scaleValueColumnField.value,
|
|
||||||
minWidth: 130,
|
|
||||||
flex: 1,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: numberFormatter
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('pricingScale.columns.basicWork'),
|
|
||||||
field: 'benchmarkBudgetBasic',
|
|
||||||
minWidth: 130,
|
|
||||||
flex: 1,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: numberFormatter
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('zxFwView.formulaColumns.basicFormula'),
|
|
||||||
field: 'basicFormula',
|
|
||||||
minWidth: 260,
|
|
||||||
flex: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('pricingScale.columns.optionalWork'),
|
|
||||||
field: 'benchmarkBudgetOptional',
|
|
||||||
minWidth: 130,
|
|
||||||
flex: 1,
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
valueFormatter: numberFormatter
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('zxFwView.formulaColumns.optionalFormula'),
|
|
||||||
field: 'optionalFormula',
|
|
||||||
minWidth: 260,
|
|
||||||
flex: 2
|
|
||||||
}
|
|
||||||
]) as ColDef<ScaleDetailRow>[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const autoGroupColumnDef = computed<ColDef<ScaleDetailRow>>(() =>
|
|
||||||
createScaleAutoGroupColumn<ScaleDetailRow>({
|
|
||||||
totalLabel: methodLabel.value,
|
|
||||||
idLabelMap: idLabelMap.value,
|
|
||||||
parseProjectIndexFromPathKey
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const detailGridOptions: GridOptions<ScaleDetailRow> = {
|
|
||||||
...gridOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncAutoRowHeights = async () => {
|
|
||||||
await nextTick()
|
|
||||||
const api = gridApi.value
|
|
||||||
if (!api || api.isDestroyed?.()) return
|
|
||||||
api.resetRowHeights()
|
|
||||||
api.onRowHeightChanged()
|
|
||||||
api.refreshCells({ force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleAutoRowHeights = () => {
|
|
||||||
if (autoHeightTimer) clearTimeout(autoHeightTimer)
|
|
||||||
autoHeightTimer = setTimeout(() => {
|
|
||||||
autoHeightTimer = null
|
|
||||||
void syncAutoRowHeights()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onGridReady = (event: GridReadyEvent<ScaleDetailRow>) => {
|
|
||||||
gridApi.value = event.api
|
|
||||||
scheduleAutoRowHeights()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(rowData, () => {
|
|
||||||
scheduleAutoRowHeights()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
watch(() => props.method, () => {
|
|
||||||
void ensureMethodStateLoaded()
|
|
||||||
scheduleAutoRowHeights()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void ensureMethodStateLoaded()
|
|
||||||
})
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
void ensureMethodStateLoaded()
|
|
||||||
scheduleAutoRowHeights()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (autoHeightTimer) clearTimeout(autoHeightTimer)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold text-foreground">{{ methodLabel }}</h3>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('zxFwView.formulaColumns.subtitle') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :class="agGridWrapClass">
|
|
||||||
<AgGridVue
|
|
||||||
:theme="myTheme"
|
|
||||||
:style="agGridStyle"
|
|
||||||
:row-data="rowData"
|
|
||||||
:column-defs="columnDefs"
|
|
||||||
:auto-group-column-def="autoGroupColumnDef"
|
|
||||||
:grid-options="detailGridOptions"
|
|
||||||
:locale-text="AG_GRID_LOCALE_CN"
|
|
||||||
@grid-ready="onGridReady"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,995 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
|
||||||
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
|
||||||
import { taskList } from '@/sql'
|
|
||||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
|
||||||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
|
||||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
|
||||||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
|
||||||
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
|
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
|
||||||
import { useKvStore } from '@/pinia/kv'
|
|
||||||
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
|
|
||||||
import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
|
|
||||||
import { sumNullableBy } from '@/lib/pricingScaleCalc'
|
|
||||||
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
|
|
||||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
|
||||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
|
||||||
import {useDataStore} from "@/pinia/zx";
|
|
||||||
|
|
||||||
interface DetailRow {
|
|
||||||
id: string
|
|
||||||
taskCode: string
|
|
||||||
taskName: string
|
|
||||||
unit: string
|
|
||||||
conversion: number | null
|
|
||||||
workload: number | null
|
|
||||||
basicFee: number | null
|
|
||||||
budgetBase: string
|
|
||||||
budgetReferenceUnitPrice: string | number | null
|
|
||||||
budgetAdoptedUnitPrice: number | null
|
|
||||||
consultCategoryFactor: number | null
|
|
||||||
cLow: number | null
|
|
||||||
cMid: number | null
|
|
||||||
cHigh: number | null
|
|
||||||
serviceFee: number | null
|
|
||||||
remark: string
|
|
||||||
path: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface XmInfoState {
|
|
||||||
projectName: string
|
|
||||||
detailRows: DetailRow[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
contractId: string,
|
|
||||||
contractName: string,
|
|
||||||
serviceId: string | number
|
|
||||||
}>()
|
|
||||||
const zxFwPricingStore = useZxFwPricingStore()
|
|
||||||
const { t, locale } = useI18n()
|
|
||||||
const kvStore = useKvStore()
|
|
||||||
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
|
||||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
|
||||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
|
||||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
|
||||||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
|
||||||
let factorDefaultsLoaded = false
|
|
||||||
const paneInstanceCreatedAt = Date.now()
|
|
||||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取服务对应的咨询服务分类因子
|
|
||||||
* @returns 咨询服务分类因子值,如果没有则返回 null
|
|
||||||
*/
|
|
||||||
const getDefaultConsultCategoryFactor = () =>
|
|
||||||
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保咨询服务分类因子默认值已加载
|
|
||||||
* 从合同段的咨询因子配置中加载,避免重复加载
|
|
||||||
*/
|
|
||||||
const ensureFactorDefaultsLoaded = async () => {
|
|
||||||
if (factorDefaultsLoaded) return
|
|
||||||
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
|
|
||||||
factorDefaultsLoaded = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否应该跳过数据持久化
|
|
||||||
* 用于在清空操作后的短时间内避免旧数据回填
|
|
||||||
* @returns 是否应该跳过持久化
|
|
||||||
*/
|
|
||||||
const shouldSkipPersist = () => {
|
|
||||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
|
||||||
if (!raw) return false
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
if (raw.includes(':')) {
|
|
||||||
const [issuedRaw, untilRaw] = raw.split(':')
|
|
||||||
const issuedAt = Number(issuedRaw)
|
|
||||||
const skipUntil = Number(untilRaw)
|
|
||||||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
|
||||||
return paneInstanceCreatedAt <= issuedAt
|
|
||||||
}
|
|
||||||
sessionStorage.removeItem(storageKey)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const skipUntil = Number(raw)
|
|
||||||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
|
||||||
sessionStorage.removeItem(storageKey)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否应该强制加载默认数据
|
|
||||||
* 用于在恢复默认操作后强制使用初始值
|
|
||||||
* @returns 是否应该强制加载默认数据
|
|
||||||
*/
|
|
||||||
const shouldForceDefaultLoad = () => {
|
|
||||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
|
||||||
if (!raw) return false
|
|
||||||
const forceUntil = Number(raw)
|
|
||||||
sessionStorage.removeItem(storageKey)
|
|
||||||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前服务工作量法的状态
|
|
||||||
* @returns 工作量法状态对象,包含明细行数据
|
|
||||||
*/
|
|
||||||
const getMethodState = () =>
|
|
||||||
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'serviceFee')
|
|
||||||
|
|
||||||
const detailRows = computed<DetailRow[]>({
|
|
||||||
get: () => {
|
|
||||||
const rows = getMethodState()?.detailRows
|
|
||||||
return Array.isArray(rows) ? rows : []
|
|
||||||
},
|
|
||||||
set: rows => {
|
|
||||||
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'serviceFee', {
|
|
||||||
detailRows: rows
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
type taskLite = {
|
|
||||||
serviceID: number
|
|
||||||
code?: string
|
|
||||||
ref?: string
|
|
||||||
name: string
|
|
||||||
basicParam: string
|
|
||||||
unit: string
|
|
||||||
conversion: number | null
|
|
||||||
maxPrice: number | null
|
|
||||||
minPrice: number | null
|
|
||||||
defPrice: number | null
|
|
||||||
midPrice: number | null
|
|
||||||
highPrice: number | null
|
|
||||||
desc: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取任务显示名称
|
|
||||||
* 根据当前语言环境返回中文或英文名称
|
|
||||||
* @param task 任务对象
|
|
||||||
* @returns 任务显示名称
|
|
||||||
*/
|
|
||||||
const getTaskDisplayName = (task: taskLite | undefined) => {
|
|
||||||
if (!task) return ''
|
|
||||||
return String(locale.value).toLowerCase().startsWith('en')
|
|
||||||
? (task as taskLite & { nameEn?: string }).nameEn || task.name
|
|
||||||
: task.name
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化任务参考单价
|
|
||||||
* 根据任务的最小/最大价格生成价格区间字符串
|
|
||||||
* @param task 任务对象
|
|
||||||
* @returns 格式化后的价格区间字符串(如 "100元-200元")
|
|
||||||
*/
|
|
||||||
const formatTaskReferenceUnitPrice = (task: taskLite) => {
|
|
||||||
const unit = task.unit || ''
|
|
||||||
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
|
|
||||||
const hasMax = typeof task.maxPrice === 'number' && Number.isFinite(task.maxPrice)
|
|
||||||
if (hasMin && hasMax) return `${task.minPrice}${unit}-${task.maxPrice}${unit}`
|
|
||||||
if (hasMin) return `${task.minPrice}${unit}`
|
|
||||||
if (hasMax) return `${task.maxPrice}${unit}`
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前服务对应的工作任务 ID 列表
|
|
||||||
* 从 taskList 中筛选出属于当前服务的任务,并按 ID 排序
|
|
||||||
* @returns 任务 ID 数组
|
|
||||||
*/
|
|
||||||
const getSourceTaskIds = () => {
|
|
||||||
// 从 serviceId 中提取最后的数字部分,例如:ct-1776069559338-f621-zx-9 -> 9
|
|
||||||
const serviceIdStr = String(props.serviceId)
|
|
||||||
const match = serviceIdStr.match(/-(\d+)$/)
|
|
||||||
const currentServiceId = match ? Number(match[1]) : NaN
|
|
||||||
return Object.entries(taskList as Record<string, taskLite>)
|
|
||||||
.filter(([, task]) => Number(task.serviceID) === currentServiceId)
|
|
||||||
.map(([key]) => Number(key))
|
|
||||||
.filter(Number.isFinite)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWorkloadMethodApplicable = computed(() => getSourceTaskIds().length > 0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建默认明细行数据
|
|
||||||
* 根据当前服务的任务列表生成初始行数据
|
|
||||||
* @returns 默认明细行数组
|
|
||||||
*/
|
|
||||||
const buildDefaultRows = (): DetailRow[] => {
|
|
||||||
const rows: DetailRow[] = []
|
|
||||||
const sourceTaskIds = getSourceTaskIds()
|
|
||||||
|
|
||||||
for (const [order, taskId] of sourceTaskIds.entries()) {
|
|
||||||
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
|
|
||||||
const taskCode = task?.code || task?.ref || ''
|
|
||||||
if (!taskCode || !task?.name) continue
|
|
||||||
const contractId = props.contractId;
|
|
||||||
const rowId = `${contractId}-task-${taskId}`
|
|
||||||
rows.push({
|
|
||||||
id: rowId,
|
|
||||||
taskCode,
|
|
||||||
taskName: getTaskDisplayName(task),
|
|
||||||
unit: task.unit || '',
|
|
||||||
conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null,
|
|
||||||
workload: typeof task.midPrice === 'number' && Number.isFinite(task.midPrice) ? task.midPrice : null,
|
|
||||||
basicFee: null,
|
|
||||||
budgetBase: task.basicParam || '',
|
|
||||||
budgetReferenceUnitPrice: null,
|
|
||||||
budgetAdoptedUnitPrice:
|
|
||||||
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
|
|
||||||
consultCategoryFactor: typeof task.highPrice === 'number' && Number.isFinite(task.highPrice) ? task.highPrice : null,
|
|
||||||
serviceFee: null,
|
|
||||||
remark: task.desc|| '',
|
|
||||||
type: 'task',
|
|
||||||
path: [rowId]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNoTaskRow = (row: DetailRow | undefined) => row?.id?.startsWith('task-none-') ?? false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合并数据库中的行数据与默认行数据
|
|
||||||
* 保留用户编辑的值(工作量、单价、因子等),缺失时使用默认值
|
|
||||||
* @param rowsFromDb 从数据库加载的行数据
|
|
||||||
* @returns 合并后的明细行数组
|
|
||||||
*/
|
|
||||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
|
||||||
const dbValueMap = new Map<string, DetailRow>()
|
|
||||||
for (const row of rowsFromDb || []) {
|
|
||||||
dbValueMap.set(row.id, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildDefaultRows().map(row => {
|
|
||||||
const fromDb = dbValueMap.get(row.id)
|
|
||||||
if (!fromDb) return row
|
|
||||||
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
|
|
||||||
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
|
|
||||||
// ✅ 计算 cLow 值
|
|
||||||
let cLowValue: number | null = null
|
|
||||||
let cMidValue: number | null = null
|
|
||||||
let cHighValue: number | null = null
|
|
||||||
const refPrice = fromDb.budgetReferenceUnitPrice
|
|
||||||
const lowPrice = row.budgetAdoptedUnitPrice
|
|
||||||
const midPrice = row.workload
|
|
||||||
const highPrice = row.consultCategoryFactor
|
|
||||||
cLowValue = refPrice * lowPrice
|
|
||||||
cMidValue = refPrice * midPrice
|
|
||||||
cHighValue = refPrice * highPrice
|
|
||||||
let serviceFee = cMidValue
|
|
||||||
|
|
||||||
if (fromDb.serviceFee != null && fromDb.serviceFee != 0) {
|
|
||||||
serviceFee = fromDb.serviceFee
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
workload: typeof fromDb.workload === 'number' ? fromDb.workload : null,
|
|
||||||
basicFee: typeof fromDb.basicFee === 'number' ? fromDb.basicFee : null,
|
|
||||||
budgetReferenceUnitPrice: fromDb.budgetReferenceUnitPrice ?? row.budgetReferenceUnitPrice,
|
|
||||||
budgetAdoptedUnitPrice: 1, // 固定为 1
|
|
||||||
cLow: cLowValue,
|
|
||||||
cMid: cMidValue,
|
|
||||||
cHigh: cHighValue,
|
|
||||||
consultCategoryFactor:
|
|
||||||
typeof fromDb.consultCategoryFactor === 'number'
|
|
||||||
? fromDb.consultCategoryFactor
|
|
||||||
: hasConsultCategoryFactor
|
|
||||||
? null
|
|
||||||
: getDefaultConsultCategoryFactor(),
|
|
||||||
serviceFee: serviceFee,
|
|
||||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseSanitizedNumberOrNull = (value: unknown) =>
|
|
||||||
parseNumberOrNull(value, { sanitize: true, precision: 3 })
|
|
||||||
|
|
||||||
const parseSanitizedAdoptedPriceOrNull = (value: unknown) =>
|
|
||||||
parseNumberOrNull(value, { sanitize: true, precision: 6 })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算基础费用
|
|
||||||
* 公式:基础费用 = 采用单价 × 换算系数 × 工作量
|
|
||||||
* @param row 明细行数据
|
|
||||||
* @returns 基础费用(保留2位小数),如果数据不完整则返回 null
|
|
||||||
*/
|
|
||||||
const calcBasicFee = (row: DetailRow | undefined) => {
|
|
||||||
if (!row || isNoTaskRow(row)) return null
|
|
||||||
const price = row.budgetAdoptedUnitPrice
|
|
||||||
const conversion = row.conversion
|
|
||||||
const workload = row.workload
|
|
||||||
if (
|
|
||||||
typeof price !== 'number' ||
|
|
||||||
!Number.isFinite(price) ||
|
|
||||||
typeof conversion !== 'number' ||
|
|
||||||
!Number.isFinite(conversion) ||
|
|
||||||
typeof workload !== 'number' ||
|
|
||||||
!Number.isFinite(workload)
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算服务费用
|
|
||||||
* 公式:服务费用 = 基础费用 × 咨询服务分类因子
|
|
||||||
* @param row 明细行数据
|
|
||||||
* @returns 服务费用(保留2位小数),如果数据不完整则返回 null
|
|
||||||
*/
|
|
||||||
const calcServiceFee = (row: DetailRow | undefined) => {
|
|
||||||
if (!row || isNoTaskRow(row)) return null
|
|
||||||
const factor = row.consultCategoryFactor
|
|
||||||
const basicFee = calcBasicFee(row)
|
|
||||||
if (
|
|
||||||
basicFee == null ||
|
|
||||||
typeof factor !== 'number' ||
|
|
||||||
!Number.isFinite(factor)
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return roundTo(toDecimal(basicFee).mul(factor), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化可编辑数字单元格
|
|
||||||
* 根据行类型和值状态返回相应的显示文本
|
|
||||||
* @param params AG Grid 单元格参数
|
|
||||||
* @returns 格式化后的显示文本
|
|
||||||
*/
|
|
||||||
const formatEditableNumber = (params: any) => {
|
|
||||||
if (isNoTaskRow(params.data)) return t('workloadPricing.none')
|
|
||||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
|
||||||
return t('workloadPricing.clickToInput')
|
|
||||||
}
|
|
||||||
if (params.value == null) return ''
|
|
||||||
return formatThousandsFlexible(params.value, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断两行是否应该合并显示(按任务名称和预算基数)
|
|
||||||
* 用于 AG Grid 的 spanRows 功能,相同任务和预算基数的行合并显示
|
|
||||||
* @param params AG Grid 行比较参数
|
|
||||||
* @returns 是否应该合并
|
|
||||||
*/
|
|
||||||
const spanRowsByTaskName = (params: any) => {
|
|
||||||
const rowA = params?.nodeA?.data as DetailRow | undefined
|
|
||||||
const rowB = params?.nodeB?.data as DetailRow | undefined
|
|
||||||
if (!rowA || !rowB) return false
|
|
||||||
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
|
|
||||||
|
|
||||||
return Boolean(rowA.taskName) && Boolean(rowA.budgetBase) && rowA.taskName === rowB.taskName && rowA.budgetBase === rowB.budgetBase
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnDefs: ColDef<DetailRow>[] = [
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.code'),
|
|
||||||
field: 'taskCode',
|
|
||||||
minWidth: 100,
|
|
||||||
width: 120,
|
|
||||||
pinned: 'left',
|
|
||||||
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
|
||||||
valueFormatter: params => (params.node?.rowPinned ? t('workloadPricing.total') : params.value || '')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.name'),
|
|
||||||
field: 'taskName',
|
|
||||||
minWidth: 150,
|
|
||||||
width: 220,
|
|
||||||
pinned: 'left',
|
|
||||||
wrapText: true,
|
|
||||||
autoHeight: true,
|
|
||||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
|
||||||
|
|
||||||
spanRows: true,
|
|
||||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.budgetBase'),
|
|
||||||
field: 'budgetBase',
|
|
||||||
minWidth: 150,
|
|
||||||
autoHeight: true,
|
|
||||||
width: 180,
|
|
||||||
colSpan: params => (params.node?.rowPinned ? 3 : 1),
|
|
||||||
spanRows: spanRowsByTaskName,
|
|
||||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.budgetReferenceUnitPrice'),
|
|
||||||
field: 'budgetReferenceUnitPrice',
|
|
||||||
minWidth: 170,
|
|
||||||
flex: 1,
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
|
||||||
valueFormatter: formatEditableNumber
|
|
||||||
},
|
|
||||||
/*{
|
|
||||||
headerName: t('workloadPricing.columns.budgetAdoptedUnitPrice'),
|
|
||||||
field: 'budgetAdoptedUnitPrice',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 170,
|
|
||||||
flex: 1,
|
|
||||||
// editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true,
|
|
||||||
'editable-cell-empty': params =>
|
|
||||||
!params.node?.group &&
|
|
||||||
!params.node?.rowPinned &&
|
|
||||||
!isNoTaskRow(params.data) &&
|
|
||||||
(params.value == null || params.value === '')
|
|
||||||
},
|
|
||||||
valueParser: params => parseSanitizedAdoptedPriceOrNull(params.newValue),
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (isNoTaskRow(params.data)) return t('workloadPricing.none')
|
|
||||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
|
||||||
return t('workloadPricing.clickToInput')
|
|
||||||
}
|
|
||||||
if (params.value == null) return ''
|
|
||||||
const unit = params.data?.unit || ''
|
|
||||||
return `${formatThousandsFlexible(params.value, 6)}${unit}`
|
|
||||||
}
|
|
||||||
},*/
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.budgetAdoptedUnitPrice'),
|
|
||||||
field: 'budgetAdoptedUnitPrice',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 170,
|
|
||||||
flex: 1,
|
|
||||||
// ❌ 移除 editable 或设置为 false
|
|
||||||
editable: false,
|
|
||||||
// ✅ 添加单元格样式
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
// ✅ 移除 cellClassRules 中的 editable-cell 相关规则
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
// ✅ 固定显示值为 1
|
|
||||||
valueFormatter: params => {
|
|
||||||
const unit = params.data?.unit || ''
|
|
||||||
return `1`
|
|
||||||
// return `1${unit}`
|
|
||||||
},
|
|
||||||
// ✅ 阻止任何编辑尝试
|
|
||||||
valueParser: () => 1,
|
|
||||||
// ✅ 禁用单元格编辑
|
|
||||||
cellEditor: undefined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.workload'),
|
|
||||||
field: 'workload',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 170,
|
|
||||||
flex: 1,
|
|
||||||
// ❌ 移除 editable 或设置为 false
|
|
||||||
editable: false,
|
|
||||||
// ✅ 添加单元格样式
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
// ✅ 移除 cellClassRules 中的 editable-cell 相关规则
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
// ✅ 固定显示值为 1
|
|
||||||
valueFormatter: params => {
|
|
||||||
const unit = params.data?.unit || ''
|
|
||||||
return `2`
|
|
||||||
// return `1${unit}`
|
|
||||||
},
|
|
||||||
// ✅ 阻止任何编辑尝试
|
|
||||||
valueParser: () => 1,
|
|
||||||
// ✅ 禁用单元格编辑
|
|
||||||
cellEditor: undefined
|
|
||||||
},
|
|
||||||
/*{
|
|
||||||
headerName: t('workloadPricing.columns.workload'),
|
|
||||||
field: 'workload',
|
|
||||||
minWidth: 140,
|
|
||||||
flex: 1,
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true,
|
|
||||||
|
|
||||||
'editable-cell-empty': params =>
|
|
||||||
!params.node?.group &&
|
|
||||||
!params.node?.rowPinned &&
|
|
||||||
!isNoTaskRow(params.data) &&
|
|
||||||
(params.value == null || params.value === '')
|
|
||||||
},
|
|
||||||
aggFunc: decimalAggSum,
|
|
||||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
|
||||||
valueFormatter: formatEditableNumber
|
|
||||||
},*/
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.consultCategoryFactor'),
|
|
||||||
field: 'consultCategoryFactor',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 170,
|
|
||||||
flex: 1,
|
|
||||||
// ❌ 移除 editable 或设置为 false
|
|
||||||
editable: false,
|
|
||||||
// ✅ 添加单元格样式
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
// ✅ 移除 cellClassRules 中的 editable-cell 相关规则
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
},
|
|
||||||
// ✅ 固定显示值为 1
|
|
||||||
valueFormatter: params => {
|
|
||||||
const unit = params.data?.unit || ''
|
|
||||||
return `3`
|
|
||||||
// return `1${unit}`
|
|
||||||
},
|
|
||||||
// ✅ 阻止任何编辑尝试
|
|
||||||
valueParser: () => 1,
|
|
||||||
// ✅ 禁用单元格编辑
|
|
||||||
cellEditor: undefined
|
|
||||||
},
|
|
||||||
/*{
|
|
||||||
headerName: t('workloadPricing.columns.consultCategoryFactor'),
|
|
||||||
field: 'consultCategoryFactor',
|
|
||||||
width: 80,
|
|
||||||
minWidth: 70,
|
|
||||||
maxWidth: 90,
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true,
|
|
||||||
|
|
||||||
'editable-cell-empty': params =>
|
|
||||||
!params.node?.group &&
|
|
||||||
!params.node?.rowPinned &&
|
|
||||||
!isNoTaskRow(params.data) &&
|
|
||||||
(params.value == null || params.value === '')
|
|
||||||
},
|
|
||||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
|
||||||
valueFormatter: formatEditableNumber
|
|
||||||
},*/
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.cLow'),
|
|
||||||
field: 'cLow',
|
|
||||||
width: 100,
|
|
||||||
minWidth: 70,
|
|
||||||
maxWidth: 120,
|
|
||||||
// ✅ 设置为不可编辑
|
|
||||||
editable: false,
|
|
||||||
// ✅ 只读样式
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
}/*,
|
|
||||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
|
||||||
valueFormatter: formatEditableNumber*/
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.cMid'),
|
|
||||||
field: 'cMid',
|
|
||||||
width: 100,
|
|
||||||
minWidth: 70,
|
|
||||||
maxWidth: 120,
|
|
||||||
// ✅ 设置为不可编辑
|
|
||||||
editable: false,
|
|
||||||
// ✅ 只读样式
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
}/*,
|
|
||||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
|
||||||
valueFormatter: formatEditableNumber*/
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.cHigh'),
|
|
||||||
field: 'cHigh',
|
|
||||||
width: 100,
|
|
||||||
minWidth: 70,
|
|
||||||
maxWidth: 120,
|
|
||||||
// ✅ 设置为不可编辑
|
|
||||||
editable: false,
|
|
||||||
// ✅ 只读样式
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true
|
|
||||||
}/*,
|
|
||||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
|
||||||
valueFormatter: formatEditableNumber*/
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.serviceFee'),
|
|
||||||
field: 'serviceFee',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 150,
|
|
||||||
flex: 1,
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
|
||||||
cellClassRules: {
|
|
||||||
'ag-right-aligned-cell': () => true,
|
|
||||||
|
|
||||||
'editable-cell-empty': params =>
|
|
||||||
!params.node?.group &&
|
|
||||||
!params.node?.rowPinned &&
|
|
||||||
!isNoTaskRow(params.data) &&
|
|
||||||
(params.value == null || params.value === '')
|
|
||||||
},
|
|
||||||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
|
||||||
valueFormatter: formatEditableNumber
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: t('workloadPricing.columns.remark'),
|
|
||||||
field: 'remark',
|
|
||||||
minWidth: 180,
|
|
||||||
flex: 1.2,
|
|
||||||
cellEditor: 'agLargeTextCellEditor',
|
|
||||||
wrapText: true,
|
|
||||||
autoHeight: true,
|
|
||||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
|
||||||
|
|
||||||
editable: false,
|
|
||||||
valueFormatter: params => {
|
|
||||||
|
|
||||||
return params.value || ''
|
|
||||||
},
|
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
|
|
||||||
cellClassRules: {
|
|
||||||
'editable-cell-empty': params =>
|
|
||||||
!params.node?.group &&
|
|
||||||
!params.node?.rowPinned &&
|
|
||||||
!isNoTaskRow(params.data) &&
|
|
||||||
(params.value == null || params.value === '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
|
||||||
|
|
||||||
const totalWorkload = computed(() => {
|
|
||||||
return 1;
|
|
||||||
})
|
|
||||||
const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row)))
|
|
||||||
const totalServiceFee = computed(() => sumNullableBy(detailRows.value, row => calcServiceFee(row)))
|
|
||||||
const pinnedTopRowData = computed(() =>
|
|
||||||
createPinnedTopRowData({
|
|
||||||
id: 'pinned-total-row',
|
|
||||||
taskCode: t('workloadPricing.total'),
|
|
||||||
taskName: '',
|
|
||||||
unit: '',
|
|
||||||
conversion: null,
|
|
||||||
workload: totalWorkload.value,
|
|
||||||
basicFee: totalBasicFee.value,
|
|
||||||
budgetBase: '',
|
|
||||||
budgetReferenceUnitPrice: '',
|
|
||||||
budgetAdoptedUnitPrice: null,
|
|
||||||
consultCategoryFactor: null,
|
|
||||||
serviceFee: totalServiceFee.value,
|
|
||||||
remark: '',
|
|
||||||
path: ['TOTAL']
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建用于持久化的明细行数据
|
|
||||||
* 计算并附加基础费用和服务费用字段
|
|
||||||
* @returns 包含计算字段的明细行数组
|
|
||||||
*/
|
|
||||||
const buildPersistDetailRows = () =>
|
|
||||||
detailRows.value.map(row => ({
|
|
||||||
...row,
|
|
||||||
basicFee: calcBasicFee(row),
|
|
||||||
serviceFee: row.serviceFee
|
|
||||||
}))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存数据到 IndexedDB 并同步到 zxFw store
|
|
||||||
* 保存时机:单元格编辑、批量操作结束、组件失活/卸载时
|
|
||||||
* 同步工作量法的总服务费用到咨询服务汇总表
|
|
||||||
*/
|
|
||||||
const saveToIndexedDB = async () => {
|
|
||||||
try {
|
|
||||||
const rows = detailRows.value.map(row => ({ ...row, type: `${props.contractId}-task`}))
|
|
||||||
const stats = await useDataStore().upsertBatch(rows)
|
|
||||||
console.log('💾 数据保存成功:', stats)
|
|
||||||
/*zxFwPricingStore.setServicePricingMethodState(
|
|
||||||
props.contractId,
|
|
||||||
props.serviceId,
|
|
||||||
'serviceFee',
|
|
||||||
payload,
|
|
||||||
{ force: true }
|
|
||||||
)*/
|
|
||||||
// const synced = await syncPricingTotalToZxFw({
|
|
||||||
// contractId: props.contractId,
|
|
||||||
// serviceId: props.serviceId,
|
|
||||||
// field: 'serviceFee',
|
|
||||||
// value: totalWorkload.value
|
|
||||||
// })
|
|
||||||
// if (!synced) return
|
|
||||||
} catch (error) {
|
|
||||||
console.error('saveToIndexedDB failed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断两个可空数值是否相等(考虑精度)
|
|
||||||
* @param left 第一个数值
|
|
||||||
* @param right 第二个数值
|
|
||||||
* @returns 是否相等
|
|
||||||
*/
|
|
||||||
const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
|
|
||||||
if (left == null && right == null) return true
|
|
||||||
if (left == null || right == null) return false
|
|
||||||
return roundTo(left, 6) === roundTo(right, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从合同段同步咨询服务分类因子
|
|
||||||
* 当合同段的咨询因子配置变化时,更新当前工作量的因子默认值
|
|
||||||
*/
|
|
||||||
const syncLinkedConsultFactorFromHt = async () => {
|
|
||||||
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
|
|
||||||
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
|
|
||||||
factorDefaultsLoaded = true
|
|
||||||
const nextDefaultFactor = getDefaultConsultCategoryFactor()
|
|
||||||
let changed = false
|
|
||||||
detailRows.value = detailRows.value.map(row => {
|
|
||||||
if (isSameNullableNumber(row.consultCategoryFactor, nextDefaultFactor)) return row
|
|
||||||
changed = true
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
consultCategoryFactor: nextDefaultFactor
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!changed) return
|
|
||||||
await saveToIndexedDB()
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkedConsultFactorSignature = computed(() => JSON.stringify({
|
|
||||||
consultFactor:
|
|
||||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
|
|
||||||
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
|
|
||||||
?? null
|
|
||||||
}))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 IndexedDB 加载工作量法数据
|
|
||||||
* 加载时机:组件挂载、激活、storageKey 变化时
|
|
||||||
* 优先加载历史数据,没有则使用默认数据
|
|
||||||
*/
|
|
||||||
const loadFromIndexedDB = async () => {
|
|
||||||
try {
|
|
||||||
if (!isWorkloadMethodApplicable.value) {
|
|
||||||
detailRows.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const taskRows = await useDataStore().query([
|
|
||||||
{ field: 'type', value: `${props.contractId}-task`, operator: 'eq' }
|
|
||||||
])
|
|
||||||
/*await ensureFactorDefaultsLoaded()
|
|
||||||
|
|
||||||
if (shouldForceDefaultLoad()) {
|
|
||||||
detailRows.value = buildDefaultRows()
|
|
||||||
return
|
|
||||||
}*/
|
|
||||||
|
|
||||||
// const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'serviceFee')
|
|
||||||
if (taskRows) {
|
|
||||||
detailRows.value = mergeWithDictRows(taskRows)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
detailRows.value = buildDefaultRows()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('loadFromIndexedDB failed:', error)
|
|
||||||
detailRows.value = buildDefaultRows()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据任务词典更新行标签
|
|
||||||
* 当语言切换或任务词典更新时,同步更新任务名称、单位等信息
|
|
||||||
*/
|
|
||||||
const relabelRowsFromTaskDict = async () => {
|
|
||||||
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
|
|
||||||
let changed = false
|
|
||||||
detailRows.value = detailRows.value.map(row => {
|
|
||||||
if (isNoTaskRow(row)) return row
|
|
||||||
const match = String(row.id || '').match(/^task-(\d+)-\d+$/)
|
|
||||||
if (!match) return row
|
|
||||||
const task = (taskList as Record<string, taskLite | undefined>)[match[1]]
|
|
||||||
if (!task) return row
|
|
||||||
const nextTaskName = getTaskDisplayName(task)
|
|
||||||
const nextUnit = task.unit || ''
|
|
||||||
const nextBudgetBase = task.basicParam || ''
|
|
||||||
const nextBudgetReferenceUnitPrice = formatTaskReferenceUnitPrice(task)
|
|
||||||
if (
|
|
||||||
row.taskName === nextTaskName &&
|
|
||||||
row.unit === nextUnit &&
|
|
||||||
row.budgetBase === nextBudgetBase &&
|
|
||||||
row.budgetReferenceUnitPrice === nextBudgetReferenceUnitPrice
|
|
||||||
) {
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
taskName: nextTaskName,
|
|
||||||
unit: nextUnit,
|
|
||||||
budgetBase: nextBudgetBase,
|
|
||||||
budgetReferenceUnitPrice: nextBudgetReferenceUnitPrice
|
|
||||||
}
|
|
||||||
})
|
|
||||||
gridApi.value?.refreshCells({ force: true })
|
|
||||||
if (!changed) return
|
|
||||||
await saveToIndexedDB()
|
|
||||||
}
|
|
||||||
|
|
||||||
let isBulkClipboardMutation = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交网格变更并保存
|
|
||||||
* 在单元格编辑完成后调用,触发数据持久化
|
|
||||||
*/
|
|
||||||
const commitGridChanges = () => {
|
|
||||||
void saveToIndexedDB()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理单元格值变化事件
|
|
||||||
* 触发时机:用户编辑完单元格后
|
|
||||||
* 批量操作期间跳过,避免重复保存
|
|
||||||
*/
|
|
||||||
const handleCellValueChanged = () => {
|
|
||||||
if (isBulkClipboardMutation) return
|
|
||||||
commitGridChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理批量粘贴/填充操作开始
|
|
||||||
* 设置标志位,避免在批量操作过程中频繁保存
|
|
||||||
*/
|
|
||||||
const handleBulkMutationStart = () => {
|
|
||||||
isBulkClipboardMutation = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理批量粘贴/填充操作结束
|
|
||||||
* 清除标志位并保存所有变更
|
|
||||||
*/
|
|
||||||
const handleBulkMutationEnd = () => {
|
|
||||||
isBulkClipboardMutation = false
|
|
||||||
commitGridChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AG Grid 初始化完成回调
|
|
||||||
* 记录 gridApi 实例,用于后续刷新单元格等操作
|
|
||||||
* @param event AG Grid 初始化事件
|
|
||||||
*/
|
|
||||||
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
|
||||||
gridApi.value = event.api
|
|
||||||
}
|
|
||||||
|
|
||||||
usePricingPaneLifecycle({
|
|
||||||
gridApi,
|
|
||||||
loadFromIndexedDB,
|
|
||||||
syncLinkedFields: syncLinkedConsultFactorFromHt,
|
|
||||||
linkedSourceSignature: linkedConsultFactorSignature,
|
|
||||||
saveToIndexedDB
|
|
||||||
})
|
|
||||||
watch(
|
|
||||||
() => locale.value,
|
|
||||||
() => {
|
|
||||||
void relabelRowsFromTaskDict()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => useDataStore().items,
|
|
||||||
async (newItems) => {
|
|
||||||
if (Object.keys(newItems).length > 0) {
|
|
||||||
loadFromIndexedDB();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true }
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* 处理复制到剪贴板的单元格数据
|
|
||||||
* 将数组类型的数据转换为 JSON 字符串
|
|
||||||
* @param params AG Grid 剪贴板参数
|
|
||||||
* @returns 处理后的单元格值
|
|
||||||
*/
|
|
||||||
const processCellForClipboard = (params: any) => {
|
|
||||||
if (Array.isArray(params.value)) {
|
|
||||||
return JSON.stringify(params.value); // 数组转字符串复制
|
|
||||||
}
|
|
||||||
return params.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理从剪贴板粘贴的单元格数据
|
|
||||||
* 根据字段类型解析数值(单价、工作量、因子等)
|
|
||||||
* @param params AG Grid 剪贴板参数
|
|
||||||
* @returns 解析后的单元格值
|
|
||||||
*/
|
|
||||||
const processCellFromClipboard = (params: any) => {
|
|
||||||
const field = String(params.column?.getColDef?.().field || '')
|
|
||||||
if (field === 'budgetAdoptedUnitPrice') {
|
|
||||||
return parseSanitizedAdoptedPriceOrNull(params.value)
|
|
||||||
}
|
|
||||||
if (field === 'workload' || field === 'consultCategoryFactor') {
|
|
||||||
return parseSanitizedNumberOrNull(params.value)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(params.value);
|
|
||||||
if (Array.isArray(parsed)) return parsed;
|
|
||||||
} catch (e) {
|
|
||||||
// 解析失败时返回原始值,无需额外处理
|
|
||||||
}
|
|
||||||
return params.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mydiyTheme = myTheme.withParams({
|
|
||||||
rowBorder: {
|
|
||||||
style: "solid",
|
|
||||||
width: 0.8,
|
|
||||||
color: "var(--border)"
|
|
||||||
},
|
|
||||||
columnBorder: {
|
|
||||||
style: "solid",
|
|
||||||
width: 0.8,
|
|
||||||
color: "var(--border)"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
|
||||||
|
|
||||||
|
|
||||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
|
||||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
|
||||||
<h3 class="text-sm font-semibold text-foreground">{{ t('workloadPricing.title') }}</h3>
|
|
||||||
<div class="text-xs text-muted-foreground"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isWorkloadMethodApplicable" :class="agGridWrapClass">
|
|
||||||
<AgGridVue :style="agGridStyle" :rowData="detailRows"
|
|
||||||
:columnDefs="gridColumnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
|
|
||||||
:animateRows="true"
|
|
||||||
:enableCellSpan="true"
|
|
||||||
@grid-ready="handleGridReady"
|
|
||||||
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
|
||||||
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
|
|
||||||
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
|
|
||||||
:suppressRowVirtualisation="true"
|
|
||||||
|
|
||||||
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
|
||||||
|
|
||||||
|
|
||||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
|
||||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
|
||||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
|
||||||
</div>
|
|
||||||
<MethodUnavailableNotice
|
|
||||||
v-else
|
|
||||||
:title="t('workloadPricing.unavailableTitle')"
|
|
||||||
:message="t('workloadPricing.unavailableMessage')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
<!--
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
interface ServiceItem {
|
|
||||||
id: string
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
services: ServiceItem[]
|
|
||||||
modelValue: string[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: string[]): void
|
|
||||||
}>()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
/** 互斥规则配置 */
|
|
||||||
const MUTUAL_EXCLUSION_RULES: Record<string, string[]> = {
|
|
||||||
'D1': ['D2-1', 'D2-2', 'D3-1', 'D3-2', 'D3-3', 'D3-4', 'D3-6-1', 'D3-7'],
|
|
||||||
'D2-1': ['D1', 'D2-2', 'D3-4', 'D3-6-1', 'D3-7'],
|
|
||||||
'D2-2': ['D2-1', 'D3-1', 'D3-2', 'D3-3']
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前已选服务的 code 集合 */
|
|
||||||
const selectedCodes = computed(() => {
|
|
||||||
return props.services
|
|
||||||
.filter(s => selectedSet.value.has(s.id))
|
|
||||||
.map(s => s.code)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 获取某服务被哪些服务互斥 */
|
|
||||||
const getBlockedBy = (targetCode: string): string[] => {
|
|
||||||
const blockers: string[] = []
|
|
||||||
for (const [blockerCode, blockedCodes] of Object.entries(MUTUAL_EXCLUSION_RULES)) {
|
|
||||||
if (selectedCodes.value.includes(blockerCode) && blockedCodes.includes(targetCode)) {
|
|
||||||
blockers.push(blockerCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return blockers
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 判断某服务是否被禁用 */
|
|
||||||
const isServiceDisabled = (item: ServiceItem): { disabled: boolean; reason: string } => {
|
|
||||||
const blockers = getBlockedBy(item.code)
|
|
||||||
if (blockers.length > 0) {
|
|
||||||
const blockerServices = blockers.map(bc => {
|
|
||||||
const s = props.services.find(s => s.code === bc)
|
|
||||||
return s ? `${s.code}${s.name}` : bc
|
|
||||||
})
|
|
||||||
return { disabled: true, reason: `与已选的${blockerServices.join('、')}互斥` }
|
|
||||||
}
|
|
||||||
return { disabled: false, reason: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedSet = computed(() => new Set(props.modelValue))
|
|
||||||
|
|
||||||
const toggleService = (item: ServiceItem, checked: boolean) => {
|
|
||||||
const { disabled } = isServiceDisabled(item)
|
|
||||||
// 如果尝试勾选被禁用的服务,直接忽略
|
|
||||||
if (checked && disabled) return
|
|
||||||
|
|
||||||
const next = new Set(props.modelValue)
|
|
||||||
if (checked) {
|
|
||||||
next.add(item.id)
|
|
||||||
// 勾选时,自动取消互斥的服务
|
|
||||||
const blockedCodes = MUTUAL_EXCLUSION_RULES[item.code] || []
|
|
||||||
for (const blockedCode of blockedCodes) {
|
|
||||||
const blockedService = props.services.find(s => s.code === blockedCode)
|
|
||||||
if (blockedService) {
|
|
||||||
next.delete(blockedService.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
next.delete(item.id)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', props.services.map(s => s.id).filter(id => next.has(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
emit('update:modelValue', [])
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
|
|
||||||
<div class="mb-1 flex items-center justify-between gap-2">
|
|
||||||
<label class="block text-[11px] font-medium text-foreground leading-none">{{ t('serviceSelector.title') }}</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
|
|
||||||
@click="clearAll"
|
|
||||||
>
|
|
||||||
{{ t('serviceSelector.clear') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-md border p-1.5">
|
|
||||||
<div class="flex flex-wrap items-start gap-1">
|
|
||||||
<label
|
|
||||||
v-for="item in props.services"
|
|
||||||
:key="item.id"
|
|
||||||
:class="[
|
|
||||||
'inline-flex w-fit max-w-full cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4',
|
|
||||||
isServiceDisabled(item).disabled
|
|
||||||
? 'opacity-50 cursor-not-allowed hover:bg-transparent'
|
|
||||||
: 'hover:bg-muted/60'
|
|
||||||
]"
|
|
||||||
:title="isServiceDisabled(item).reason"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="mt-0.5"
|
|
||||||
:checked="selectedSet.has(item.id)"
|
|
||||||
:disabled="isServiceDisabled(item).disabled"
|
|
||||||
@change="toggleService(item, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
|
|
||||||
<span class="text-foreground break-words">{{ item.name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
|
|
||||||
{{ t('serviceSelector.empty') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-->
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
interface ServiceItem {
|
|
||||||
id: string
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
services: ServiceItem[]
|
|
||||||
modelValue: string[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: string[]): void
|
|
||||||
}>()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const selectedSet = computed(() => new Set(props.modelValue))
|
|
||||||
|
|
||||||
const toggleService = (item: ServiceItem, checked: boolean) => {
|
|
||||||
const next = new Set(props.modelValue)
|
|
||||||
if (checked) {
|
|
||||||
next.add(item.id)
|
|
||||||
} else {
|
|
||||||
next.delete(item.id)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', props.services.map(s => s.id).filter(id => next.has(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
emit('update:modelValue', [])
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
|
|
||||||
<div class="mb-1 flex items-center justify-between gap-2">
|
|
||||||
<label class="block text-[11px] font-medium text-foreground leading-none">{{ t('serviceSelector.title') }}</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
|
|
||||||
@click="clearAll"
|
|
||||||
>
|
|
||||||
{{ t('serviceSelector.clear') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-md border p-1.5">
|
|
||||||
<div class="flex flex-wrap items-start gap-1">
|
|
||||||
<label
|
|
||||||
v-for="item in props.services"
|
|
||||||
:key="item.id"
|
|
||||||
class="inline-flex w-fit max-w-full cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4 hover:bg-muted/60"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="mt-0.5"
|
|
||||||
:checked="selectedSet.has(item.id)"
|
|
||||||
@change="toggleService(item, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
|
|
||||||
<span class="text-foreground break-words">{{ item.name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
|
|
||||||
{{ t('serviceSelector.empty') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import type localforage from 'localforage'
|
|
||||||
import { i18n } from '@/i18n'
|
|
||||||
|
|
||||||
export interface DataEntry {
|
|
||||||
key: string
|
|
||||||
value: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ForageStoreSnapshot {
|
|
||||||
storeName: string
|
|
||||||
entries: DataEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataPackage {
|
|
||||||
version: number
|
|
||||||
packageType?: 'project-snapshot'
|
|
||||||
exportedAt: string
|
|
||||||
projectId?: string
|
|
||||||
localStorage: DataEntry[]
|
|
||||||
sessionStorage: DataEntry[]
|
|
||||||
localforageDefault: DataEntry[]
|
|
||||||
localforageStores?: ForageStoreSnapshot[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ForageInstance = ReturnType<typeof localforage.createInstance>
|
|
||||||
export type ForageStore = Pick<ForageInstance, 'keys' | 'getItem' | 'setItem' | 'clear'>
|
|
||||||
|
|
||||||
type XmInfoLike = {
|
|
||||||
projectName?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readWebStorage = (storageObj: Storage): DataEntry[] => {
|
|
||||||
const entries: DataEntry[] = []
|
|
||||||
for (let i = 0; i < storageObj.length; i++) {
|
|
||||||
const key = storageObj.key(i)
|
|
||||||
if (!key) continue
|
|
||||||
const raw = storageObj.getItem(key)
|
|
||||||
let value: any = raw
|
|
||||||
if (raw != null) {
|
|
||||||
try {
|
|
||||||
value = JSON.parse(raw)
|
|
||||||
} catch {
|
|
||||||
value = raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries.push({ key, value })
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
export const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
|
|
||||||
storageObj.clear()
|
|
||||||
for (const entry of entries || []) {
|
|
||||||
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
|
|
||||||
storageObj.setItem(entry.key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toPersistableValue = (value: unknown) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(JSON.stringify(value))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('normalize persist value failed, fallback to null:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readForage = async (store: ForageStore): Promise<DataEntry[]> => {
|
|
||||||
const keys = await store.keys()
|
|
||||||
const values = await Promise.all(keys.map(key => store.getItem(key)))
|
|
||||||
return keys.map((key, index) => ({
|
|
||||||
key,
|
|
||||||
value: toPersistableValue(values[index])
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const writeForage = async (store: ForageStore, entries: DataEntry[]) => {
|
|
||||||
await store.clear()
|
|
||||||
await Promise.all((entries || []).map(entry => store.setItem(entry.key, toPersistableValue(entry.value))))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeEntries = (value: unknown): DataEntry[] => {
|
|
||||||
if (!Array.isArray(value)) return []
|
|
||||||
return value
|
|
||||||
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
|
|
||||||
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeForageStoreSnapshots = (value: unknown): ForageStoreSnapshot[] => {
|
|
||||||
if (!Array.isArray(value)) return []
|
|
||||||
return value
|
|
||||||
.filter(item =>
|
|
||||||
item
|
|
||||||
&& typeof item === 'object'
|
|
||||||
&& typeof (item as any).storeName === 'string'
|
|
||||||
&& Array.isArray((item as any).entries)
|
|
||||||
)
|
|
||||||
.map(item => ({
|
|
||||||
storeName: String((item as any).storeName),
|
|
||||||
entries: normalizeEntries((item as any).entries)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sanitizeFileNamePart = (value: string): string => {
|
|
||||||
const cleaned = value
|
|
||||||
.replace(/[\\/:*?"<>|]/g, '_')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
return cleaned || i18n.global.t('tab.messages.defaultProjectName')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: string, legacyProjectDbKey: string) => {
|
|
||||||
const target =
|
|
||||||
entries.find(item => item.key === projectInfoDbKey) ||
|
|
||||||
entries.find(item => item.key === legacyProjectDbKey)
|
|
||||||
const data = (target?.value || {}) as XmInfoLike
|
|
||||||
return typeof data.projectName === 'string'
|
|
||||||
? sanitizeFileNamePart(data.projectName)
|
|
||||||
: i18n.global.t('tab.messages.defaultProjectName')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isDataPackageLike = (value: unknown): value is DataPackage => {
|
|
||||||
if (!value || typeof value !== 'object') return false
|
|
||||||
const payload = value as Partial<DataPackage>
|
|
||||||
const hasRequiredArrays =
|
|
||||||
Array.isArray(payload.localStorage) &&
|
|
||||||
Array.isArray(payload.sessionStorage) &&
|
|
||||||
Array.isArray(payload.localforageDefault)
|
|
||||||
if (!hasRequiredArrays) return false
|
|
||||||
if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) return false
|
|
||||||
if (payload.packageType != null && payload.packageType !== 'project-snapshot') return false
|
|
||||||
if (payload.projectId != null && typeof payload.projectId !== 'string') return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
.tab-strip-sortable > .tab-item {
|
|
||||||
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-strip-sortable.is-dragging > .tab-item {
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-drag-ghost {
|
|
||||||
opacity: 0.32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-drag-chosen {
|
|
||||||
transform: scale(1.015);
|
|
||||||
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-drag-active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-viewport']) {
|
|
||||||
scrollbar-width: none;
|
|
||||||
overflow-y: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-viewport']::-webkit-scrollbar) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-scrollbar'][data-orientation='vertical']),
|
|
||||||
.tab-strip-scroll-area :deep([data-slot='scroll-area-corner']) {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-dropdown-enter-active,
|
|
||||||
.toolbar-dropdown-leave-active {
|
|
||||||
transition: opacity 180ms ease, transform 180ms ease;
|
|
||||||
transform-origin: top right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-dropdown-enter-from,
|
|
||||||
.toolbar-dropdown-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-6px) scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar slide transitions */
|
|
||||||
.slide-in-left-enter-active,
|
|
||||||
.slide-in-left-leave-active {
|
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-in-left-enter-from,
|
|
||||||
.slide-in-left-leave-to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
export interface UserGuideStep {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
points: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type XmInfoLike = {
|
|
||||||
projectName?: unknown
|
|
||||||
preparedBy?: unknown
|
|
||||||
reviewedBy?: unknown
|
|
||||||
preparedDate?: unknown
|
|
||||||
projectIndustry?: unknown
|
|
||||||
preparedCompany?: unknown
|
|
||||||
overview?: unknown
|
|
||||||
desc?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HtBaseInfoLike = {
|
|
||||||
quality?: unknown
|
|
||||||
duration?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScaleRowLike {
|
|
||||||
id: string
|
|
||||||
amount: number | null
|
|
||||||
landArea: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface XmInfoStorageLike extends XmInfoLike {
|
|
||||||
detailRows?: ScaleRowLike[]
|
|
||||||
totalAmount?: number
|
|
||||||
roughCalcEnabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface XmScaleStorageLike {
|
|
||||||
detailRows?: ScaleRowLike[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContractCardItem {
|
|
||||||
id: string
|
|
||||||
name?: string
|
|
||||||
order?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ZxFwRowLike {
|
|
||||||
id: string
|
|
||||||
process?: unknown
|
|
||||||
subtotal?: unknown
|
|
||||||
finalFee?: unknown
|
|
||||||
investScale?: unknown
|
|
||||||
landScale?: unknown
|
|
||||||
workload?: unknown
|
|
||||||
hourly?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkContentRowLike {
|
|
||||||
id?: unknown
|
|
||||||
content?: unknown
|
|
||||||
checked?: unknown
|
|
||||||
custom?: unknown
|
|
||||||
serviceGroup?: unknown
|
|
||||||
serviceid?: unknown
|
|
||||||
isAddTrigger?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkContentStateLike {
|
|
||||||
detailRows?: WorkContentRowLike[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ZxFwStorageLike {
|
|
||||||
selectedIds?: string[]
|
|
||||||
selectedCodes?: string[]
|
|
||||||
detailRows?: ZxFwRowLike[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScaleMethodRowLike extends ScaleRowLike {
|
|
||||||
benchmarkBudgetBasicChecked?: unknown
|
|
||||||
benchmarkBudgetOptionalChecked?: unknown
|
|
||||||
basicFormula?: unknown
|
|
||||||
optionalFormula?: unknown
|
|
||||||
budgetFee?: unknown
|
|
||||||
budgetFeeBasic?: unknown
|
|
||||||
budgetFeeOptional?: unknown
|
|
||||||
consultCategoryFactor?: unknown
|
|
||||||
majorFactor?: unknown
|
|
||||||
workStageFactor?: unknown
|
|
||||||
workRatio?: unknown
|
|
||||||
remark?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HtFeeMainRowLike {
|
|
||||||
id?: unknown
|
|
||||||
name?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateMethodRowLike {
|
|
||||||
rate?: unknown
|
|
||||||
budgetFee?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuantityMethodRowLike {
|
|
||||||
id?: unknown
|
|
||||||
feeItem?: unknown
|
|
||||||
unit?: unknown
|
|
||||||
quantity?: unknown
|
|
||||||
unitPrice?: unknown
|
|
||||||
budgetFee?: number | null
|
|
||||||
remark?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkloadMethodRowLike {
|
|
||||||
id: string
|
|
||||||
budgetAdoptedUnitPrice?: unknown
|
|
||||||
workload?: unknown
|
|
||||||
basicFee?: unknown
|
|
||||||
consultCategoryFactor?: unknown
|
|
||||||
serviceFee?: unknown
|
|
||||||
remark?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HourlyMethodRowLike {
|
|
||||||
id: string
|
|
||||||
adoptedBudgetUnitPrice?: unknown
|
|
||||||
personnelCount?: unknown
|
|
||||||
workdayCount?: unknown
|
|
||||||
serviceBudget?: unknown
|
|
||||||
remark?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailRowsStorageLike<T> {
|
|
||||||
detailRows?: T[]
|
|
||||||
roughCalcEnabled?: boolean
|
|
||||||
totalAmount?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FactorRowLike {
|
|
||||||
id: string
|
|
||||||
standardFactor?: unknown
|
|
||||||
budgetValue?: unknown
|
|
||||||
remark?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportScaleRow {
|
|
||||||
major: number
|
|
||||||
cost: number | null
|
|
||||||
area: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod1Detail {
|
|
||||||
proNum: number
|
|
||||||
major: number
|
|
||||||
cost: number
|
|
||||||
basicFee: number
|
|
||||||
basicFormula: string
|
|
||||||
basicFee_basic: number
|
|
||||||
optionalFormula: string
|
|
||||||
basicFee_optional: number
|
|
||||||
serviceCoe: number
|
|
||||||
majorCoe: number
|
|
||||||
processCoe: number
|
|
||||||
proportion: number
|
|
||||||
fee: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod1 {
|
|
||||||
proAmount: number
|
|
||||||
cost: number
|
|
||||||
basicFee: number
|
|
||||||
basicFee_basic: number
|
|
||||||
basicFee_optional: number
|
|
||||||
fee: number
|
|
||||||
det: ExportMethod1Detail[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod2Detail {
|
|
||||||
proNum: number
|
|
||||||
major: number
|
|
||||||
area: number
|
|
||||||
basicFee: number
|
|
||||||
basicFormula: string
|
|
||||||
basicFee_basic: number
|
|
||||||
optionalFormula: string
|
|
||||||
basicFee_optional: number
|
|
||||||
serviceCoe: number
|
|
||||||
majorCoe: number
|
|
||||||
processCoe: number
|
|
||||||
proportion: number
|
|
||||||
fee: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod2 {
|
|
||||||
proAmount: number
|
|
||||||
area: number
|
|
||||||
basicFee: number
|
|
||||||
basicFee_basic: number
|
|
||||||
basicFee_optional: number
|
|
||||||
fee: number
|
|
||||||
det: ExportMethod2Detail[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod3Detail {
|
|
||||||
task: number
|
|
||||||
price: number
|
|
||||||
amount: number
|
|
||||||
basicFee: number
|
|
||||||
serviceCoe: number
|
|
||||||
fee: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod3 {
|
|
||||||
basicFee: number
|
|
||||||
fee: number
|
|
||||||
det: ExportMethod3Detail[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod4Detail {
|
|
||||||
expert: number
|
|
||||||
price: number
|
|
||||||
person_num: number
|
|
||||||
work_day: number
|
|
||||||
fee: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod4 {
|
|
||||||
person_num: number
|
|
||||||
work_day: number
|
|
||||||
fee: number
|
|
||||||
det: ExportMethod4Detail[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportService {
|
|
||||||
id: number
|
|
||||||
fee: number
|
|
||||||
finalFee: number
|
|
||||||
process: number
|
|
||||||
tasks: ExportTaskGroup[]
|
|
||||||
method1?: ExportMethod1
|
|
||||||
method2?: ExportMethod2
|
|
||||||
method3?: ExportMethod3
|
|
||||||
method4?: ExportMethod4
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportTaskGroup {
|
|
||||||
serviceid?: number
|
|
||||||
text: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportServiceCoe {
|
|
||||||
serviceid: number
|
|
||||||
coe: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMajorCoe {
|
|
||||||
majorid: number
|
|
||||||
coe: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportContract {
|
|
||||||
name: string
|
|
||||||
serviceFee: number
|
|
||||||
addtionalFee: number
|
|
||||||
reserveFee: number
|
|
||||||
fee: number
|
|
||||||
quality: string
|
|
||||||
duration: string
|
|
||||||
scale: ExportScaleRow[]
|
|
||||||
serviceCoes: ExportServiceCoe[]
|
|
||||||
majorCoes: ExportMajorCoe[]
|
|
||||||
services: ExportService[]
|
|
||||||
addtional: ExportAdditional | null
|
|
||||||
reserve: ExportReserve | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod0 {
|
|
||||||
coe: number
|
|
||||||
fee: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod5Detail {
|
|
||||||
name: string
|
|
||||||
unit: string
|
|
||||||
amount: number
|
|
||||||
price: number
|
|
||||||
fee: number
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportMethod5 {
|
|
||||||
fee: number
|
|
||||||
det: ExportMethod5Detail[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportAdditionalDetail {
|
|
||||||
id: number | string
|
|
||||||
code?: unknown
|
|
||||||
name: string
|
|
||||||
fee: number
|
|
||||||
tasks: ExportTaskGroup[]
|
|
||||||
m0?: ExportMethod0
|
|
||||||
m4?: ExportMethod4
|
|
||||||
m5?: ExportMethod5
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportAdditional {
|
|
||||||
code?: unknown
|
|
||||||
name: string
|
|
||||||
fee: number
|
|
||||||
det: ExportAdditionalDetail[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportReserve {
|
|
||||||
code?: unknown
|
|
||||||
name: string
|
|
||||||
fee: number
|
|
||||||
tasks: ExportTaskGroup[]
|
|
||||||
m0?: ExportMethod0
|
|
||||||
m4?: ExportMethod4
|
|
||||||
m5?: ExportMethod5
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportReportPayload {
|
|
||||||
name: string
|
|
||||||
writer: string
|
|
||||||
reviewer: string
|
|
||||||
company: string
|
|
||||||
date: string
|
|
||||||
industry: number
|
|
||||||
fee: number
|
|
||||||
scaleCost: number
|
|
||||||
overview: string
|
|
||||||
desc: string
|
|
||||||
scale: ExportScaleRow[]
|
|
||||||
serviceCoes: ExportServiceCoe[]
|
|
||||||
majorCoes: ExportMajorCoe[]
|
|
||||||
contracts: ExportContract[]
|
|
||||||
}
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { useTabStore } from '@/pinia/tab'
|
|
||||||
import { useKvStore } from '@/pinia/kv'
|
|
||||||
import { useUiPrefsStore } from '@/pinia/uiPrefs'
|
|
||||||
import {
|
|
||||||
BarChart3,
|
|
||||||
Calculator,
|
|
||||||
Check,
|
|
||||||
Languages,
|
|
||||||
X
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { getIndustryDisplayName, industryTypeList } from '@/sql'
|
|
||||||
import { initializeProjectFactorStates, initializeProjectScaleState } from '@/lib/projectWorkspace'
|
|
||||||
import {
|
|
||||||
buildProjectUrl,
|
|
||||||
DEFAULT_PROJECT_ID,
|
|
||||||
FORCE_HOME_QUERY_KEY,
|
|
||||||
NEW_PROJECT_QUERY_KEY,
|
|
||||||
OPEN_PROJECT_DIALOG_QUERY_KEY,
|
|
||||||
PROJECT_TAB_ID,
|
|
||||||
QUICK_PROJECT_ID,
|
|
||||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
|
||||||
QUICK_CONTRACT_FALLBACK_NAME,
|
|
||||||
QUICK_CONTRACT_ID,
|
|
||||||
QUICK_CONTRACT_META_KEY,
|
|
||||||
QUICK_MAJOR_FACTOR_KEY,
|
|
||||||
QUICK_PROJECT_INFO_KEY,
|
|
||||||
readCurrentProjectId,
|
|
||||||
writeProjectIdToUrl,
|
|
||||||
setPendingHomeImportFile,
|
|
||||||
writeWorkspaceMode
|
|
||||||
} from '@/lib/workspace'
|
|
||||||
import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry'
|
|
||||||
import { createProjectKvAdapter } from '@/lib/projectKvStore'
|
|
||||||
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
|
|
||||||
|
|
||||||
interface QuickProjectInfoState {
|
|
||||||
projectIndustry?: string
|
|
||||||
projectName?: string
|
|
||||||
preparedBy?: string
|
|
||||||
reviewedBy?: string
|
|
||||||
preparedCompany?: string
|
|
||||||
preparedDate?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuickContractMetaState {
|
|
||||||
id?: string
|
|
||||||
name?: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectInfoState {
|
|
||||||
projectIndustry?: string
|
|
||||||
projectName?: string
|
|
||||||
preparedBy?: string
|
|
||||||
reviewedBy?: string
|
|
||||||
preparedCompany?: string
|
|
||||||
preparedDate?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
|
||||||
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
|
||||||
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
|
|
||||||
const PROJECT_SCALE_KEY = 'xm-info-v3'
|
|
||||||
const getActiveProjectId = () => readCurrentProjectId()
|
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
|
||||||
const kvStore = useKvStore()
|
|
||||||
const uiPrefsStore = useUiPrefsStore()
|
|
||||||
const { t, locale } = useI18n()
|
|
||||||
const projectDialogOpen = ref(false)
|
|
||||||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
|
||||||
const projectSubmitting = ref(false)
|
|
||||||
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
|
||||||
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
|
||||||
const quickSubmitting = ref(false)
|
|
||||||
const homeImportInputRef = ref<HTMLInputElement | null>(null)
|
|
||||||
const homeImportConfirmOpen = ref(false)
|
|
||||||
const pendingHomeImportFile = ref<File | null>(null)
|
|
||||||
const pendingHomeImportFileName = ref('')
|
|
||||||
const existingProjectDialogOpen = ref(false)
|
|
||||||
const existingProjects = ref<Array<{ id: string; name: string; updatedAt: string }>>([])
|
|
||||||
const existingProjectLoading = ref(false)
|
|
||||||
const hasExistingProjects = ref(false)
|
|
||||||
const openedProjectIds = ref<string[]>([])
|
|
||||||
let existingProjectPollTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
const RELATED_FILES_URL = 'https://www.lianzhong.com.cn/file'
|
|
||||||
const localeBadge = computed(() => (locale.value === 'en-US' ? 'EN' : '中'))
|
|
||||||
const toggleLocale = () => {
|
|
||||||
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
|
|
||||||
uiPrefsStore.setLocale(next as 'zh-CN' | 'en-US')
|
|
||||||
}
|
|
||||||
|
|
||||||
const openRelatedFiles = () => {
|
|
||||||
window.open(RELATED_FILES_URL, '_blank', 'noopener')
|
|
||||||
}
|
|
||||||
const resolveProjectRegistryName = (projectIdRaw: string) => {
|
|
||||||
const projectId = String(projectIdRaw || '').trim()
|
|
||||||
if (projectId !== DEFAULT_PROJECT_ID) return undefined
|
|
||||||
return t('xmInfo.defaultProjectName')
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeCleanProjectUrl = (projectIdRaw: string) => {
|
|
||||||
try {
|
|
||||||
const href = buildProjectUrl(projectIdRaw, { forceHome: false, newProject: false })
|
|
||||||
window.history.replaceState({}, '', href)
|
|
||||||
} catch {
|
|
||||||
writeProjectIdToUrl(projectIdRaw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToWorkspace = (projectIdRaw: string, mode: 'project' | 'quick') => {
|
|
||||||
const projectId = String(projectIdRaw || '').trim()
|
|
||||||
if (!projectId) return false
|
|
||||||
writeWorkspaceMode(mode)
|
|
||||||
const currentProjectId = getActiveProjectId()
|
|
||||||
const shouldReloadApp = mode === 'project' && currentProjectId !== projectId
|
|
||||||
if (shouldReloadApp) {
|
|
||||||
window.location.href = buildProjectUrl(projectId, { forceHome: false, newProject: false })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
writeCleanProjectUrl(projectId)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTodayDateString = () => {
|
|
||||||
const now = new Date()
|
|
||||||
const year = String(now.getFullYear())
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(now.getDate()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatProjectEditedTime = (value: string) => {
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return '-'
|
|
||||||
const pad = (num: number) => String(num).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const enterProjectCalc = () => {
|
|
||||||
const projectId = getActiveProjectId()
|
|
||||||
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
|
||||||
if (!navigateToWorkspace(projectId, 'project')) return
|
|
||||||
tabStore.enterWorkspace({
|
|
||||||
id: PROJECT_TAB_ID,
|
|
||||||
title: t('home.projectCalcTab'),
|
|
||||||
componentName: 'ProjectCalcView'
|
|
||||||
})
|
|
||||||
tabStore.hasCompletedSetup = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadProjectDefaults = async () => {
|
|
||||||
const savedInfo = await kvStore.getItem<ProjectInfoState>(PROJECT_INFO_KEY)
|
|
||||||
projectIndustry.value =
|
|
||||||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
|
||||||
? savedInfo.projectIndustry.trim()
|
|
||||||
: String(industryTypeList[0]?.id || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const openProjectCalc = async () => {
|
|
||||||
|
|
||||||
projectDialogOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncExistingProjectOpenedState = (projectIds: string[]) => {
|
|
||||||
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(projectIds))
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExistingProjectOpened = (projectIdRaw: string) => {
|
|
||||||
const projectId = String(projectIdRaw || '').trim()
|
|
||||||
return projectId ? openedProjectIds.value.includes(projectId) : false
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshExistingProjects = async (options?: { showLoading?: boolean }) => {
|
|
||||||
if (options?.showLoading !== false) {
|
|
||||||
existingProjectLoading.value = true
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const projects = listProjects()
|
|
||||||
.filter(item => item.id !== QUICK_PROJECT_ID)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const left = new Date(a.updatedAt).getTime()
|
|
||||||
const right = new Date(b.updatedAt).getTime()
|
|
||||||
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0)
|
|
||||||
})
|
|
||||||
existingProjects.value = projects.map(project => ({
|
|
||||||
id: project.id,
|
|
||||||
name: project.name,
|
|
||||||
updatedAt: project.updatedAt
|
|
||||||
}))
|
|
||||||
hasExistingProjects.value = projects.length > 0
|
|
||||||
syncExistingProjectOpenedState(projects.map(project => project.id))
|
|
||||||
} finally {
|
|
||||||
if (options?.showLoading !== false) {
|
|
||||||
existingProjectLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopExistingProjectPolling = () => {
|
|
||||||
if (existingProjectPollTimer == null) return
|
|
||||||
clearInterval(existingProjectPollTimer)
|
|
||||||
existingProjectPollTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const startExistingProjectPolling = () => {
|
|
||||||
stopExistingProjectPolling()
|
|
||||||
existingProjectPollTimer = setInterval(() => {
|
|
||||||
if (!existingProjectDialogOpen.value) return
|
|
||||||
void refreshExistingProjects({ showLoading: false })
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openExistingProjectDialog = async () => {
|
|
||||||
projectDialogOpen.value = false
|
|
||||||
existingProjectDialogOpen.value = true
|
|
||||||
await refreshExistingProjects()
|
|
||||||
startExistingProjectPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeExistingProjectDialog = () => {
|
|
||||||
existingProjectDialogOpen.value = false
|
|
||||||
stopExistingProjectPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
const enterExistingProject = (projectIdRaw: string) => {
|
|
||||||
const projectId = String(projectIdRaw || '').trim()
|
|
||||||
if (!projectId || isExistingProjectOpened(projectId)) return
|
|
||||||
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
|
||||||
if (!navigateToWorkspace(projectId, 'project')) return
|
|
||||||
tabStore.enterWorkspace({
|
|
||||||
id: PROJECT_TAB_ID,
|
|
||||||
title: t('home.projectCalcTab'),
|
|
||||||
componentName: 'ProjectCalcView'
|
|
||||||
})
|
|
||||||
tabStore.hasCompletedSetup = true
|
|
||||||
closeExistingProjectDialog()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeProjectCalcDialog = () => {
|
|
||||||
projectDialogOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmProjectCalc = async () => {
|
|
||||||
const industry = projectIndustry.value.trim()
|
|
||||||
if (!industry) return
|
|
||||||
|
|
||||||
projectSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const project = createProject(t('xmInfo.defaultProjectName'))
|
|
||||||
const kvAdapter = createProjectKvAdapter(project.id)
|
|
||||||
await kvAdapter.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
|
|
||||||
projectIndustry: industry,
|
|
||||||
projectName: t('xmInfo.defaultProjectName'),
|
|
||||||
preparedBy: '',
|
|
||||||
reviewedBy: '',
|
|
||||||
preparedCompany: '',
|
|
||||||
preparedDate: getTodayDateString()
|
|
||||||
})
|
|
||||||
await initializeProjectFactorStates(
|
|
||||||
kvAdapter,
|
|
||||||
industry,
|
|
||||||
PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
|
|
||||||
PROJECT_MAJOR_FACTOR_KEY
|
|
||||||
)
|
|
||||||
await initializeProjectScaleState(kvAdapter, industry, PROJECT_SCALE_KEY)
|
|
||||||
writeWorkspaceMode('project')
|
|
||||||
window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false })
|
|
||||||
} finally {
|
|
||||||
projectSubmitting.value = false
|
|
||||||
projectDialogOpen.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadQuickDefaults = async () => {
|
|
||||||
const [savedInfo, savedMeta] = await Promise.all([
|
|
||||||
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
|
|
||||||
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
|
|
||||||
])
|
|
||||||
|
|
||||||
quickIndustry.value =
|
|
||||||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
|
||||||
? savedInfo.projectIndustry.trim()
|
|
||||||
: String(industryTypeList[0]?.id || '')
|
|
||||||
quickContractName.value =
|
|
||||||
typeof savedMeta?.name === 'string' && savedMeta.name.trim()
|
|
||||||
? savedMeta.name.trim()
|
|
||||||
: QUICK_CONTRACT_FALLBACK_NAME
|
|
||||||
}
|
|
||||||
|
|
||||||
const enterQuickCalc = (contractName: string) => {
|
|
||||||
if (!navigateToWorkspace(QUICK_PROJECT_ID, 'quick')) return
|
|
||||||
tabStore.enterWorkspace({
|
|
||||||
id: `contract-${QUICK_CONTRACT_ID}`,
|
|
||||||
title: t('home.quickCalcTab'),
|
|
||||||
componentName: 'QuickCalcWorkbenchView',
|
|
||||||
props: {
|
|
||||||
contractId: QUICK_CONTRACT_ID,
|
|
||||||
contractName,
|
|
||||||
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
|
||||||
projectScaleKey: null,
|
|
||||||
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
|
||||||
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
|
||||||
}
|
|
||||||
})
|
|
||||||
tabStore.hasCompletedSetup = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openQuickCalc = () => {
|
|
||||||
window.alert(t('home.cards.developing'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHomeImportChange = (event: Event) => {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
pendingHomeImportFile.value = file
|
|
||||||
pendingHomeImportFileName.value = file.name
|
|
||||||
homeImportConfirmOpen.value = true
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const openHomeImport = () => {
|
|
||||||
homeImportInputRef.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelHomeImportConfirm = () => {
|
|
||||||
homeImportConfirmOpen.value = false
|
|
||||||
pendingHomeImportFile.value = null
|
|
||||||
pendingHomeImportFileName.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmHomeImport = async () => {
|
|
||||||
const file = pendingHomeImportFile.value
|
|
||||||
if (!file) return
|
|
||||||
await setPendingHomeImportFile(file, { skipWorkspaceConfirm: true })
|
|
||||||
const targetProject = createProject(t('xmInfo.defaultProjectName'))
|
|
||||||
writeWorkspaceMode('project')
|
|
||||||
window.location.href = buildProjectUrl(targetProject.id, { forceHome: false, newProject: false })
|
|
||||||
cancelHomeImportConfirm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHomeWindowFocus = () => {
|
|
||||||
if (!existingProjectDialogOpen.value) return
|
|
||||||
void refreshExistingProjects({ showLoading: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHomeVisibilityChange = () => {
|
|
||||||
if (document.visibilityState !== 'visible') return
|
|
||||||
handleHomeWindowFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void refreshExistingProjects()
|
|
||||||
void loadProjectDefaults()
|
|
||||||
void loadQuickDefaults()
|
|
||||||
window.addEventListener('focus', handleHomeWindowFocus)
|
|
||||||
document.addEventListener('visibilitychange', handleHomeVisibilityChange)
|
|
||||||
try {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
const isNewProject = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
|
||||||
if (isNewProject) {
|
|
||||||
const projectId = getActiveProjectId()
|
|
||||||
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
|
||||||
const openProjectDialog = url.searchParams.get(OPEN_PROJECT_DIALOG_QUERY_KEY) !== '0'
|
|
||||||
if (openProjectDialog) {
|
|
||||||
void openProjectCalc()
|
|
||||||
}
|
|
||||||
url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
|
|
||||||
url.searchParams.delete(OPEN_PROJECT_DIALOG_QUERY_KEY)
|
|
||||||
url.searchParams.delete(FORCE_HOME_QUERY_KEY)
|
|
||||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore url parsing errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopExistingProjectPolling()
|
|
||||||
window.removeEventListener('focus', handleHomeWindowFocus)
|
|
||||||
document.removeEventListener('visibilitychange', handleHomeVisibilityChange)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<input ref="homeImportInputRef" type="file" accept=".zw" class="sr-only" @change="handleHomeImportChange" />
|
|
||||||
<div class="home-entry relative flex min-h-full items-center justify-center overflow-hidden px-4 py-8 lg:py-10">
|
|
||||||
<div class="pointer-events-none absolute inset-0 bg-[url('/background.png')] bg-cover bg-center bg-no-repeat" />
|
|
||||||
<div class="pointer-events-none absolute inset-0 bg-white/78" />
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(59,130,246,0.08),transparent_70%)]"
|
|
||||||
/>
|
|
||||||
<div class="relative w-full max-w-[1240px]">
|
|
||||||
<div class="absolute right-0 top-0 z-10">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-full border border-slate-200/80 bg-white/85 px-3 text-xs text-slate-600 shadow-sm backdrop-blur transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
|
||||||
@click="toggleLocale"
|
|
||||||
>
|
|
||||||
<Languages class="h-3.5 w-3.5" />
|
|
||||||
<span>{{ localeBadge }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="home-title text-center">
|
|
||||||
<h1
|
|
||||||
class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl"
|
|
||||||
style="font-family: HarmonyOS_Sans_SC, 'Microsoft YaHei', sans-serif; font-weight: 300; white-space: pre-line;"
|
|
||||||
>
|
|
||||||
{{ t('home.title') }}
|
|
||||||
</h1>
|
|
||||||
<p class="mt-1.5 text-sm text-slate-500">{{ t('home.subtitle') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 grid items-stretch gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<section
|
|
||||||
class="home-card-base home-entry-item home-entry-item--1 relative overflow-hidden rounded-2xl bg-[#dc2626] p-7 text-white shadow-[0_24px_60px_rgba(153,27,27,0.35)]"
|
|
||||||
>
|
|
||||||
<div class="pointer-events-none absolute -right-20 -top-16 h-56 w-56 rounded-full bg-white/12 blur-2xl" />
|
|
||||||
<div class="pointer-events-none absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-white/8 blur-3xl" />
|
|
||||||
<div v-for="index in 10" :key="index" :class="`home-hero-meteor home-hero-meteor--${index}`" />
|
|
||||||
<div class="relative inline-flex h-11 w-11 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/35">
|
|
||||||
<BarChart3 class="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<h2 class="relative mt-8 whitespace-pre-line text-xl font-semibold leading-tight tracking-tight">
|
|
||||||
{{ t('home.cards.heroTitle') }}
|
|
||||||
</h2>
|
|
||||||
<p class="relative mt-2 text-sm text-red-200/90">{{ t('home.cards.heroSubTitle') }}</p>
|
|
||||||
<div class="relative mt-6 h-px bg-white/20" />
|
|
||||||
<p class="relative mt-4 whitespace-pre-line text-xs leading-5 text-red-200/80">{{ t('home.cards.heroDesc') }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<article
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="home-card-base home-entry-item home-entry-item--2 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
|
||||||
@click="openProjectCalc"
|
|
||||||
@keydown.enter.prevent="openProjectCalc"
|
|
||||||
@keydown.space.prevent="openProjectCalc"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-blue-100 bg-blue-50/80 text-blue-600 shadow-sm transition-transform duration-200 group-hover:scale-105">
|
|
||||||
<BarChart3 class="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.projectBudget') }}</h3>
|
|
||||||
<p class="mt-1.5 text-xs leading-5 text-slate-500">{{ t('home.cards.projectBudgetDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex flex-wrap items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
v-if="hasExistingProjects"
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer rounded-md border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700"
|
|
||||||
@click.stop="openExistingProjectDialog"
|
|
||||||
>
|
|
||||||
{{ t('home.cards.pickExisting') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-blue-700"
|
|
||||||
@click.stop="openProjectCalc"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
{{ t('home.cards.enter') }}
|
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="home-card-base home-entry-item home-entry-item--3 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
|
||||||
@click="openQuickCalc"
|
|
||||||
@keydown.enter.prevent="openQuickCalc"
|
|
||||||
@keydown.space.prevent="openQuickCalc"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-amber-100 bg-amber-50/80 text-amber-600 shadow-sm transition-transform duration-200 group-hover:scale-105">
|
|
||||||
<Calculator class="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.quickCalc') }}</h3>
|
|
||||||
<p class="mt-1.5 text-xs leading-5 text-slate-500">{{ t('home.cards.quickCalcDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
|
||||||
<span>{{ t('home.cards.developing') }}</span>
|
|
||||||
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="home-card-base home-entry-item home-entry-item--4 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
|
||||||
@click="openRelatedFiles"
|
|
||||||
@keydown.enter.prevent="openRelatedFiles"
|
|
||||||
@keydown.space.prevent="openRelatedFiles"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-emerald-100 bg-emerald-50/80 shadow-sm transition-transform duration-200 group-hover:scale-105">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 1024 1024"
|
|
||||||
class="h-6 w-6"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M271.146667 128h-85.333334a64 64 0 0 0-64 64v640a64 64 0 0 0 64 64h85.333334a64 64 0 0 0 64-64V192a64 64 0 0 0-64-64z m21.333333 704a21.333333 21.333333 0 0 1-21.333333 21.333333h-85.333334a21.333333 21.333333 0 0 1-21.333333-21.333333V320h128z m0-554.666667h-128V192a21.333333 21.333333 0 0 1 21.333333-21.333333h85.333334a21.333333 21.333333 0 0 1 21.333333 21.333333zM901.12 808.533333L789.333333 178.346667a64 64 0 0 0-74.026666-51.84l-84.053334 14.72a64 64 0 0 0-44.16 31.146666A64 64 0 0 0 527.146667 128h-85.333334a64 64 0 0 0-64 64v640a64 64 0 0 0 64 64h85.333334a64 64 0 0 0 64-64V279.253333l99.84 566.4a64 64 0 0 0 74.24 51.84l84.053333-14.72a64 64 0 0 0 51.84-74.24zM548.48 832a21.333333 21.333333 0 0 1-21.333333 21.333333h-85.333334a21.333333 21.333333 0 0 1-21.333333-21.333333V320h128z m0-554.666667h-128V192a21.333333 21.333333 0 0 1 21.333333-21.333333h85.333334a21.333333 21.333333 0 0 1 21.333333 21.333333z m73.386667-69.333333A21.333333 21.333333 0 0 1 640 183.253333l83.2-14.72a21.333333 21.333333 0 0 1 24.746667 17.28l14.933333 84.053334-126.08 21.333333z m219.946666 632.746667l-84.053333 14.72a21.333333 21.333333 0 0 1-24.746667-17.28L644.266667 334.08l125.866666-21.333333 88.96 504.106666a21.333333 21.333333 0 0 1-17.28 23.893334z"
|
|
||||||
fill="#e0db84"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.relatedFiles') }}</h3>
|
|
||||||
<p class="mt-1.5 text-xs leading-5 text-slate-500">{{ t('home.cards.relatedFilesDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
|
||||||
<span>{{ t('home.cards.openRelatedFiles') }}</span>
|
|
||||||
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="existingProjectDialogOpen"
|
|
||||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
|
||||||
@click.self="closeExistingProjectDialog"
|
|
||||||
>
|
|
||||||
<div class="w-full max-w-lg rounded-3xl border border-slate-200/60 bg-white shadow-2xl">
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-100 px-6 pt-6 pb-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-bold text-[#1a1a1a]">{{ t('home.dialog.chooseExistingProject') }}</h3>
|
|
||||||
<p class="mt-1.5 text-base text-[#666]">{{ t('home.dialog.chooseExistingProjectDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
|
||||||
@click="closeExistingProjectDialog"
|
|
||||||
>
|
|
||||||
<X class="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-h-80 space-y-3 overflow-auto px-6 py-5">
|
|
||||||
<div
|
|
||||||
v-if="!existingProjectLoading && existingProjects.length === 0"
|
|
||||||
class="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-5 text-center text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
{{ t('home.dialog.noProjectYet') }}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-for="project in existingProjects"
|
|
||||||
:key="project.id"
|
|
||||||
type="button"
|
|
||||||
:disabled="isExistingProjectOpened(project.id)"
|
|
||||||
class="flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3.5 text-left transition hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
@click="enterExistingProject(project.id)"
|
|
||||||
>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="text-base font-medium text-[#1a1a1a]">
|
|
||||||
{{ project.name }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-[#888]">{{ project.id }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 pl-4 text-sm text-[#888]">
|
|
||||||
{{ t('tab.toolbar.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-slate-100 px-6 pt-4 pb-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer rounded-lg border border-slate-200 bg-white px-5 py-2.5 text-base font-medium text-[#666] transition hover:border-slate-300 hover:bg-slate-50"
|
|
||||||
@click="closeExistingProjectDialog"
|
|
||||||
>
|
|
||||||
{{ t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="projectDialogOpen"
|
|
||||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
|
||||||
@click.self="closeProjectCalcDialog"
|
|
||||||
>
|
|
||||||
<div class="w-full max-w-lg rounded-3xl border border-slate-200/60 bg-white shadow-2xl">
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-100 px-6 pt-6 pb-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-bold text-[#1a1a1a]">{{ t('home.dialog.newProject') }}</h3>
|
|
||||||
<p class="mt-1.5 text-base text-[#666]">选择工程行业后,进入项目计算页面</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
|
||||||
@click="closeProjectCalcDialog"
|
|
||||||
>
|
|
||||||
<X class="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6 pt-5 pb-4">
|
|
||||||
<h4 class="mb-3 text-base font-semibold text-[#1a1a1a]">{{ t('home.dialog.industry') }}</h4>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<button
|
|
||||||
v-for="item in industryTypeList"
|
|
||||||
:key="`project-${item.id}`"
|
|
||||||
type="button"
|
|
||||||
:class="[
|
|
||||||
'flex w-full items-center justify-between rounded-xl border px-4 py-3.5 text-left transition',
|
|
||||||
projectIndustry === String(item.id)
|
|
||||||
? 'border-[#1a1a1a] bg-white text-[#1a1a1a] shadow-sm'
|
|
||||||
: 'border-slate-200 bg-white text-[#1a1a1a] hover:border-slate-300'
|
|
||||||
]"
|
|
||||||
@click="projectIndustry = String(item.id)"
|
|
||||||
>
|
|
||||||
<span class="text-base">{{ getIndustryDisplayName(item.id, locale) }}</span>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'flex h-5 w-5 items-center justify-center rounded-md border transition',
|
|
||||||
projectIndustry === String(item.id)
|
|
||||||
? 'border-[#1a1a1a] bg-[#1a1a1a] text-white'
|
|
||||||
: 'border-slate-300 bg-white'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<Check v-if="projectIndustry === String(item.id)" class="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-slate-100 px-6 pt-4 pb-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer rounded-lg border border-slate-200 bg-white px-5 py-2.5 text-base font-medium text-[#666] transition hover:border-slate-300 hover:bg-slate-50"
|
|
||||||
@click="closeProjectCalcDialog; openExistingProjectDialog()"
|
|
||||||
>
|
|
||||||
{{ t('home.cards.pickExisting') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:disabled="projectSubmitting || !projectIndustry"
|
|
||||||
class="cursor-pointer rounded-lg bg-[#1a1a1a] px-5 py-2.5 text-base font-medium text-white transition hover:bg-[#2a2a2a] disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
@click="confirmProjectCalc"
|
|
||||||
>
|
|
||||||
{{ projectSubmitting ? t('home.dialog.entering') : t('home.dialog.enterProjectCalc') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="homeImportConfirmOpen"
|
|
||||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
|
||||||
@click.self="cancelHomeImportConfirm"
|
|
||||||
>
|
|
||||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
|
||||||
<div class=" px-5 py-4">
|
|
||||||
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.confirmImport') }}</h3>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
|
||||||
{{ t('home.dialog.confirmImportDesc', { file: pendingHomeImportFileName || t('home.cards.pickFile') }) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end gap-2 px-5 py-4">
|
|
||||||
<Button variant="outline" @click="cancelHomeImportConfirm">{{ t('common.cancel') }}</Button>
|
|
||||||
<Button variant="destructive" @click="confirmHomeImport">{{ t('home.dialog.confirmImportAction') }}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.home-hero {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
.home-card-base {
|
|
||||||
min-height: 248px;
|
|
||||||
}
|
|
||||||
.home-title {
|
|
||||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 18px 24px;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
|
||||||
border-radius: 20px;
|
|
||||||
background-image: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(59, 130, 246, 0.06), transparent 70%);
|
|
||||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
|
|
||||||
}
|
|
||||||
.home-entry-item {
|
|
||||||
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
||||||
}
|
|
||||||
.home-entry-item--1 { animation-delay: 0.2s; }
|
|
||||||
.home-entry-item--2 { animation-delay: 0.3s; }
|
|
||||||
.home-entry-item--3 { animation-delay: 0.4s; }
|
|
||||||
.home-entry-item--4 { animation-delay: 0.5s; }
|
|
||||||
.home-entry-item--5 { animation-delay: 0.6s; }
|
|
||||||
|
|
||||||
@keyframes hero-in {
|
|
||||||
from { opacity: 0; transform: translateX(-20px) scale(0.97); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
.home-hero-meteor {
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
width: 120px;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0));
|
|
||||||
transform: rotate(-28deg);
|
|
||||||
opacity: 0;
|
|
||||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
|
|
||||||
animation: hero-meteor 3.8s linear infinite;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--1 {
|
|
||||||
top: 16%;
|
|
||||||
right: -30%;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--2 {
|
|
||||||
top: 38%;
|
|
||||||
right: -40%;
|
|
||||||
animation-delay: 1.2s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--3 {
|
|
||||||
top: 62%;
|
|
||||||
right: -35%;
|
|
||||||
animation-delay: 2.2s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--4 {
|
|
||||||
top: 24%;
|
|
||||||
right: -45%;
|
|
||||||
animation-delay: 0.6s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--5 {
|
|
||||||
top: 50%;
|
|
||||||
right: -28%;
|
|
||||||
animation-delay: 1.7s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--6 {
|
|
||||||
top: 74%;
|
|
||||||
right: -42%;
|
|
||||||
animation-delay: 2.8s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--7 {
|
|
||||||
top: 10%;
|
|
||||||
right: -48%;
|
|
||||||
animation-delay: 0.35s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--8 {
|
|
||||||
top: 31%;
|
|
||||||
right: -26%;
|
|
||||||
animation-delay: 1.05s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--9 {
|
|
||||||
top: 56%;
|
|
||||||
right: -50%;
|
|
||||||
animation-delay: 2.45s;
|
|
||||||
}
|
|
||||||
.home-hero-meteor--10 {
|
|
||||||
top: 82%;
|
|
||||||
right: -30%;
|
|
||||||
animation-delay: 3.15s;
|
|
||||||
}
|
|
||||||
@keyframes hero-meteor {
|
|
||||||
0% { transform: translate3d(0, 0, 0) rotate(-28deg); opacity: 0; }
|
|
||||||
8% { opacity: 0.9; }
|
|
||||||
34% { opacity: 0.9; }
|
|
||||||
42% { opacity: 0; }
|
|
||||||
100% { transform: translate3d(-340px, 220px, 0) rotate(-28deg); opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes fade-up {
|
|
||||||
from { opacity: 0; transform: translateY(16px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<template>
|
|
||||||
<TypeLine
|
|
||||||
scene="ht-fee-method-type-line"
|
|
||||||
:title="titleText"
|
|
||||||
:subtitle="t('htFeeMethodTypeLine.contractId', { id: contractIdText })"
|
|
||||||
:copy-text="contractIdText"
|
|
||||||
:storage-key="activeTypeStorageKey"
|
|
||||||
default-category="rate-fee"
|
|
||||||
:categories="categories"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import TypeLine from '@/layout/typeLine.vue'
|
|
||||||
import HtFeeGrid from '@/features/shared/components/HtFeeGrid.vue'
|
|
||||||
import HtFeeRateMethodForm from '@/features/ht/components/HtFeeRateMethodForm.vue'
|
|
||||||
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
|
|
||||||
|
|
||||||
interface TypeLineCategoryItem {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
component: Component
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
sourceTitle?: string
|
|
||||||
storageKey: string
|
|
||||||
rowId: string | number
|
|
||||||
rowName?: string
|
|
||||||
contractId?: string
|
|
||||||
contractName?: string
|
|
||||||
}>()
|
|
||||||
const { t } = useI18n()
|
|
||||||
const sourceTitleText = computed(() => props.sourceTitle || t('htFeeMethodTypeLine.feeDetail'))
|
|
||||||
const rowNameText = computed(() => props.rowName || t('htFeeMethodTypeLine.unnamed'))
|
|
||||||
const rowIdText = computed(() => String(props.rowId || '').trim())
|
|
||||||
const contractIdText = computed(() => String(props.contractId || '').trim())
|
|
||||||
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
|
|
||||||
const titleText = computed(() => t('htFeeMethodTypeLine.title', {
|
|
||||||
contractName: contractNameText.value,
|
|
||||||
rowName: rowNameText.value || sourceTitleText.value
|
|
||||||
}))
|
|
||||||
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${rowIdText.value}`)
|
|
||||||
const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
|
|
||||||
`${props.storageKey}-${rowIdText.value}-${method}`
|
|
||||||
|
|
||||||
const quantityUnitPricePane = markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name: 'HtFeeGrid',
|
|
||||||
setup() {
|
|
||||||
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
|
|
||||||
return () =>
|
|
||||||
h(HtFeeGrid, {
|
|
||||||
title: t('htFeeMethodTypeLine.quantityUnitPrice'),
|
|
||||||
storageKey: quantityStorageKey.value,
|
|
||||||
htMainStorageKey: props.storageKey,
|
|
||||||
htRowId: rowIdText.value,
|
|
||||||
htMethodType: 'quantity-unit-price-fee'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const rateFeePane = markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name: 'HtFeeMethodTypePane-rate-fee',
|
|
||||||
setup() {
|
|
||||||
const rateStorageKey = computed(() => buildMethodStorageKey('rate-fee'))
|
|
||||||
return () =>
|
|
||||||
h(HtFeeRateMethodForm, {
|
|
||||||
storageKey: rateStorageKey.value,
|
|
||||||
contractId: props.contractId,
|
|
||||||
htMainStorageKey: props.storageKey,
|
|
||||||
htRowId: rowIdText.value,
|
|
||||||
htMethodType: 'rate-fee'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const hourlyFeePane = markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name: 'HtFeeMethodTypePane-hourly-fee',
|
|
||||||
setup() {
|
|
||||||
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
|
|
||||||
return () =>
|
|
||||||
h(HourlyFeeGrid, {
|
|
||||||
title: t('hourlyFeeGrid.title'),
|
|
||||||
storageKey: hourlyStorageKey.value,
|
|
||||||
htMainStorageKey: props.storageKey,
|
|
||||||
htRowId: rowIdText.value,
|
|
||||||
htMethodType: 'hourly-fee'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const isReserveFee = computed(() => props.sourceTitle === t('htSummary.reservePrefix'))
|
|
||||||
const showWorkContent = computed(() => {
|
|
||||||
if (isReserveFee.value) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const workContentPane = markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name: 'WorkContentPane',
|
|
||||||
setup() {
|
|
||||||
const AsyncWorkContentGrid = defineAsyncComponent({
|
|
||||||
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
|
|
||||||
onError: err => {
|
|
||||||
console.error('加载 WorkContentGrid 组件失败:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => h(AsyncWorkContentGrid, {
|
|
||||||
title: t('workContent.title'),
|
|
||||||
storageKey: `work-content-${props.storageKey}-${rowIdText.value}`,
|
|
||||||
dictMode: 'additional'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const categories = computed<TypeLineCategoryItem[]>(() => {
|
|
||||||
const base: TypeLineCategoryItem[] = [
|
|
||||||
{ key: 'rate-fee', label: t('htFeeGrid.columns.rateFee'), component: rateFeePane },
|
|
||||||
{ key: 'hourly-fee', label: t('htFeeGrid.columns.hourlyFee'), component: hourlyFeePane },
|
|
||||||
{ key: 'quantity-unit-price-fee', label: t('htFeeGrid.columns.quantityUnitPriceFee'), component: quantityUnitPricePane },
|
|
||||||
]
|
|
||||||
if (showWorkContent.value) {
|
|
||||||
base.push({ key: 'work-content', label: t('workContent.title'), component: workContentPane })
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
<template>
|
|
||||||
<TypeLine
|
|
||||||
scene="zxfw-pricing-tab"
|
|
||||||
:title="`${contractName ? `${t('zxFwView.contractPrefix', { name: contractName })} · ` : ''}${fwName}${t('zxFwView.calcSuffix')}`"
|
|
||||||
:subtitle="t('zxFwView.contractId', { id: contractId })"
|
|
||||||
:copy-text="contractId"
|
|
||||||
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
|
|
||||||
:default-category="defaultCategory"
|
|
||||||
:categories="pricingCategories"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import TypeLine from '@/layout/typeLine.vue'
|
|
||||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
|
||||||
import ScaleFormulaReadonlyPane from '@/features/pricing/components/ScaleFormulaReadonlyPane.vue'
|
|
||||||
|
|
||||||
interface ServiceMethodType {
|
|
||||||
scale?: boolean | null
|
|
||||||
onlyCostScale?: boolean | null
|
|
||||||
amount?: boolean | null
|
|
||||||
workDay?: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
contractId: string
|
|
||||||
contractName?: string
|
|
||||||
serviceId: string|number
|
|
||||||
fwName:string
|
|
||||||
type?: ServiceMethodType
|
|
||||||
projectInfoKey?: string
|
|
||||||
}>()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
interface PricingCategoryItem {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
component: Component
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) =>
|
|
||||||
typeof value === 'boolean' ? value : fallback
|
|
||||||
|
|
||||||
const methodAvailability = computed(() => {
|
|
||||||
const scale = resolveMethodEnabled(props.type?.scale, true)
|
|
||||||
const onlyCostScale = resolveMethodEnabled(props.type?.onlyCostScale, false)
|
|
||||||
const amount = resolveMethodEnabled(props.type?.amount, true)
|
|
||||||
const workDay = resolveMethodEnabled(props.type?.workDay, true)
|
|
||||||
|
|
||||||
return {
|
|
||||||
investmentScale: scale,
|
|
||||||
landScale: scale && !onlyCostScale,
|
|
||||||
workload: amount,
|
|
||||||
hourly: workDay
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const createPricingPane = (name: string) =>
|
|
||||||
markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name,
|
|
||||||
setup() {
|
|
||||||
const AsyncPricingView = defineAsyncComponent({
|
|
||||||
loader: () => import(`@/features/pricing/components/${name}.vue`),
|
|
||||||
onError: err => {
|
|
||||||
console.error('load PricingMethodView failed:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => h(AsyncPricingView, {
|
|
||||||
contractId: props.contractId,
|
|
||||||
contractName: props.contractName,
|
|
||||||
serviceId: props.serviceId,
|
|
||||||
projectInfoKey: props.projectInfoKey
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const createMethodUnavailablePane = (title: string, message: string) =>
|
|
||||||
markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name: 'MethodUnavailablePane',
|
|
||||||
setup() {
|
|
||||||
return () => h(MethodUnavailableNotice, { title, message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
|
|
||||||
const landScaleView = createPricingPane('LandScalePricingPane')
|
|
||||||
const workloadView = createPricingPane('WorkloadPricingPane')
|
|
||||||
const hourlyView = createPricingPane('HourlyPricingPane')
|
|
||||||
const otherService = createPricingPane('OtherService')
|
|
||||||
|
|
||||||
const createScaleFormulaPane = (
|
|
||||||
method: 'investScale' | 'landScale',
|
|
||||||
name: 'InvestmentScaleFormulaPane' | 'LandScaleFormulaPane'
|
|
||||||
) =>
|
|
||||||
markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name,
|
|
||||||
setup() {
|
|
||||||
return () => h(ScaleFormulaReadonlyPane, {
|
|
||||||
contractId: props.contractId,
|
|
||||||
serviceId: props.serviceId,
|
|
||||||
method
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const investmentScaleFormulaView = createScaleFormulaPane('investScale', 'InvestmentScaleFormulaPane')
|
|
||||||
const landScaleFormulaView = createScaleFormulaPane('landScale', 'LandScaleFormulaPane')
|
|
||||||
|
|
||||||
const workContentPane = markRaw(
|
|
||||||
defineComponent({
|
|
||||||
name: 'WorkContentPane',
|
|
||||||
setup() {
|
|
||||||
const AsyncWorkContentGrid = defineAsyncComponent({
|
|
||||||
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
|
|
||||||
onError: err => {
|
|
||||||
console.error('load WorkContentGrid failed:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => h(AsyncWorkContentGrid, {
|
|
||||||
title: t('zxFwView.workContentTitle'),
|
|
||||||
storageKey: `work-content-${props.contractId}-${props.serviceId}`,
|
|
||||||
contractId: props.contractId,
|
|
||||||
projectInfoKey: props.projectInfoKey,
|
|
||||||
serviceId: props.serviceId,
|
|
||||||
dictMode: 'service',
|
|
||||||
"show-no-column": true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const investmentScaleUnavailableView = createMethodUnavailablePane(
|
|
||||||
t('zxFwView.unavailable.investmentScaleTitle'),
|
|
||||||
t('zxFwView.unavailable.investmentScaleMessage')
|
|
||||||
)
|
|
||||||
const landScaleUnavailableView = createMethodUnavailablePane(
|
|
||||||
t('zxFwView.unavailable.landScaleTitle'),
|
|
||||||
t('zxFwView.unavailable.landScaleMessage')
|
|
||||||
)
|
|
||||||
const workloadUnavailableView = createMethodUnavailablePane(
|
|
||||||
t('zxFwView.unavailable.workloadTitle'),
|
|
||||||
t('zxFwView.unavailable.workloadMessage')
|
|
||||||
)
|
|
||||||
const hourlyUnavailableView = createMethodUnavailablePane(
|
|
||||||
t('zxFwView.unavailable.hourlyTitle'),
|
|
||||||
t('zxFwView.unavailable.hourlyMessage')
|
|
||||||
)
|
|
||||||
|
|
||||||
const pricingCategories = computed<PricingCategoryItem[]>(() => [
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'investment-scale-method',
|
|
||||||
label: t('zxFwView.categories.investmentScale'),
|
|
||||||
component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView
|
|
||||||
},
|
|
||||||
/*{
|
|
||||||
key: 'investment-scale-formula',
|
|
||||||
label: t('zxFwView.categories.investmentScaleFormula'),
|
|
||||||
component: methodAvailability.value.investmentScale ? investmentScaleFormulaView : investmentScaleUnavailableView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'land-scale-method',
|
|
||||||
label: t('zxFwView.categories.landScale'),
|
|
||||||
component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'land-scale-formula',
|
|
||||||
label: t('zxFwView.categories.landScaleFormula'),
|
|
||||||
component: methodAvailability.value.landScale ? landScaleFormulaView : landScaleUnavailableView
|
|
||||||
},*/
|
|
||||||
{
|
|
||||||
key: 'workload-method',
|
|
||||||
label: t('zxFwView.categories.workload'),
|
|
||||||
component: methodAvailability.value.workload ? workloadView : workloadUnavailableView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'hourly-method',
|
|
||||||
label: t('zxFwView.categories.hourly'),
|
|
||||||
component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'work-content',
|
|
||||||
label: t('zxFwView.categories.workContent'),
|
|
||||||
component: workContentPane
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'other-service',
|
|
||||||
label: t('zxFwView.categories.otherService'),
|
|
||||||
component: otherService
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const defaultCategory = computed(() => {
|
|
||||||
const m = methodAvailability.value
|
|
||||||
if (m.investmentScale) return 'investment-scale-method'
|
|
||||||
if (m.landScale) return 'land-scale-method'
|
|
||||||
if (m.workload) return 'workload-method'
|
|
||||||
if (m.hourly) return 'hourly-method'
|
|
||||||
return 'work-content'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<template>
|
|
||||||
<TypeLine
|
|
||||||
scene="xm-tab"
|
|
||||||
title=""
|
|
||||||
storage-key="project-active-cat"
|
|
||||||
default-category="info"
|
|
||||||
:categories="xmCategories"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, defineAsyncComponent, markRaw } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import TypeLine from '@/layout/typeLine.vue'
|
|
||||||
const { t } = useI18n()
|
|
||||||
const infoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/info.vue')))
|
|
||||||
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmInfo.vue')))
|
|
||||||
const htView = markRaw(defineAsyncComponent(() => import('@/features/ht/components/Ht.vue')))
|
|
||||||
const consultCategoryFactorView = markRaw(
|
|
||||||
defineAsyncComponent(() => import('@/features/xm/components/XmConsultCategoryFactor.vue'))
|
|
||||||
)
|
|
||||||
const majorFactorView = markRaw(
|
|
||||||
defineAsyncComponent(() => import('@/features/xm/components/XmMajorFactor.vue'))
|
|
||||||
)
|
|
||||||
|
|
||||||
const xmCategories = computed(() => [
|
|
||||||
{ key: 'info', label: t('xmCard.categories.info'), component: infoView },
|
|
||||||
{ key: 'scale-info', label: t('xmCard.categories.scaleInfo'), component: scaleInfoView },
|
|
||||||
// { key: 'consult-category-factor', label: t('xmCard.categories.consultCategoryFactor'), component: consultCategoryFactorView },
|
|
||||||
// { key: 'major-factor', label: t('xmCard.categories.majorFactor'), component: majorFactorView },
|
|
||||||
{ key: 'contract', label: t('xmCard.categories.contract'), component: htView }
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
export const MAJOR_NAME_EN_BY_CODE: Record<string, string> = {
|
|
||||||
E1: 'General Transportation Engineering',
|
|
||||||
'E1-1': 'Land (Sea) Acquisition Compensation',
|
|
||||||
'E1-2': 'Relocation Compensation',
|
|
||||||
'E1-3': 'Relocation/Utility Diversion Works',
|
|
||||||
'E1-4': 'Other Construction Expenses',
|
|
||||||
'E1-5': 'Contingency',
|
|
||||||
'E1-6': 'Construction Loan Interest',
|
|
||||||
E2: 'Highway Engineering',
|
|
||||||
'E2-1': 'Temporary Works',
|
|
||||||
'E2-2': 'Subgrade Works',
|
|
||||||
'E2-3': 'Pavement Works',
|
|
||||||
'E2-4': 'Bridge and Culvert Works',
|
|
||||||
'E2-5': 'Tunnel Works',
|
|
||||||
'E2-6': 'Interchange Works',
|
|
||||||
'E2-7': 'MEP Works',
|
|
||||||
'E2-8': 'Traffic Safety Facilities',
|
|
||||||
'E2-9': 'Landscaping and Environmental Works',
|
|
||||||
'E2-10': 'Building Works',
|
|
||||||
E3: 'Railway Engineering',
|
|
||||||
'E3-1': 'Large Temporary Facilities and Transitional Works',
|
|
||||||
'E3-2': 'Subgrade Works',
|
|
||||||
'E3-3': 'Bridge and Culvert Works',
|
|
||||||
'E3-4': 'Tunnel and Cut-and-Cover Works',
|
|
||||||
'E3-5': 'Track Works',
|
|
||||||
'E3-6': 'Communication, Signaling, Information and Disaster Monitoring',
|
|
||||||
'E3-7': 'Power and Traction Power Supply Works',
|
|
||||||
'E3-8': 'Building Works (Buildings and Ancillary Works)',
|
|
||||||
'E3-9': 'Interior Decoration Works',
|
|
||||||
E4: 'Waterway Engineering',
|
|
||||||
'E4-1': 'Temporary Works',
|
|
||||||
'E4-2': 'Civil Works',
|
|
||||||
'E4-3': 'Mechanical, Electrical and Steel Structure Works',
|
|
||||||
'E4-4': 'Equipment Works',
|
|
||||||
'E4-5': 'Ancillary Building Works (Buildings and Ancillary Works)'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SERVICE_NAME_EN_BY_CODE: Record<string, string> = {
|
|
||||||
D1: 'Whole-Process Cost Consulting',
|
|
||||||
D2: 'Stage-Based Cost Consulting',
|
|
||||||
'D2-1': 'Early-Stage Cost Consulting',
|
|
||||||
'D2-2-1': 'Implementation-Stage Cost Consulting (Highway/Waterway)',
|
|
||||||
'D2-2-2': 'Implementation-Stage Cost Consulting (Railway)',
|
|
||||||
D3: 'Basic Cost Consulting',
|
|
||||||
'D3-1': 'Investment Estimate',
|
|
||||||
'D3-2': 'Design Estimate',
|
|
||||||
'D3-3': 'Construction Drawing Budget',
|
|
||||||
'D3-4': 'BOQ and BOQ Budget (or Max Bid Price)',
|
|
||||||
'D3-5': 'Estimate Review/Reconciliation (Railway Only)',
|
|
||||||
'D3-6-1': 'Contract (Project) Settlement',
|
|
||||||
'D3-6-2': 'Contract (Project) Settlement',
|
|
||||||
'D3-7': 'Final Account',
|
|
||||||
D4: 'Specialized Cost Consulting',
|
|
||||||
'D4-1': 'Cost Advisory Service',
|
|
||||||
'D4-2': 'Cost Policy Formulation/Revision',
|
|
||||||
'D4-3': 'Cost Science and Technology Research',
|
|
||||||
'D4-4': 'Quota Determination',
|
|
||||||
'D4-5': 'Cost Information Consulting',
|
|
||||||
'D4-6': 'Cost Appraisal',
|
|
||||||
'D4-7': 'Cost Estimation',
|
|
||||||
'D4-8': 'Cost Accounting',
|
|
||||||
'D4-9': 'Quantity Takeoff',
|
|
||||||
'D4-10': 'Variation Cost Consulting',
|
|
||||||
'D4-11': 'Adjusted Estimate',
|
|
||||||
'D4-12': 'Adjusted Budget Estimate',
|
|
||||||
'D4-13': 'Cost Inspection',
|
|
||||||
'D4-14': 'Other Specialized Consulting',
|
|
||||||
'D4-15-1': 'Cost Data Validation (Estimate)',
|
|
||||||
'D4-15-2': 'Cost Data Validation (Budget Estimate)',
|
|
||||||
'D4-15-3': 'Cost Data Validation (Construction Drawing Budget)',
|
|
||||||
'D4-15-4': 'Cost Data Validation (BOQ and BOQ Budget)',
|
|
||||||
'D4-15-5': 'Cost Data Validation (Estimate Review, Railway Only)',
|
|
||||||
'D4-15-6': 'Cost Data Validation (Contract Settlement)',
|
|
||||||
'D4-15-7': 'Cost Data Validation (Contract Settlement)',
|
|
||||||
'D4-15-8': 'Cost Data Validation (Final Account)'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TASK_NAME_EN_BY_CODE: Record<string, string> = {
|
|
||||||
'C4-1': 'Daily Cost Advisory',
|
|
||||||
'C4-2': 'Special Cost Advisory',
|
|
||||||
'C5-1': 'Organization and Research',
|
|
||||||
'C5-2-1': 'Document Drafting',
|
|
||||||
'C5-2-2': 'Document Drafting',
|
|
||||||
'C5-3-1': 'Review',
|
|
||||||
'C5-3-2': 'Review',
|
|
||||||
'C5-3-3': 'Review',
|
|
||||||
'C6-1': 'Organization and Research',
|
|
||||||
'C6-2-1': 'Research and Report Writing',
|
|
||||||
'C6-2-2': 'Research and Report Writing',
|
|
||||||
'C6-2-3': 'Research and Report Writing',
|
|
||||||
'C6-3-1': 'Standards/Guideline Drafting',
|
|
||||||
'C6-3-2': 'Standards/Guideline Drafting',
|
|
||||||
'C6-3-3': 'Standards/Guideline Drafting',
|
|
||||||
'C6-3-4': 'Standards/Guideline Drafting',
|
|
||||||
'C6-4-1': 'Review and Acceptance',
|
|
||||||
'C6-4-2': 'Review and Acceptance',
|
|
||||||
'C6-4-3': 'Review and Acceptance',
|
|
||||||
'C6-5-1': 'Training and Communication',
|
|
||||||
'C6-5-2': 'Training and Communication',
|
|
||||||
'C7-1': 'Organization and Research',
|
|
||||||
'C7-2': 'Outline Preparation',
|
|
||||||
'C7-3': 'Data Collection and Measurement',
|
|
||||||
'C7-4-1': 'Data Processing and Analysis',
|
|
||||||
'C7-4-2': 'Data Processing and Analysis',
|
|
||||||
'C7-5': 'Quota Determination Report',
|
|
||||||
'C7-6-1': 'Quota Text and Notes Drafting',
|
|
||||||
'C7-6-2': 'Quota Text and Notes Drafting',
|
|
||||||
'C7-7-1': 'Review and Acceptance',
|
|
||||||
'C7-7-2': 'Review and Acceptance',
|
|
||||||
'C7-7-3': 'Review and Acceptance',
|
|
||||||
'C7-8-1': 'Training and Communication',
|
|
||||||
'C7-8-2': 'Training and Communication',
|
|
||||||
'C8-1': 'Q ≤ 10',
|
|
||||||
'C8-2': '10 < Q ≤ 30',
|
|
||||||
'C8-3': '30 < Q ≤ 50',
|
|
||||||
'C8-4': '50 < Q ≤ 100',
|
|
||||||
'C8-5': 'Q > 100'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EXPERT_NAME_EN_BY_CODE: Record<string, string> = {
|
|
||||||
'C9-1-1': 'Technician and Others',
|
|
||||||
'C9-1-2': 'Assistant Engineer',
|
|
||||||
'C9-1-3': 'Intermediate Engineer or Level-2 Cost Engineer',
|
|
||||||
'C9-1-4': 'Senior Engineer or Level-1 Cost Engineer',
|
|
||||||
'C9-1-5': 'Professor-Level Senior Engineer or Senior Expert',
|
|
||||||
'C9-2-1': 'Level-2 Cost Engineer + Intermediate Engineer',
|
|
||||||
'C9-3-1': 'Level-1 Cost Engineer + Intermediate Engineer',
|
|
||||||
'C9-3-2': 'Level-1 Cost Engineer + Senior Engineer'
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { createI18n } from 'vue-i18n'
|
|
||||||
import { enUS } from './locales/en-US'
|
|
||||||
import { zhCN } from './locales/zh-CN'
|
|
||||||
|
|
||||||
export const I18N_LOCALE_KEY = 'jgjs-locale-v1'
|
|
||||||
export const DEFAULT_LOCALE = 'zh-CN'
|
|
||||||
|
|
||||||
const messages = {
|
|
||||||
'zh-CN': zhCN,
|
|
||||||
'en-US': enUS
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type AppLocale = keyof typeof messages
|
|
||||||
|
|
||||||
const getInitialLocale = (): AppLocale => {
|
|
||||||
if (typeof window === 'undefined') return DEFAULT_LOCALE
|
|
||||||
const saved = String(localStorage.getItem(I18N_LOCALE_KEY) || '').trim() as AppLocale
|
|
||||||
if (saved in messages) return saved
|
|
||||||
const language = String(navigator.language || '').toLowerCase()
|
|
||||||
return language.startsWith('en') ? 'en-US' : DEFAULT_LOCALE
|
|
||||||
}
|
|
||||||
|
|
||||||
export const i18n = createI18n({
|
|
||||||
legacy: false,
|
|
||||||
locale: getInitialLocale(),
|
|
||||||
fallbackLocale: DEFAULT_LOCALE,
|
|
||||||
messages
|
|
||||||
})
|
|
||||||
|
|
||||||
export const setAppLocale = (locale: AppLocale) => {
|
|
||||||
i18n.global.locale.value = locale
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(I18N_LOCALE_KEY, locale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,687 +0,0 @@
|
|||||||
export const enUS = {
|
|
||||||
common: {
|
|
||||||
cancel: 'Cancel',
|
|
||||||
confirm: 'Confirm',
|
|
||||||
delete: 'Delete',
|
|
||||||
close: 'Close',
|
|
||||||
clear: 'Clear',
|
|
||||||
loading: 'Loading...'
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
projectConflict: {
|
|
||||||
title: 'Project Already Open',
|
|
||||||
desc: 'Project "{name}" is already active in another tab. Editing is blocked here to avoid IndexedDB conflicts.',
|
|
||||||
countdown: 'This page will try to close automatically in {seconds} seconds. You can open another project in a new tab first.',
|
|
||||||
opened: '(Opened)',
|
|
||||||
lastEdited: 'Last edited: {time}',
|
|
||||||
openDefault: 'Open Default Project',
|
|
||||||
createAndOpen: 'Create and Open'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
home: {
|
|
||||||
title: 'Engineering Calculation Entry',
|
|
||||||
subtitle: 'Project Budget · Quick Calc · Import Data',
|
|
||||||
projectCalcTab: 'Project Calculation',
|
|
||||||
quickCalcTab: 'Quick Calculation',
|
|
||||||
cards: {
|
|
||||||
heroTitle: 'One-Click Smart Budget',
|
|
||||||
heroSubTitle: 'Accelerate standards adoption',
|
|
||||||
heroDesc: 'Cost consulting fee calculator for transport construction projects',
|
|
||||||
projectBudget: 'Project Budget',
|
|
||||||
projectBudgetDesc: 'For full project-level calculation across multiple contracts with import/export support',
|
|
||||||
quickCalc: 'Quick Calc',
|
|
||||||
quickCalcDesc: 'Suitable for single service trial, select industry, consultation type, engineering specialty, input base number and get results in seconds',
|
|
||||||
importData: 'Import Data',
|
|
||||||
importDataDesc: 'Import a ".zw" package and create a new project automatically to restore the data without overriding existing projects',
|
|
||||||
enter: 'Enter',
|
|
||||||
developing: 'In Development',
|
|
||||||
pickFile: 'Choose File',
|
|
||||||
pickExisting: 'Choose Existing',
|
|
||||||
relatedFiles: 'Related Files',
|
|
||||||
relatedFilesDesc: 'View related fee documents, tender files, contract files, service content, and work requirements',
|
|
||||||
openRelatedFiles: 'Open Page'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
newProject: 'New Project',
|
|
||||||
chooseIndustryDesc: 'Choose an industry and enter project calculation directly.',
|
|
||||||
industry: 'Industry',
|
|
||||||
selectIndustry: 'Select industry',
|
|
||||||
entering: 'Entering...',
|
|
||||||
enterProjectCalc: 'Enter Project Calculation',
|
|
||||||
confirmImport: 'Confirm Import',
|
|
||||||
confirmImportDesc: 'Import "{file}"',
|
|
||||||
confirmImportAction: 'Import and Create Project',
|
|
||||||
chooseExistingProject: 'Choose Existing Project',
|
|
||||||
chooseExistingProjectDesc: 'Select a project from the list and enter workspace directly.',
|
|
||||||
noProjectYet: 'No project available. Create a new project first.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tab: {
|
|
||||||
toolbar: {
|
|
||||||
light: 'Light',
|
|
||||||
dark: 'Dark',
|
|
||||||
language: 'Lang',
|
|
||||||
importExport: 'Import/Export',
|
|
||||||
importData: 'Import',
|
|
||||||
exportData: 'Export',
|
|
||||||
exportReport: 'Export Report',
|
|
||||||
userGuide: 'Guide',
|
|
||||||
reset: 'Reset',
|
|
||||||
resetting: 'Resetting...',
|
|
||||||
projectList: 'Projects',
|
|
||||||
projectCount: 'Projects: {count}',
|
|
||||||
createProject: 'New Project',
|
|
||||||
backHome: 'Back Home',
|
|
||||||
resetAll: 'Reset All',
|
|
||||||
opened: '(Opened)',
|
|
||||||
lastEdited: 'Last edited: {time}'
|
|
||||||
},
|
|
||||||
menu: {
|
|
||||||
closeAll: 'Close All',
|
|
||||||
closeLeft: 'Close Left',
|
|
||||||
closeRight: 'Close Right',
|
|
||||||
closeOther: 'Close Others'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
resetTitle: 'Confirm Reset',
|
|
||||||
resetDesc: 'All project data will be cleared and the default page will be restored. Continue?',
|
|
||||||
confirmReset: 'Confirm Reset',
|
|
||||||
importOverrideTitle: 'Confirm Override Import',
|
|
||||||
importOverrideDesc: 'Use "{file}" to override all local data for current project. Continue?',
|
|
||||||
confirmOverride: 'Confirm Override',
|
|
||||||
newProjectTitle: 'New Project',
|
|
||||||
newProjectDesc: 'Choose an industry, then open the new project calculation page in a new tab.',
|
|
||||||
createAndOpen: 'Create & Open',
|
|
||||||
creating: 'Creating...',
|
|
||||||
projectLimitTitle: 'Project Limit Reached',
|
|
||||||
projectLimitDesc: 'Project count has reached {max}. Delete one project before adding a new one.',
|
|
||||||
iKnow: 'OK',
|
|
||||||
deleteProjectTitle: 'Confirm Delete Project',
|
|
||||||
deleteCurrentProjectDesc: 'Delete current project "{name}"? Data will be cleared and you will return to home.',
|
|
||||||
deleteProjectDesc: 'Delete project "{name}"? This will remove local data for that project.'
|
|
||||||
},
|
|
||||||
guide: {
|
|
||||||
title: 'User Guide',
|
|
||||||
later: 'Later',
|
|
||||||
prev: 'Prev',
|
|
||||||
next: 'Next',
|
|
||||||
finish: 'Finish and Disable Auto Popup',
|
|
||||||
jumpToStep: 'Jump to step {index}',
|
|
||||||
steps: {
|
|
||||||
step1: {
|
|
||||||
title: 'Project Calculation Overview',
|
|
||||||
description: 'This guide only explains the main project-calculation flow: project setup -> contract segments -> service pricing -> report export.',
|
|
||||||
point1: 'The entry is the "Project Card", and the left flow line guides you through the setup in order.',
|
|
||||||
point2: 'Forms and grids auto-save locally, so manual save is usually unnecessary.',
|
|
||||||
point3: 'Project-level data affects later segment calculation and report output, so fill it first.'
|
|
||||||
},
|
|
||||||
step2: {
|
|
||||||
title: 'Project-Level Setup',
|
|
||||||
description: 'Project-level setup mainly includes Basic Info, Scale Info, Consult Category Factor, and Major Factor.',
|
|
||||||
point1: 'Basic Info: maintain project name, industry, and other base data.',
|
|
||||||
point2: 'Scale Info: fill project scale by major as input for later budget values.',
|
|
||||||
point3: 'The two factor pages maintain budget values and notes used to adjust calculation.'
|
|
||||||
},
|
|
||||||
step3: {
|
|
||||||
title: 'Fill Basic Info First',
|
|
||||||
description: 'Complete the Basic Info page first. Project name and industry are core inputs for later project calculation.',
|
|
||||||
point1: 'Project name is used in the home list, tab labels, and exported reports.',
|
|
||||||
point2: 'Project industry determines the scale structure, major tree, and part of the budget logic.',
|
|
||||||
point3: 'If project name is left empty, the system falls back to the default project name.'
|
|
||||||
},
|
|
||||||
step4: {
|
|
||||||
title: 'Maintain Scale Info',
|
|
||||||
description: 'The Scale Info page stores project-level scale data, which is one of the base inputs of project calculation.',
|
|
||||||
point1: 'Fill scale values by major. These numbers participate in later service budget values and summary.',
|
|
||||||
point2: 'The grid supports direct edit, batch paste, and undo/redo for fast multi-row input.',
|
|
||||||
point3: 'Grouped rows and the pinned summary row are calculated automatically for quick checking.'
|
|
||||||
},
|
|
||||||
step5: {
|
|
||||||
title: 'Maintain Project Factors',
|
|
||||||
description: 'Consult Category Factor and Major Factor are used to adjust project budget values and should be reviewed before segment calculation.',
|
|
||||||
point1: 'Consult Category Factor page: maintain budget values and notes by consult category.',
|
|
||||||
point2: 'Major Factor page: maintain budget values and notes by major tree.',
|
|
||||||
point3: 'Both pages support batch paste and undo/redo for efficient maintenance.'
|
|
||||||
},
|
|
||||||
step6: {
|
|
||||||
title: 'Enter Segment Calculation',
|
|
||||||
description: 'After project-level setup is complete, go to Contract Segment Management and calculate each segment one by one.',
|
|
||||||
point1: 'Create a contract segment first, then open its detail page to continue.',
|
|
||||||
point2: 'Scale data under one segment belongs only to that segment and does not affect others.',
|
|
||||||
point3: 'The consulting service page generates service rows and acts as the entry for pricing methods.'
|
|
||||||
},
|
|
||||||
step7: {
|
|
||||||
title: 'Choose Pricing Methods',
|
|
||||||
description: 'From consulting service details inside a segment, open the service pricing page and fill method data based on the service.',
|
|
||||||
point1: 'Common methods include investment scale, land scale, workload, and hourly pricing.',
|
|
||||||
point2: 'Different services can enable different methods, and the system summarizes them into subtotal and final amount.',
|
|
||||||
point3: 'If the default calculated value needs adjustment, edit the final amount or note fields directly.'
|
|
||||||
},
|
|
||||||
step8: {
|
|
||||||
title: 'Review and Export',
|
|
||||||
description: 'After project-level and segment-level calculation is complete, review the final summary and then export the report.',
|
|
||||||
point1: 'Check project name, scale info, factors, and segment service amounts before exporting.',
|
|
||||||
point2: 'Export uses the current project data and generates the final report accordingly.',
|
|
||||||
point3: 'If you make large changes, export a backup first before continuing.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toast: {
|
|
||||||
export: 'Export Report',
|
|
||||||
success: 'Export Success',
|
|
||||||
failed: 'Export Failed',
|
|
||||||
saveSuccess: 'Save Success',
|
|
||||||
saveFailed: 'Save Failed'
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
defaultProjectLabel: 'Default Project',
|
|
||||||
defaultProjectName: 'Cost Project',
|
|
||||||
projectNamePrefix: 'Project-{id}',
|
|
||||||
contractFallbackName: 'Contract-{index}',
|
|
||||||
reportFileSuffix: 'Budget Report',
|
|
||||||
reportGenerating: 'Generating report file...',
|
|
||||||
reportExportDone: 'Report export completed',
|
|
||||||
reportExportFailedRetry: 'Report export failed, please retry',
|
|
||||||
importFailedTitle: 'Import Failed',
|
|
||||||
importProjectIdMissing: 'This package does not contain project ID (legacy export). Import is blocked to avoid cross-project overwrite.',
|
|
||||||
importProjectMismatch: 'This package belongs to another project and cannot override current project.',
|
|
||||||
importInvalidFile: 'File is invalid, corrupted, or modified.',
|
|
||||||
importCryptoUnavailable: 'This runtime does not support secure archive decryption. Please open the site over HTTPS or another secure local context and try again.',
|
|
||||||
importWriteError: 'An error occurred while writing local data.',
|
|
||||||
openFile: 'Open file'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
typeLine: {
|
|
||||||
copy: 'Copy',
|
|
||||||
copied: 'Copied',
|
|
||||||
copyFailed: 'Copy failed',
|
|
||||||
brandAlt: 'Zhongwei',
|
|
||||||
supportText: 'This website is supported by Zhongwei Engineering Consulting Co., Ltd.',
|
|
||||||
aboutTitle: 'About Us',
|
|
||||||
companyName: 'Zhongwei Engineering Consulting Co., Ltd.',
|
|
||||||
openOfficialSiteAria: 'Open official website',
|
|
||||||
officialSiteTitle: 'Official Website',
|
|
||||||
aboutParagraph1: 'Zhongwei Engineering Consulting Co., Ltd. was founded in 2009, focusing on whole-process consulting for project cost and cost control. It is a preferred audit vendor for Guangdong government. The company serves multi-domain and diverse clients, with cumulative project investment over one trillion CNY, deep participation in major national projects such as the Hong Kong-Zhuhai-Macao Bridge and Hengqin Campus of the University of Macau, and participation in over 30 national/provincial/municipal standards.',
|
|
||||||
aboutParagraph2: 'Based in the Greater Bay Area and expanding globally, the company has offices in Macau and Sri Lanka, with cross-border and overseas delivery capabilities. With 15 years of expertise and trillion-level project experience, it provides precise and reliable engineering consulting services.'
|
|
||||||
},
|
|
||||||
agGrid: {
|
|
||||||
resetDefault: 'Reset to default'
|
|
||||||
},
|
|
||||||
ht: {
|
|
||||||
title: 'Contract Segments',
|
|
||||||
projectTotalBudget: 'Project Total Budget: {amount}',
|
|
||||||
budgetLoading: 'Calculating...',
|
|
||||||
selectedCount: '{count} selected',
|
|
||||||
exportSelected: 'Export Selected',
|
|
||||||
deleteSelected: 'Delete Selected',
|
|
||||||
cancelSelect: 'Cancel',
|
|
||||||
addContract: 'Add Segment',
|
|
||||||
batchDelete: 'Batch Delete',
|
|
||||||
exportContracts: 'Export Segments',
|
|
||||||
importContracts: 'Import Segments',
|
|
||||||
searchPlaceholder: 'Search by segment name or ID',
|
|
||||||
clearFilter: 'Clear Filter',
|
|
||||||
searchingHint: 'Searching ({filtered} / {total}), drag sorting is disabled',
|
|
||||||
selectModeExportHint: 'Export mode: select segments and click "Export Selected"',
|
|
||||||
selectModeDeleteHint: 'Delete mode: select segments and click "Delete Selected"',
|
|
||||||
setupRequiredHint: 'Set project industry in "Basic Info" before adding or importing segments',
|
|
||||||
listLayout: 'List',
|
|
||||||
gridLayout: 'Grid',
|
|
||||||
dragSort: 'Drag to Sort',
|
|
||||||
dragSortSearchOff: 'Drag Sort (Disabled in Search)',
|
|
||||||
edit: 'Edit',
|
|
||||||
remove: 'Delete',
|
|
||||||
idLabel: 'ID: {id}',
|
|
||||||
contractBudget: 'Budget: {amount}',
|
|
||||||
contractBudgetLine: 'Segment Budget: {amount}',
|
|
||||||
createdAt: 'Created: {time}',
|
|
||||||
emptyTitle: 'No Contract Segments',
|
|
||||||
emptyDesc: 'Add one to get started',
|
|
||||||
notFound: 'No matching contract segment',
|
|
||||||
backToTop: 'Back to Top',
|
|
||||||
editContract: 'Edit Segment',
|
|
||||||
createContract: 'New Segment',
|
|
||||||
contractTabTitle: 'Segment {name}',
|
|
||||||
contractName: 'Segment Name',
|
|
||||||
contractNamePlaceholder: 'Enter segment name',
|
|
||||||
save: 'Save',
|
|
||||||
ok: 'OK',
|
|
||||||
toastSuccessTitle: 'Success',
|
|
||||||
createSuccess: 'Created successfully',
|
|
||||||
editSuccess: 'Updated successfully',
|
|
||||||
deleteSuccess: 'Deleted successfully',
|
|
||||||
sortDone: 'Sort completed',
|
|
||||||
exportSuccess: 'Exported successfully ({count} segments)',
|
|
||||||
importSuccess: 'Imported successfully ({count} segments)',
|
|
||||||
deleteBatchSuccess: 'Deleted successfully ({count} segments)',
|
|
||||||
tipTitle: 'Notice',
|
|
||||||
exportFailedTitle: 'Export Failed',
|
|
||||||
importFailedTitle: 'Import Failed',
|
|
||||||
batchDeleteFailedTitle: 'Batch Delete Failed',
|
|
||||||
retry: 'Please try again.',
|
|
||||||
selectAtLeastOne: 'Please select at least one contract segment.',
|
|
||||||
noContractsToDelete: 'No contract segment found to delete.',
|
|
||||||
industryMissingForExport: 'Project industry is missing. Please set it in "Basic Info" first.',
|
|
||||||
importIndustryMismatch: 'Industry mismatch (package: {importIndustry}, current: {currentIndustry}).',
|
|
||||||
importCurrentIndustryMissing: 'Current project industry is not set. Please set it in "Basic Info" first.',
|
|
||||||
importPackageIndustryMissing: 'Import package missing industry info. Re-export with latest version and try again.',
|
|
||||||
importFileInvalid: 'Invalid or corrupted file, or not a contract-segment package.',
|
|
||||||
importCryptoUnavailable: 'This runtime does not support secure archive decryption. Please open the site over HTTPS or another secure local context and try again.',
|
|
||||||
deleteSingleTitle: 'Confirm Delete Segment',
|
|
||||||
deleteSingleDesc: 'Delete "{name}" and all related service/pricing data. Continue?',
|
|
||||||
deleteBatchTitle: 'Confirm Batch Delete',
|
|
||||||
deleteBatchDesc: 'Delete {count} segments and related service/pricing data. Continue?'
|
|
||||||
},
|
|
||||||
htCard: {
|
|
||||||
title: 'Segment: {name}',
|
|
||||||
subtitle: 'Segment ID: {id}',
|
|
||||||
metaBudget: 'Segment Budget: {amount}',
|
|
||||||
currencySuffix: 'CNY',
|
|
||||||
categories: {
|
|
||||||
baseInfo: 'Basic Info',
|
|
||||||
scaleInfo: 'Scale Info',
|
|
||||||
services: 'Consulting Services',
|
|
||||||
consultFactor: 'Consult Category Factor',
|
|
||||||
majorFactor: 'Major Factor',
|
|
||||||
additionalFee: 'Additional Fee',
|
|
||||||
reserveFee: 'Reserve Fee',
|
|
||||||
summary: 'Summary'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htBaseInfo: {
|
|
||||||
title: 'Basic Info',
|
|
||||||
defaultQuality: 'The comprehensive evaluation of cost consulting services should reach "Good" or a score of 90.',
|
|
||||||
qualityLabel: 'Quality Requirement',
|
|
||||||
qualityPlaceholder: 'Enter quality requirement',
|
|
||||||
durationLabel: 'Duration Requirement',
|
|
||||||
durationPlaceholder: 'Enter duration requirement'
|
|
||||||
},
|
|
||||||
htFactors: {
|
|
||||||
consultCategoryTitle: 'Consult Category Factor Details',
|
|
||||||
majorTitle: 'Major Factor Details'
|
|
||||||
},
|
|
||||||
htFee: {
|
|
||||||
additionalTitle: 'Additional Work Fee',
|
|
||||||
reserveTitle: 'Reserve Fee'
|
|
||||||
},
|
|
||||||
htInfo: {
|
|
||||||
scaleDetailTitle: 'Contract Scale Details'
|
|
||||||
},
|
|
||||||
htFeeRate: {
|
|
||||||
baseLabel: 'Base (total budget of all service fees)',
|
|
||||||
reserveBaseLabel: 'Base (consulting services total + additional work fee total)',
|
|
||||||
rateLabel: 'Rate (%)',
|
|
||||||
ratePlaceholder: 'Enter rate, suggested 1 ~ 5',
|
|
||||||
budgetFeeLabel: 'Budget Fee (Auto)',
|
|
||||||
remarkLabel: 'Remark',
|
|
||||||
remarkPlaceholder: 'Enter remark'
|
|
||||||
},
|
|
||||||
htZxFw: {
|
|
||||||
title: 'Consulting Service Details',
|
|
||||||
warning: 'Please review and adjust recommended limits/special values in the specification, then update final fee if needed.',
|
|
||||||
editTabTitle: 'Service Edit-{name}',
|
|
||||||
subtotal: 'Subtotal',
|
|
||||||
edit: 'Edit',
|
|
||||||
resetDefault: 'Reset',
|
|
||||||
delete: 'Remove',
|
|
||||||
processDraft: 'Draft',
|
|
||||||
processReview: 'Review',
|
|
||||||
columns: {
|
|
||||||
code: 'Code',
|
|
||||||
name: 'Name',
|
|
||||||
process: 'Process',
|
|
||||||
investScale: 'Investment Scale',
|
|
||||||
landScale: 'Land Scale',
|
|
||||||
workload: 'Workload',
|
|
||||||
hourly: 'Hourly',
|
|
||||||
subtotal: 'Subtotal',
|
|
||||||
finalFee: 'Final Fee ✎',
|
|
||||||
finalFeeTooltip: 'This column supports manual edits and will auto-sync to the fixed subtotal row.',
|
|
||||||
remark: 'Remark',
|
|
||||||
actions: 'Actions'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
resetTitle: 'Confirm Reset to Default',
|
|
||||||
resetDesc: 'This will recalculate default data from latest scale/factor values and overwrite current data for "{name}". Continue?',
|
|
||||||
confirmReset: 'Confirm Reset',
|
|
||||||
deleteTitle: 'Confirm Delete Service',
|
|
||||||
deleteDesc: 'This will logically remove "{name}". Existing entered data is kept and will restore if re-selected. Continue?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htSummary: {
|
|
||||||
title: 'Contract Summary',
|
|
||||||
total: 'Total',
|
|
||||||
remark: 'Remark',
|
|
||||||
placeholder: 'Fill consulting services / additional work fee / reserve fee first',
|
|
||||||
additionalPrefix: 'Additional Work Fee',
|
|
||||||
reservePrefix: 'Reserve Fee',
|
|
||||||
explainByRate: 'By rate {rate}%, calculated {fee} CNY',
|
|
||||||
explainByHourly: 'By hourly method, calculated {fee} CNY',
|
|
||||||
explainByQuantity: 'By quantity-unit-price method, calculated {fee} CNY',
|
|
||||||
columns: {
|
|
||||||
code: 'Code',
|
|
||||||
name: 'Name',
|
|
||||||
investScale: 'Investment Scale',
|
|
||||||
landScale: 'Land Scale',
|
|
||||||
workload: 'Workload',
|
|
||||||
hourly: 'Hourly',
|
|
||||||
subtotal: 'Subtotal',
|
|
||||||
finalFee: 'Final Fee'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htFeeGrid: {
|
|
||||||
subtotal: 'Subtotal',
|
|
||||||
currentRow: 'Current Row',
|
|
||||||
unnamed: 'Unnamed',
|
|
||||||
edit: 'Edit',
|
|
||||||
clear: 'Clear',
|
|
||||||
add: 'Add',
|
|
||||||
editTabTitle: 'Fee Edit-{name}',
|
|
||||||
columns: {
|
|
||||||
name: 'Name',
|
|
||||||
rateFee: 'Rate Fee',
|
|
||||||
hourlyFee: 'Hourly',
|
|
||||||
quantityUnitPriceFee: 'Quantity Unit Price',
|
|
||||||
subtotal: 'Subtotal',
|
|
||||||
actions: 'Actions'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
clearTitle: 'Confirm Clear',
|
|
||||||
clearDesc: 'This will clear editable and auto-calculated data for "{name}" and its edit page. Continue?',
|
|
||||||
confirmClear: 'Confirm Clear'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
xmFactorGrid: {
|
|
||||||
clickToInput: 'Click to input',
|
|
||||||
columns: {
|
|
||||||
standardFactor: 'Standard Factor',
|
|
||||||
budgetValue: 'Budget Value',
|
|
||||||
remark: 'Remark',
|
|
||||||
groupName: 'Major Code and Major Name'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
serviceSelector: {
|
|
||||||
title: 'Select Services',
|
|
||||||
clear: 'Clear',
|
|
||||||
empty: 'No services'
|
|
||||||
},
|
|
||||||
zxFwView: {
|
|
||||||
contractPrefix: 'Contract: {name}',
|
|
||||||
calcSuffix: ' Calculation',
|
|
||||||
contractId: 'Contract ID: {id}',
|
|
||||||
workContentTitle: 'Work Content',
|
|
||||||
categories: {
|
|
||||||
investmentScale: 'Investment Scale',
|
|
||||||
investmentScaleFormula: 'Investment Scale Formula',
|
|
||||||
landScale: 'Land Scale',
|
|
||||||
landScaleFormula: 'Land Scale Formula',
|
|
||||||
workload: 'Workload',
|
|
||||||
hourly: 'Hourly',
|
|
||||||
workContent: 'Work Content'
|
|
||||||
},
|
|
||||||
formulaColumns: {
|
|
||||||
subtitle: 'Shows the latest detail rows from the current pricing-method store and stays in sync with store updates.',
|
|
||||||
amount: 'Amount (CNY)',
|
|
||||||
basicFormula: 'Basic Work Formula',
|
|
||||||
optionalFormula: 'Optional Work Formula'
|
|
||||||
},
|
|
||||||
unavailable: {
|
|
||||||
investmentScaleTitle: 'Investment Scale Not Applicable',
|
|
||||||
investmentScaleMessage: 'Scale method is not enabled for this service, so Investment Scale is not editable.',
|
|
||||||
landScaleTitle: 'Land Scale Not Applicable',
|
|
||||||
landScaleMessage: 'This service only supports Investment Scale, so Land Scale is not editable.',
|
|
||||||
workloadTitle: 'Workload Not Applicable',
|
|
||||||
workloadMessage: 'Workload method is not enabled for this service, so Workload is not editable.',
|
|
||||||
hourlyTitle: 'Hourly Not Applicable',
|
|
||||||
hourlyMessage: 'Hourly method is not enabled for this service, so Hourly is not editable.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htFeeDetail: {
|
|
||||||
subtotal: 'Subtotal',
|
|
||||||
currentRow: 'Current Row',
|
|
||||||
clickToInput: 'Click to input',
|
|
||||||
addRow: 'Add Row',
|
|
||||||
columns: {
|
|
||||||
no: 'No.',
|
|
||||||
feeItem: 'Fee Item',
|
|
||||||
unit: 'Unit',
|
|
||||||
quantity: 'Quantity',
|
|
||||||
unitPrice: 'Unit Price (CNY)',
|
|
||||||
budgetFee: 'Budget Fee (CNY)',
|
|
||||||
remark: 'Remark',
|
|
||||||
actions: 'Actions'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
deleteTitle: 'Confirm Delete Row',
|
|
||||||
deleteDesc: 'Delete row "{name}"?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
workContent: {
|
|
||||||
title: 'Work Content',
|
|
||||||
addCustom: 'Add Custom Content',
|
|
||||||
clickToInput: 'Click to input',
|
|
||||||
clickToInputContent: 'Click to input work content',
|
|
||||||
currentRow: 'Current Row',
|
|
||||||
unnamed: 'Unnamed',
|
|
||||||
ungrouped: 'Ungrouped',
|
|
||||||
type: {
|
|
||||||
basic: 'Basic Work',
|
|
||||||
optional: 'Optional Work',
|
|
||||||
daily: 'Daily Advisory',
|
|
||||||
special: 'Special Advisory',
|
|
||||||
additional: 'Additional Work',
|
|
||||||
custom: 'Custom'
|
|
||||||
},
|
|
||||||
columns: {
|
|
||||||
no: 'No.',
|
|
||||||
content: 'Content',
|
|
||||||
type: 'Type',
|
|
||||||
remark: 'Remark',
|
|
||||||
actions: 'Actions'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
deleteTitle: 'Confirm Delete Row',
|
|
||||||
deleteDesc: 'Delete row "{name}"?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
quickCalc: {
|
|
||||||
projectName: 'Quick Calculation',
|
|
||||||
industryLabel: 'Industry {name}',
|
|
||||||
selectIndustry: 'Select industry',
|
|
||||||
saving: 'Saving...',
|
|
||||||
synced: 'Industry synced',
|
|
||||||
notSelectedIndustry: 'Industry not selected',
|
|
||||||
notSelected: 'Not selected',
|
|
||||||
consultCategory: 'Consult Category',
|
|
||||||
majorCategory: 'Major',
|
|
||||||
types: {
|
|
||||||
consult: {
|
|
||||||
label: 'Consult Category (Common)',
|
|
||||||
hint: 'Select consult category first, then complete scale and budget parameters.'
|
|
||||||
},
|
|
||||||
general: {
|
|
||||||
label: 'General Major',
|
|
||||||
hint: 'Cross-industry common compensation and other expense majors.'
|
|
||||||
},
|
|
||||||
road: {
|
|
||||||
label: 'Highway Major',
|
|
||||||
hint: 'Shown by default when industry is Highway Engineering.'
|
|
||||||
},
|
|
||||||
railway: {
|
|
||||||
label: 'Railway Major',
|
|
||||||
hint: 'Shown by default when industry is Railway Engineering.'
|
|
||||||
},
|
|
||||||
waterway: {
|
|
||||||
label: 'Waterway Major',
|
|
||||||
hint: 'Shown by default when industry is Waterway Engineering.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
industry: 'Industry',
|
|
||||||
code: 'Code',
|
|
||||||
investScale: 'Investment Scale (10k CNY)',
|
|
||||||
landScale: 'Land Scale (mu)',
|
|
||||||
formula: 'Formula',
|
|
||||||
amount: 'Amount (CNY)',
|
|
||||||
consultFactor: 'Consult Category Factor',
|
|
||||||
majorFactor: 'Major Factor',
|
|
||||||
workEnvCoefficient: 'Work environment coefficient',
|
|
||||||
workEnvCoefficientPlaceholder: 'Default 1',
|
|
||||||
budgetAmount: 'Budget Amount (CNY)'
|
|
||||||
},
|
|
||||||
sections: {
|
|
||||||
currentSelection: 'Current Selection',
|
|
||||||
basicInfo: 'Basic Info',
|
|
||||||
scaleBase: 'Scale Base',
|
|
||||||
benchmarkBudget: 'Benchmark Budget',
|
|
||||||
serviceBudget: 'Service Budget',
|
|
||||||
},
|
|
||||||
empty: {
|
|
||||||
selectIndustry: 'Select an industry first. Then choose consult category and matched majors will appear.',
|
|
||||||
selectConsult: 'Select a consult category first. Matched general and major categories will then appear.',
|
|
||||||
scaleUnavailable: 'The selected consult category does not support scale method, so major categories are hidden.',
|
|
||||||
consultCostOnly: 'The selected consult category is priced by industry summary. Major factor is auto-applied by industry.'
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
selectConsultFirst: 'Select consult category first',
|
|
||||||
scaleUnavailable: 'Current category does not support scale method',
|
|
||||||
selectMajorFirst: 'Select major first',
|
|
||||||
preferLandScale: 'Current major is priced by land scale',
|
|
||||||
investUnavailable: 'Current major does not support investment scale',
|
|
||||||
consultCostOnly: 'Current category supports investment scale only',
|
|
||||||
landUnavailable: 'Current major does not support land scale',
|
|
||||||
input: 'Please input',
|
|
||||||
selectScaleFirst: 'Select and input a scale value first'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methodUnavailable: {
|
|
||||||
defaultTitle: 'This Service Is Not Applicable to Current Pricing Method'
|
|
||||||
},
|
|
||||||
xmCard: {
|
|
||||||
categories: {
|
|
||||||
info: 'Basic Info',
|
|
||||||
scaleInfo: 'Scale Info',
|
|
||||||
consultCategoryFactor: 'Consult Category Factor',
|
|
||||||
majorFactor: 'Major Factor',
|
|
||||||
contract: 'Contract Segment Management'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htFeeMethodTypeLine: {
|
|
||||||
feeDetail: 'Fee Details',
|
|
||||||
unnamed: 'Unnamed',
|
|
||||||
title: 'Segment: {contractName} · {rowName}',
|
|
||||||
contractId: 'Contract ID: {id}',
|
|
||||||
quantityUnitPrice: 'Quantity Unit Price'
|
|
||||||
},
|
|
||||||
pricingScale: {
|
|
||||||
totalInvestmentByIndustry: '{industryName} Total Investment',
|
|
||||||
totalInvestment: 'Total Investment',
|
|
||||||
clickToInput: 'Optional, enter manually, numeric, 4 decimals',
|
|
||||||
projectLabel: 'Project {index}',
|
|
||||||
columns: {
|
|
||||||
investAmount: 'Cost Amount (10k CNY)',
|
|
||||||
landArea: 'Land Area (mu)',
|
|
||||||
benchmarkBudget: 'Benchmark Budget (CNY)',
|
|
||||||
basicWork: 'Basic Work',
|
|
||||||
optionalWork: 'Optional Work',
|
|
||||||
subtotal: 'Subtotal',
|
|
||||||
budgetFee: 'Budget Fee',
|
|
||||||
consultCategoryFactor: 'Consult Category Factor',
|
|
||||||
majorFactor: 'Major Factor',
|
|
||||||
workStageFactor: 'Work Stage Factor (Draft/Review)',
|
|
||||||
workRatio: 'Work Ratio (%)',
|
|
||||||
total: 'Total',
|
|
||||||
remark: 'Remark',
|
|
||||||
majorGroup: 'Major Code and Major Name'
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
resetInvestAmount: 'Click ↻ to restore default cost amount for this column',
|
|
||||||
resetLandArea: 'Click ↻ to restore default land area for this column',
|
|
||||||
resetConsultCategoryFactor: 'Click ↻ to restore default consult category factor for this column',
|
|
||||||
resetMajorFactor: 'Click ↻ to restore default major factor for this column'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pricingPane: {
|
|
||||||
projectCount: 'Project Count',
|
|
||||||
clearTitle: 'Confirm Clear Current Details',
|
|
||||||
confirmClear: 'Confirm Clear',
|
|
||||||
useDefault: 'Use Default Data',
|
|
||||||
overrideTitle: 'Confirm Override Current Details',
|
|
||||||
confirmOverride: 'Confirm Override',
|
|
||||||
investment: {
|
|
||||||
title: 'Investment Scale Details',
|
|
||||||
clearDesc: 'This will clear current investment scale details. Continue?',
|
|
||||||
overrideDesc: 'Use contract default data to override current investment scale details. Continue?'
|
|
||||||
},
|
|
||||||
land: {
|
|
||||||
title: 'Land Scale Details',
|
|
||||||
clearDesc: 'This will clear current land scale details. Continue?',
|
|
||||||
overrideDesc: 'Use contract default data to override current land scale details. Continue?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
workloadPricing: {
|
|
||||||
title: 'Workload Details',
|
|
||||||
unavailableTitle: 'Workload Method Not Applicable',
|
|
||||||
unavailableMessage: 'No workload tasks are associated with this service. No input is needed.',
|
|
||||||
clickToInput: 'Click to input',
|
|
||||||
none: 'N/A',
|
|
||||||
total: 'Grand Total',
|
|
||||||
columns: {
|
|
||||||
code: 'Code',
|
|
||||||
name: 'Name',
|
|
||||||
budgetBase: 'Budget Base',
|
|
||||||
budgetReferenceUnitPrice: 'Budget Reference Unit Price',
|
|
||||||
budgetAdoptedUnitPrice: 'Budget Adopted Unit Price',
|
|
||||||
workload: 'Workload',
|
|
||||||
consultCategoryFactor: 'Consult Category Factor',
|
|
||||||
serviceFee: 'Service Fee (CNY)',
|
|
||||||
remark: 'Remark'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hourlyFeeGrid: {
|
|
||||||
title: 'Hourly Method Details',
|
|
||||||
clickToInput: 'Click to input',
|
|
||||||
total: 'Grand Total',
|
|
||||||
columns: {
|
|
||||||
code: 'Code',
|
|
||||||
name: 'Name',
|
|
||||||
technician: 'Technician',
|
|
||||||
assistantEngineer: 'Assistant Engineer',
|
|
||||||
midEngineer: 'Intermediate Engineer (or Level 2 Cost Engineer)',
|
|
||||||
seniorEngineer: 'Senior Engineer (or Level 1 Cost Engineer)',
|
|
||||||
profSeniorEngineer: 'Professor-level Senior Engineer',
|
|
||||||
unitPrice: 'Unit Price (CNY/workday)',
|
|
||||||
workdayCount: 'Workday Count',
|
|
||||||
subtotal: 'Subtotal (CNY)',
|
|
||||||
avgUnitPrice: 'Average Unit Price (CNY/workday)',
|
|
||||||
remark: 'Remark'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
xmScaleGrid: {
|
|
||||||
syncToastTitle: 'Consulting Services Synced',
|
|
||||||
syncToastDesc: 'Scale info synced to consulting services ({serviceCount} services, {methodCount} pricing pages, {rowCount} rows)'
|
|
||||||
},
|
|
||||||
xmInfo: {
|
|
||||||
defaultProjectName: 'xxx Cost Consulting Service',
|
|
||||||
defaultDesc: 'When providing cost consulting services, penalties should be graded by service quality. For scores >=85 and <90, penalty is 10% of budget fee; >=80 and <85: 20%; >=75 and <80: 30%; >=70 and <75: 40%; <70: 50% or above.',
|
|
||||||
industryHint: 'Changing industry requires reset and re-selection',
|
|
||||||
industryHintAria: 'Industry hint',
|
|
||||||
createFromHomeFirst: 'Please create a project from Home before entering this page.',
|
|
||||||
fields: {
|
|
||||||
projectName: 'Project Name',
|
|
||||||
projectIndustry: 'Industry',
|
|
||||||
overview: 'Project Overview',
|
|
||||||
preparedBy: 'Prepared By',
|
|
||||||
reviewedBy: 'Reviewed By',
|
|
||||||
preparedCompany: 'Prepared Company',
|
|
||||||
preparedDate: 'Prepared Date',
|
|
||||||
desc: 'Other Notes'
|
|
||||||
},
|
|
||||||
placeholders: {
|
|
||||||
overview: 'Enter project overview',
|
|
||||||
preparedBy: 'Enter preparer',
|
|
||||||
reviewedBy: 'Enter reviewer',
|
|
||||||
preparedCompany: 'Enter prepared company'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const
|
|
||||||
@@ -1,738 +0,0 @@
|
|||||||
export const zhCN = {
|
|
||||||
common: {
|
|
||||||
cancel: '取消',
|
|
||||||
confirm: '确认',
|
|
||||||
delete: '删除',
|
|
||||||
close: '关闭',
|
|
||||||
clear: '清空',
|
|
||||||
loading: '加载中...'
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
projectConflict: {
|
|
||||||
title: '检测到项目重复打开',
|
|
||||||
desc: '项目「{name}」已在其他页面处于活跃状态。为避免 IndexedDB 数据冲突,本页面已阻断编辑。',
|
|
||||||
countdown: '本页将在 {seconds} 秒后自动尝试关闭。你也可以先在新标签页打开其他项目。',
|
|
||||||
opened: '(已打开)',
|
|
||||||
lastEdited: '最后编辑:{time}',
|
|
||||||
openDefault: '打开默认项目',
|
|
||||||
createAndOpen: '新建项目并打开'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
home: {
|
|
||||||
title: '粤价函〔2008〕929号\n《广东省交通工程造价技术中介服务收费项目及标准表》',
|
|
||||||
subtitle: '项目计算 · 单项速算 · 导入数据',
|
|
||||||
projectCalcTab: '项目计算',
|
|
||||||
quickCalcTab: '快速计算',
|
|
||||||
cards: {
|
|
||||||
heroTitle: '智能预算一键生成',
|
|
||||||
heroSubTitle: '助力《规范》高效落地',
|
|
||||||
heroDesc: '交通建设项目工程造价咨询服务费计算',
|
|
||||||
projectBudget: '项目预算',
|
|
||||||
projectBudgetDesc: '适用于多合同段、项目级整体计算,支持导出/导入完整项目数据',
|
|
||||||
quickCalc: '单项速算',
|
|
||||||
quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果',
|
|
||||||
importData: '导入数据',
|
|
||||||
importDataDesc: '导入".zw"数据包,并自动新建一个项目用于恢复数据,不会覆盖现有项目',
|
|
||||||
enter: '进入计算',
|
|
||||||
developing: '正在开发',
|
|
||||||
pickFile: '选择文件',
|
|
||||||
pickExisting: '选择已有项目',
|
|
||||||
relatedFiles: '相关文件',
|
|
||||||
relatedFilesDesc: '可查看相关收费文件、招标文件、合同文件、服务内容、工作要求',
|
|
||||||
openRelatedFiles: '打开页面'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
newProject: '新建项目',
|
|
||||||
chooseIndustryDesc: '选择工程行业后,直接进入项目计算页面。',
|
|
||||||
industry: '工程行业',
|
|
||||||
selectIndustry: '请选择工程行业',
|
|
||||||
entering: '进入中...',
|
|
||||||
enterProjectCalc: '进入项目计算',
|
|
||||||
confirmImport: '确认导入数据',
|
|
||||||
confirmImportDesc: '将导入“{file}”数据包',
|
|
||||||
confirmImportAction: '确认导入并新建项目',
|
|
||||||
chooseExistingProject: '选择已有项目',
|
|
||||||
chooseExistingProjectDesc: '从项目列表中选择一个项目并直接进入工作台。',
|
|
||||||
noProjectYet: '当前暂无可进入的项目,请先新建项目。'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tab: {
|
|
||||||
toolbar: {
|
|
||||||
light: '浅色',
|
|
||||||
dark: '深色',
|
|
||||||
language: '语言',
|
|
||||||
importExport: '导入/导出',
|
|
||||||
importData: '导入数据',
|
|
||||||
exportData: '导出数据',
|
|
||||||
exportReport: '导出报表',
|
|
||||||
userGuide: '使用引导',
|
|
||||||
reset: '重置',
|
|
||||||
resetting: '重置中...',
|
|
||||||
projectList: '项目列表',
|
|
||||||
projectCount: '项目数量:{count}/{max}',
|
|
||||||
createProject: '新建项目',
|
|
||||||
backHome: '返回入口',
|
|
||||||
resetAll: '清除全部项目',
|
|
||||||
opened: '(已打开)',
|
|
||||||
lastEdited: '最后编辑:{time}'
|
|
||||||
},
|
|
||||||
menu: {
|
|
||||||
closeAll: '删除所有',
|
|
||||||
closeLeft: '删除左侧',
|
|
||||||
closeRight: '删除右侧',
|
|
||||||
closeOther: '删除其他'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
resetTitle: '确认重置',
|
|
||||||
resetDesc: '将清空全部项目数据,并恢复默认页面,确认继续吗?',
|
|
||||||
confirmReset: '确认重置',
|
|
||||||
importOverrideTitle: '确认导入覆盖',
|
|
||||||
importOverrideDesc: '将使用“{file}”覆盖当前本地全部数据,是否继续?',
|
|
||||||
confirmOverride: '确认覆盖',
|
|
||||||
newProjectTitle: '新建项目',
|
|
||||||
newProjectDesc: '选择工程行业后,将在新标签页直接打开新项目计算页面。',
|
|
||||||
createAndOpen: '新建并打开',
|
|
||||||
creating: '创建中...',
|
|
||||||
projectLimitTitle: '项目数量已达上限',
|
|
||||||
projectLimitDesc: '当前项目数量已达到 {max} 个,请先删除一个项目后再添加。',
|
|
||||||
iKnow: '我知道了',
|
|
||||||
deleteProjectTitle: '确认删除项目',
|
|
||||||
deleteCurrentProjectDesc: '确认删除当前项目「{name}」吗?将先清空该项目全部本地数据并返回首页。',
|
|
||||||
deleteProjectDesc: '确认删除项目「{name}」吗?这会移除该项目本地数据。'
|
|
||||||
},
|
|
||||||
guide: {
|
|
||||||
title: '新用户引导',
|
|
||||||
later: '稍后再看',
|
|
||||||
prev: '上一步',
|
|
||||||
next: '下一步',
|
|
||||||
finish: '完成并不再自动弹出',
|
|
||||||
jumpToStep: '跳转到第 {index} 步',
|
|
||||||
steps: {
|
|
||||||
step1: {
|
|
||||||
title: '项目计算总览',
|
|
||||||
description: '这个引导只说明项目计算主链路,按“项目级设置 -> 合同段 -> 服务计费 -> 导出报表”的顺序理解即可。',
|
|
||||||
point1: '项目计算入口是“项目卡片”,左侧流程线会带你按顺序完成配置。',
|
|
||||||
point2: '页面里的表单和表格会自动保存,本地修改通常无需手动点击保存。',
|
|
||||||
point3: '项目级数据会影响后续合同段和报表结果,建议先补齐项目级信息。'
|
|
||||||
},
|
|
||||||
step2: {
|
|
||||||
title: '项目级配置入口',
|
|
||||||
description: '项目级配置主要包括基础信息、规模信息、咨询分类系数、工程专业系数四部分。',
|
|
||||||
point1: '基础信息:维护项目名称、工程行业等项目基础资料。',
|
|
||||||
point2: '规模信息:按专业填写项目规模,为后续预算取值提供依据。',
|
|
||||||
point3: '两个系数页:维护预算取值和说明,作为项目计算的调节项。'
|
|
||||||
},
|
|
||||||
step3: {
|
|
||||||
title: '先填基础信息',
|
|
||||||
description: '先完成基础信息页,尤其是项目名称和工程行业,后续所有项目计算都会依赖这里的数据。',
|
|
||||||
point1: '项目名称会用于主页列表、标签页显示和报表导出。',
|
|
||||||
point2: '工程行业决定规模信息结构、专业树和部分预算取值逻辑。',
|
|
||||||
point3: '如果项目名称留空,系统会自动回填默认项目名称。'
|
|
||||||
},
|
|
||||||
step4: {
|
|
||||||
title: '维护规模信息',
|
|
||||||
description: '规模信息页用于录入项目级规模数据,这是项目计算的基础输入之一。',
|
|
||||||
point1: '按专业填写对应规模值,数值会参与后续服务预算取值和汇总。',
|
|
||||||
point2: '表格支持直接编辑、批量粘贴、撤销重做,适合一次性录入多行数据。',
|
|
||||||
point3: '分组行和固定汇总行会自动计算,便于快速检查录入结果。'
|
|
||||||
},
|
|
||||||
step5: {
|
|
||||||
title: '维护项目系数',
|
|
||||||
description: '咨询分类系数和工程专业系数用于调整项目预算取值,建议在进入合同段前先检查完整。',
|
|
||||||
point1: '咨询分类系数页:按咨询分类维护预算取值与说明。',
|
|
||||||
point2: '工程专业系数页:按专业树维护预算取值与说明。',
|
|
||||||
point3: '两个系数页都支持批量粘贴和撤销重做,适合集中维护。'
|
|
||||||
},
|
|
||||||
step6: {
|
|
||||||
title: '进入合同段计算',
|
|
||||||
description: '项目级配置完成后,再进入合同段管理,逐个维护合同段的规模和服务费用。',
|
|
||||||
point1: '先新增合同段,再进入合同段详情页继续计算。',
|
|
||||||
point2: '合同段下的规模信息用于该合同段自己的计费数据,不会和其他合同段串数据。',
|
|
||||||
point3: '咨询服务页负责生成服务明细,并作为各计费方法页面的入口。'
|
|
||||||
},
|
|
||||||
step7: {
|
|
||||||
title: '选择计费方法',
|
|
||||||
description: '在合同段的咨询服务明细中,可进入具体服务计算页,按服务适用情况填写计费方法数据。',
|
|
||||||
point1: '常见方法包括投资规模法、用地规模法、工作量法和工时法。',
|
|
||||||
point2: '不同服务可启用不同方法,系统会按填写结果汇总到服务小计和确认金额。',
|
|
||||||
point3: '如果默认计算值需要调整,可直接修改确认金额或说明字段。'
|
|
||||||
},
|
|
||||||
step8: {
|
|
||||||
title: '汇总与导出',
|
|
||||||
description: '完成项目级和合同段级计算后,最后再检查汇总结果并导出报表。',
|
|
||||||
point1: '先检查项目名称、规模信息、系数和各合同段服务金额是否完整。',
|
|
||||||
point2: '确认无误后再执行报表导出,导出结果会按当前项目数据生成。',
|
|
||||||
point3: '如果做了大范围调整,建议先导出备份,再继续修改。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toast: {
|
|
||||||
export: '导出报表',
|
|
||||||
success: '导出成功',
|
|
||||||
failed: '导出失败',
|
|
||||||
saveSuccess: '保存成功',
|
|
||||||
saveFailed: '保存失败'
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
defaultProjectLabel: '默认项目',
|
|
||||||
defaultProjectName: '造价项目',
|
|
||||||
projectNamePrefix: '项目-{id}',
|
|
||||||
contractFallbackName: '合同段-{index}',
|
|
||||||
reportFileSuffix: '预算文件',
|
|
||||||
reportGenerating: '正在生成报表文件...',
|
|
||||||
reportExportDone: '报表导出完成',
|
|
||||||
reportExportFailedRetry: '报表导出失败,请重试',
|
|
||||||
importFailedTitle: '导入失败',
|
|
||||||
importProjectIdMissing: '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。',
|
|
||||||
importProjectMismatch: '该数据包属于其他项目,不能覆盖当前项目。',
|
|
||||||
importInvalidFile: '文件无效、已损坏或被修改。',
|
|
||||||
importCryptoUnavailable: '当前运行环境不支持安全解密导入,请使用 HTTPS 域名或本地安全环境访问后重试。',
|
|
||||||
importWriteError: '写入本地数据时发生错误。',
|
|
||||||
openFile: '打开文件'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
typeLine: {
|
|
||||||
copy: '复制',
|
|
||||||
copied: '已复制',
|
|
||||||
copyFailed: '复制失败',
|
|
||||||
brandAlt: '众为咨询',
|
|
||||||
supportText: '本网站由众为工程咨询有限公司提供免费技术支持',
|
|
||||||
aboutTitle: '关于我们',
|
|
||||||
companyName: '众为工程咨询有限公司',
|
|
||||||
openOfficialSiteAria: '跳转到官网首页',
|
|
||||||
officialSiteTitle: '官网首页',
|
|
||||||
aboutParagraph1: '众为工程咨询有限公司 2009 年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。',
|
|
||||||
aboutParagraph2: '公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。'
|
|
||||||
},
|
|
||||||
agGrid: {
|
|
||||||
resetDefault: '恢复默认值'
|
|
||||||
},
|
|
||||||
ht: {
|
|
||||||
title: '合同段列表',
|
|
||||||
projectTotalBudget: '项目总预算金额:{amount}',
|
|
||||||
budgetLoading: '计算中...',
|
|
||||||
selectedCount: '已选 {count} 个',
|
|
||||||
exportSelected: '导出已选',
|
|
||||||
deleteSelected: '删除已选',
|
|
||||||
cancelSelect: '取消',
|
|
||||||
addContract: '添加合同段',
|
|
||||||
batchDelete: '批量删除',
|
|
||||||
exportContracts: '导出合同段',
|
|
||||||
importContracts: '导入合同段',
|
|
||||||
searchPlaceholder: '搜索合同段名称或ID',
|
|
||||||
clearFilter: '清空筛选',
|
|
||||||
searchingHint: '搜索中({filtered} / {total}),已关闭拖拽排序',
|
|
||||||
selectModeExportHint: '导出选择模式:勾选合同段后点击“导出已选”',
|
|
||||||
selectModeDeleteHint: '删除选择模式:勾选合同段后点击“删除已选”',
|
|
||||||
setupRequiredHint: '请先在“基础信息”里新建项目并选择工程行业后,再新增或导入合同段',
|
|
||||||
listLayout: '列表布局',
|
|
||||||
gridLayout: '网格布局',
|
|
||||||
dragSort: '拖动排序',
|
|
||||||
dragSortSearchOff: '拖动排序(搜索时关闭)',
|
|
||||||
edit: '编辑',
|
|
||||||
remove: '删除',
|
|
||||||
idLabel: 'ID:{id}',
|
|
||||||
contractBudget: '预算:{amount}',
|
|
||||||
contractBudgetLine: '本合同预算金额:{amount}',
|
|
||||||
createdAt: '创建时间:{time}',
|
|
||||||
emptyTitle: '暂无合同卡片',
|
|
||||||
emptyDesc: '赶紧来添加吧',
|
|
||||||
notFound: '未找到匹配的合同段',
|
|
||||||
backToTop: '回到顶部',
|
|
||||||
editContract: '编辑合同段',
|
|
||||||
createContract: '新增合同段',
|
|
||||||
contractTabTitle: '合同段{name}',
|
|
||||||
contractName: '合同段名称',
|
|
||||||
contractNamePlaceholder: '请输入合同段名称',
|
|
||||||
save: '保存',
|
|
||||||
ok: '确定',
|
|
||||||
toastSuccessTitle: '操作成功',
|
|
||||||
createSuccess: '新建成功',
|
|
||||||
editSuccess: '编辑成功',
|
|
||||||
deleteSuccess: '删除成功',
|
|
||||||
sortDone: '排序完成',
|
|
||||||
exportSuccess: '导出成功({count} 个合同段)',
|
|
||||||
importSuccess: '导入成功({count} 个合同段)',
|
|
||||||
deleteBatchSuccess: '删除成功({count} 个合同段)',
|
|
||||||
tipTitle: '提示',
|
|
||||||
exportFailedTitle: '导出失败',
|
|
||||||
importFailedTitle: '导入失败',
|
|
||||||
batchDeleteFailedTitle: '批量删除失败',
|
|
||||||
retry: '请重试。',
|
|
||||||
selectAtLeastOne: '请先勾选至少一个合同段。',
|
|
||||||
noContractsToDelete: '未找到可删除的合同段。',
|
|
||||||
industryMissingForExport: '未读取到当前项目工程行业,请先在“基础信息”里新建项目。',
|
|
||||||
importIndustryMismatch: '工程行业不一致(导入包:{importIndustry},当前项目:{currentIndustry})。',
|
|
||||||
importCurrentIndustryMissing: '当前项目未设置工程行业,请先在“基础信息”里新建项目。',
|
|
||||||
importPackageIndustryMissing: '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。',
|
|
||||||
importFileInvalid: '文件无效、已损坏或不是合同段导出文件。',
|
|
||||||
importCryptoUnavailable: '当前运行环境不支持安全解密导入,请使用 HTTPS 域名或本地安全环境访问后重试。',
|
|
||||||
deleteSingleTitle: '确认删除合同段',
|
|
||||||
deleteSingleDesc: '即将删除“{name}”及其关联咨询服务和计价数据,是否继续?',
|
|
||||||
deleteBatchTitle: '确认批量删除',
|
|
||||||
deleteBatchDesc: '即将删除 {count} 个合同段及其关联咨询服务和计价数据,是否继续?'
|
|
||||||
},
|
|
||||||
htCard: {
|
|
||||||
title: '合同段:{name}',
|
|
||||||
subtitle: '合同段ID:{id}',
|
|
||||||
metaBudget: '合同段预算金额:{amount}',
|
|
||||||
currencySuffix: '元',
|
|
||||||
categories: {
|
|
||||||
baseInfo: '合同基础信息',
|
|
||||||
scaleInfo: '合同规模',
|
|
||||||
services: '合同费用汇总表',
|
|
||||||
consultFactor: '咨询分类系数',
|
|
||||||
majorFactor: '工程专业系数',
|
|
||||||
additionalFee: '附加工作费',
|
|
||||||
reserveFee: '预备费',
|
|
||||||
summary: '汇总'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htBaseInfo: {
|
|
||||||
title: '基础信息',
|
|
||||||
defaultQuality: '造价咨询服务的综合评价应达到"较好"或综合评分90分',
|
|
||||||
qualityLabel: '质量要求',
|
|
||||||
qualityPlaceholder: '请输入质量要求',
|
|
||||||
durationLabel: '工期要求',
|
|
||||||
durationPlaceholder: '请输入工期要求'
|
|
||||||
},
|
|
||||||
htFactors: {
|
|
||||||
consultCategoryTitle: '咨询分类系数明细',
|
|
||||||
majorTitle: '工程专业系数明细'
|
|
||||||
},
|
|
||||||
htFee: {
|
|
||||||
additionalTitle: '附加工作费',
|
|
||||||
reserveTitle: '预备费'
|
|
||||||
},
|
|
||||||
htInfo: {
|
|
||||||
scaleDetailTitle: '合同规模明细'
|
|
||||||
},
|
|
||||||
htFeeRate: {
|
|
||||||
baseLabel: '基数(所有服务费预算合计)',
|
|
||||||
reserveBaseLabel: '基数(咨询服务总计 + 附加工作费总计)',
|
|
||||||
rateLabel: '费率(%)',
|
|
||||||
ratePlaceholder: '请输入费率,建议1 ~ 5',
|
|
||||||
budgetFeeLabel: '预算费用(自动计算)',
|
|
||||||
remarkLabel: '说明',
|
|
||||||
remarkPlaceholder: '请输入说明'
|
|
||||||
},
|
|
||||||
htZxFw: {
|
|
||||||
title: '咨询服务明细',
|
|
||||||
warning: '※ 请注意检查并修改《规范》建议的限值或特殊值,并在确认金额栏修改',
|
|
||||||
editTabTitle: '服务编辑-{name}',
|
|
||||||
subtotal: '小计',
|
|
||||||
edit: '编辑',
|
|
||||||
resetDefault: '恢复默认',
|
|
||||||
delete: '删除',
|
|
||||||
processDraft: '编制',
|
|
||||||
processReview: '审核',
|
|
||||||
columns: {
|
|
||||||
code: '编码',
|
|
||||||
name: '名称',
|
|
||||||
process: '工作环节',
|
|
||||||
investScale: '投资规模法',
|
|
||||||
landScale: '用地规模法',
|
|
||||||
workload: '工作量法',
|
|
||||||
hourly: '工时法',
|
|
||||||
subtotal: '小计',
|
|
||||||
finalFee: '确认金额 ✎',
|
|
||||||
finalFeeTooltip: '该列支持手动修改,修改后会自动汇总到固定小计行',
|
|
||||||
remark: '备注',
|
|
||||||
actions: '操作'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
resetTitle: '确认恢复默认数据',
|
|
||||||
resetDesc: '会使用合同卡片里面最新填写的规模信息以及系数,自动计算默认数据,覆盖“{name}”当前数据,是否继续?',
|
|
||||||
confirmReset: '确认恢复',
|
|
||||||
deleteTitle: '确认删除服务',
|
|
||||||
deleteDesc: '将逻辑删除“{name}”,已填写的数据不会清空,重新勾选后会恢复,是否继续?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htSummary: {
|
|
||||||
title: '合同段汇总',
|
|
||||||
total: '合计',
|
|
||||||
remark: '说明',
|
|
||||||
placeholder: '请先填咨询服务/附加工作费/预备费的数据',
|
|
||||||
additionalPrefix: '附加工作费',
|
|
||||||
reservePrefix: '预备费',
|
|
||||||
explainByRate: '按费率{rate}%计得{fee}元',
|
|
||||||
explainByHourly: '按工时法计得{fee}元',
|
|
||||||
explainByQuantity: '按数量单价计得{fee}元',
|
|
||||||
columns: {
|
|
||||||
code: '编码',
|
|
||||||
name: '名称',
|
|
||||||
investScale: '投资规模法',
|
|
||||||
landScale: '用地规模法',
|
|
||||||
workload: '工作量法',
|
|
||||||
hourly: '工时法',
|
|
||||||
subtotal: '小计',
|
|
||||||
finalFee: '确认金额'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htFeeGrid: {
|
|
||||||
subtotal: '小计',
|
|
||||||
currentRow: '当前行',
|
|
||||||
unnamed: '未命名',
|
|
||||||
edit: '编辑',
|
|
||||||
clear: '清空',
|
|
||||||
add: '新增',
|
|
||||||
editTabTitle: '费用编辑-{name}',
|
|
||||||
columns: {
|
|
||||||
name: '名字',
|
|
||||||
rateFee: '费率计取',
|
|
||||||
hourlyFee: '工时法',
|
|
||||||
quantityUnitPriceFee: '数量单价',
|
|
||||||
subtotal: '小计',
|
|
||||||
actions: '操作'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
clearTitle: '确认清空',
|
|
||||||
clearDesc: '将清空“{name}”及其编辑页面的可填和自动计算数据,是否继续?',
|
|
||||||
confirmClear: '确认清空'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
xmFactorGrid: {
|
|
||||||
clickToInput: '点击输入',
|
|
||||||
columns: {
|
|
||||||
standardFactor: '标准系数',
|
|
||||||
budgetValue: '预算取值',
|
|
||||||
remark: '说明',
|
|
||||||
groupName: '专业编码以及工程专业名称'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
serviceSelector: {
|
|
||||||
title: '选择服务',
|
|
||||||
clear: '清空',
|
|
||||||
empty: '暂无服务'
|
|
||||||
},
|
|
||||||
zxFwView: {
|
|
||||||
contractPrefix: '合同段:{name}',
|
|
||||||
calcSuffix: '计算',
|
|
||||||
contractId: '合同ID:{id}',
|
|
||||||
workContentTitle: '工作内容',
|
|
||||||
categories: {
|
|
||||||
investmentScale: '投资规模法',
|
|
||||||
investmentScaleFormula: '投资规模法计算公式',
|
|
||||||
landScale: '用地规模法',
|
|
||||||
landScaleFormula: '用地规模法计算公式',
|
|
||||||
workload: '工作量法',
|
|
||||||
hourly: '工时法',
|
|
||||||
workContent: '工作内容',
|
|
||||||
otherService: '其他服务计算'
|
|
||||||
},
|
|
||||||
formulaColumns: {
|
|
||||||
subtitle: '直接展示当前计价法 store 的最新明细,随数据变更自动同步。',
|
|
||||||
amount: '金额(元)',
|
|
||||||
basicFormula: '基本工作计算式',
|
|
||||||
optionalFormula: '可选工作计算式'
|
|
||||||
},
|
|
||||||
unavailable: {
|
|
||||||
investmentScaleTitle: '该服务不适用投资规模法',
|
|
||||||
investmentScaleMessage: '当前服务未启用规模法,投资规模法不可编辑。',
|
|
||||||
landScaleTitle: '该服务不适用用地规模法',
|
|
||||||
landScaleMessage: '当前服务仅支持投资规模法,用地规模法不可编辑。',
|
|
||||||
workloadTitle: '该服务不适用工作量法',
|
|
||||||
workloadMessage: '当前服务未启用工作量法,工作量法不可编辑。',
|
|
||||||
hourlyTitle: '该服务不适用工时法',
|
|
||||||
hourlyMessage: '当前服务未启用工时法,工时法不可编辑。'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htFeeDetail: {
|
|
||||||
subtotal: '小计',
|
|
||||||
currentRow: '当前行',
|
|
||||||
clickToInput: '点击输入',
|
|
||||||
addRow: '添加行',
|
|
||||||
columns: {
|
|
||||||
no: '序号',
|
|
||||||
feeItem: '费用项',
|
|
||||||
unit: '单位',
|
|
||||||
quantity: '数量',
|
|
||||||
unitPrice: '单价(元)',
|
|
||||||
budgetFee: '预算费用(元)',
|
|
||||||
remark: '说明',
|
|
||||||
actions: '操作'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
deleteTitle: '确认删除行',
|
|
||||||
deleteDesc: '将删除“{name}”这条明细,是否继续?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
workContent: {
|
|
||||||
title: '工作内容',
|
|
||||||
addCustom: '添加自定义内容',
|
|
||||||
clickToInput: '点击输入',
|
|
||||||
clickToInputContent: '点击输入工作内容',
|
|
||||||
currentRow: '当前行',
|
|
||||||
unnamed: '未命名',
|
|
||||||
ungrouped: '未分组',
|
|
||||||
type: {
|
|
||||||
basic: '基本工作',
|
|
||||||
optional: '可选工作',
|
|
||||||
daily: '日常顾问',
|
|
||||||
special: '专项顾问',
|
|
||||||
additional: '附加工作',
|
|
||||||
custom: '自定义'
|
|
||||||
},
|
|
||||||
columns: {
|
|
||||||
no: '序号',
|
|
||||||
content: '工作内容',
|
|
||||||
type: '工作类型',
|
|
||||||
remark: '备注',
|
|
||||||
actions: '操作'
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
deleteTitle: '确认删除行',
|
|
||||||
deleteDesc: '将删除“{name}”这条明细,是否继续?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
quickCalc: {
|
|
||||||
projectName: '快速计算',
|
|
||||||
industryLabel: '行业 {name}',
|
|
||||||
selectIndustry: '请选择工程行业',
|
|
||||||
saving: '保存中...',
|
|
||||||
synced: '已同步行业',
|
|
||||||
notSelectedIndustry: '未选择行业',
|
|
||||||
notSelected: '未选择',
|
|
||||||
consultCategory: '咨询类别',
|
|
||||||
majorCategory: '工程专业',
|
|
||||||
types: {
|
|
||||||
consult: {
|
|
||||||
label: '咨询类别(常用)',
|
|
||||||
hint: '先选择咨询类别,再补规模和预算参数。'
|
|
||||||
},
|
|
||||||
general: {
|
|
||||||
label: '通用专业',
|
|
||||||
hint: '跨行业共用的补偿与其他费用专业。'
|
|
||||||
},
|
|
||||||
road: {
|
|
||||||
label: '公路工程专业',
|
|
||||||
hint: '首页行业为公路工程时默认展示。'
|
|
||||||
},
|
|
||||||
railway: {
|
|
||||||
label: '铁路工程专业',
|
|
||||||
hint: '首页行业为铁路工程时默认展示。'
|
|
||||||
},
|
|
||||||
waterway: {
|
|
||||||
label: '水运工程专业',
|
|
||||||
hint: '首页行业为水运工程时默认展示。'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
industry: '工程行业',
|
|
||||||
code: '编码',
|
|
||||||
investScale: '投资规模(万元)',
|
|
||||||
landScale: '用地规模(亩)',
|
|
||||||
formula: '计算式',
|
|
||||||
amount: '金额(元)',
|
|
||||||
consultFactor: '咨询分类系数',
|
|
||||||
majorFactor: '工程专业系数',
|
|
||||||
workEnvCoefficient: '工作环节系数',
|
|
||||||
workEnvCoefficientPlaceholder: '默认 1',
|
|
||||||
budgetAmount: '预算金额(元)'
|
|
||||||
},
|
|
||||||
sections: {
|
|
||||||
basicInfo: '基础信息',
|
|
||||||
scaleBase: '计算基数',
|
|
||||||
benchmarkBudget: '基准预算',
|
|
||||||
serviceBudget: '服务预算',
|
|
||||||
},
|
|
||||||
empty: {
|
|
||||||
selectIndustry: '请选择工程行业。选中后可先选择咨询类别,再显示对应专业分类。',
|
|
||||||
selectConsult: '请先选择咨询类别。选中后才会显示匹配的通用专业和工程专业分类。',
|
|
||||||
scaleUnavailable: '当前咨询类别不适用规模法,因此不显示专业分类。',
|
|
||||||
consultCostOnly: '当前咨询类别按行业汇总计价,工程专业系数已按所选行业自动带入,不再显示内部互补专业行。'
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
selectConsultFirst: '请先选择咨询类别',
|
|
||||||
scaleUnavailable: '当前分类不适用规模法',
|
|
||||||
selectMajorFirst: '请先选择工程专业',
|
|
||||||
preferLandScale: '当前专业按用地规模计价',
|
|
||||||
investUnavailable: '当前专业不适用投资规模',
|
|
||||||
consultCostOnly: '当前分类仅支持投资规模',
|
|
||||||
landUnavailable: '当前专业不适用用地规模',
|
|
||||||
input: '请输入',
|
|
||||||
selectScaleFirst: '请先选择输入对应规模'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methodUnavailable: {
|
|
||||||
defaultTitle: '该服务不适用当前计价方法'
|
|
||||||
},
|
|
||||||
xmCard: {
|
|
||||||
categories: {
|
|
||||||
info: '项目基础信息',
|
|
||||||
scaleInfo: '项目规模',
|
|
||||||
consultCategoryFactor: '咨询分类系数',
|
|
||||||
majorFactor: '工程专业系数',
|
|
||||||
contract: '合同段管理'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
htFeeMethodTypeLine: {
|
|
||||||
feeDetail: '费用明细',
|
|
||||||
unnamed: '未命名',
|
|
||||||
title: '合同段:{contractName} · {rowName}',
|
|
||||||
contractId: '合同ID:{id}',
|
|
||||||
quantityUnitPrice: '数量单价'
|
|
||||||
},
|
|
||||||
pricingScale: {
|
|
||||||
totalInvestmentByIndustry: '{industryName}总投资',
|
|
||||||
totalInvestment: '总投资',
|
|
||||||
clickToInput: '非必填,手动录入,数字,4位',
|
|
||||||
projectLabel: '项目{index}',
|
|
||||||
columns: {
|
|
||||||
code: '编码',
|
|
||||||
investAmount: '造价金额(万元)',
|
|
||||||
landArea: '造价金额(元)',
|
|
||||||
benchmarkBudget: '基准预算(元)',
|
|
||||||
basicWork: '基本工作',
|
|
||||||
optionalWork: '可选工作',
|
|
||||||
subtotal: '小计',
|
|
||||||
budgetFee: '预算费用',
|
|
||||||
consultCategoryFactor: '咨询分类系数',
|
|
||||||
majorFactor: '专业系数',
|
|
||||||
workStageFactor: '工作环节系数',
|
|
||||||
workRatio: '工作占比',
|
|
||||||
total: '合计',
|
|
||||||
remark: '说明',
|
|
||||||
majorGroup: '项目明细费用',
|
|
||||||
name: '名称',
|
|
||||||
number: '编码',
|
|
||||||
base: '计算基础',
|
|
||||||
base2: '计算基数F(万元)',
|
|
||||||
formula: '计算公式',
|
|
||||||
calculationAmount: '计算金额(元)',
|
|
||||||
calculationGroup: '计算公式',
|
|
||||||
serviceFee: '服务费用(元)'
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
resetInvestAmount: '点击右侧↻恢复本列默认造价金额',
|
|
||||||
resetLandArea: '点击右侧↻恢复本列默认用地面积',
|
|
||||||
resetConsultCategoryFactor: '点击右侧↻恢复本列默认咨询分类系数',
|
|
||||||
resetMajorFactor: '点击右侧↻恢复本列默认专业系数'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pricingPane: {
|
|
||||||
projectCount: '项目数量',
|
|
||||||
clearTitle: '确认清空当前明细',
|
|
||||||
confirmClear: '确认清空',
|
|
||||||
useDefault: '使用默认数据',
|
|
||||||
overrideTitle: '确认覆盖当前明细',
|
|
||||||
confirmOverride: '确认覆盖',
|
|
||||||
investment: {
|
|
||||||
title: '投资规模明细',
|
|
||||||
clearDesc: '将清空当前投资规模明细,是否继续?',
|
|
||||||
overrideDesc: '将使用合同默认数据覆盖当前投资规模明细,是否继续?'
|
|
||||||
},
|
|
||||||
land: {
|
|
||||||
title: '用地规模明细',
|
|
||||||
clearDesc: '将清空当前用地规模明细,是否继续?',
|
|
||||||
overrideDesc: '将使用合同默认数据覆盖当前用地规模明细,是否继续?'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
workloadPricing: {
|
|
||||||
title: '工作量明细',
|
|
||||||
unavailableTitle: '该服务不适用工作量法',
|
|
||||||
unavailableMessage: '当前服务没有关联工作量法任务,无需填写此部分内容。',
|
|
||||||
clickToInput: '点击输入',
|
|
||||||
none: '无',
|
|
||||||
total: '总合计',
|
|
||||||
columns: {
|
|
||||||
code: '编码',
|
|
||||||
name: '名称',
|
|
||||||
budgetBase: '计算基础',
|
|
||||||
budgetReferenceUnitPrice: '计算基数(份)',
|
|
||||||
budgetAdoptedUnitPrice: '最低单价(万元/份)',
|
|
||||||
workload: '中值单价(万元/份)',
|
|
||||||
consultCategoryFactor: '最高单价(万元/份)',
|
|
||||||
cLow: '计算最低值(元)',
|
|
||||||
cMid: '计算中值(元)',
|
|
||||||
cHigh: '计算最高值(元)',
|
|
||||||
serviceFee: '本计算取值(元)',
|
|
||||||
remark: '说明'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hourlyFeeGrid: {
|
|
||||||
title: '工时法明细',
|
|
||||||
clickToInput: '点击输入',
|
|
||||||
total: '总合计',
|
|
||||||
columns: {
|
|
||||||
code: '编码',
|
|
||||||
name: '名称',
|
|
||||||
technician: '技术员',
|
|
||||||
assistantEngineer: '助理工程师',
|
|
||||||
midEngineer: '中级工程师(或二级造价工程师)',
|
|
||||||
seniorEngineer: '高级工程师(或一级造价工程师)',
|
|
||||||
profSeniorEngineer: '正高级工程师',
|
|
||||||
unitPrice: '单价(元/工日)',
|
|
||||||
workdayCount: '工日数量(工日)',
|
|
||||||
subtotal: '费用小计(元)',
|
|
||||||
unitPrice2: '单价(元/工日)',
|
|
||||||
workdayCount2: '工日数量(工日)',
|
|
||||||
subtotal2: '费用小计(元)',
|
|
||||||
unitPrice3: '单价(元/工日)',
|
|
||||||
workdayCount3: '工日数量(工日)',
|
|
||||||
subtotal3: '费用小计(元)',
|
|
||||||
unitPrice4: '单价(元/工日)',
|
|
||||||
workdayCount4: '工日数量(工日)',
|
|
||||||
subtotal4: '费用小计(元)',
|
|
||||||
unitPrice5: '单价(元/工日)',
|
|
||||||
workdayCount5: '工日数量(工日)',
|
|
||||||
subtotal5: '费用小计(元)',
|
|
||||||
unitPrice6: '单价(元/工日)',
|
|
||||||
workdayCount6: '工日数量(工日)',
|
|
||||||
subtotal6: '费用小计(元)',
|
|
||||||
avgUnitPrice: '折算单价(元/工日)',
|
|
||||||
remark: '说明',
|
|
||||||
total: '合计',
|
|
||||||
referenceUnitPrice: '参考单价(元/工日)',
|
|
||||||
laborBudgetUnitPrice: '劳动预算单价(元/工日)',
|
|
||||||
compositeBudgetUnitPrice: '综合预算单价(元/工日)',
|
|
||||||
adoptedBudgetUnitPrice: '采用单价(元/工日)',
|
|
||||||
personnelCount: '人员数量(人)',
|
|
||||||
serviceBudget: '服务费用(元)',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
otherService: {
|
|
||||||
title: '其他服务计算',
|
|
||||||
clickToInput: '点击输入',
|
|
||||||
total: '小计',
|
|
||||||
columns: {
|
|
||||||
num: '序号',
|
|
||||||
code: '编码',
|
|
||||||
name: '名称',
|
|
||||||
feeItem: '费用项',
|
|
||||||
unit: '单位',
|
|
||||||
quantity: '数量',
|
|
||||||
unitPrice: '单价',
|
|
||||||
serviceFee: '服务费用(元)',
|
|
||||||
remark: '说明',
|
|
||||||
actions: '操作'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
xmScaleGrid: {
|
|
||||||
syncToastTitle: '已同步咨询服务',
|
|
||||||
syncToastDesc: '规模信息已同步到咨询服务({serviceCount} 项服务,{methodCount} 个计价页,{rowCount} 行)'
|
|
||||||
},
|
|
||||||
xmInfo: {
|
|
||||||
defaultProjectName: 'xxx造价咨询服务',
|
|
||||||
defaultDesc: '在履行造价咨询服务时,宜根据咨询服务质量情况分级确定相应的处罚金额。其中考评得分在大于及等于85和小于90分时,处罚金额为预算费用的10%;其中考评得分在大于及等于80和小于85分时,处罚金额为预算费用的20%;其中考评得分在大于及等于75和小于80分时,处罚金额为预算费用的30%;其中考评得分在大于及等于70和小于75分时,处罚金额为预算费用的40%;其中考评得分小于70分时,处罚金额为预算费用的50%以上。',
|
|
||||||
industryHint: '变更需要重置后重新选择',
|
|
||||||
industryHintAria: '工程行业提示',
|
|
||||||
createFromHomeFirst: '请从首页先新建项目后再进入此页面。',
|
|
||||||
fields: {
|
|
||||||
projectName: '项目名称',
|
|
||||||
projectIndustry: '工程行业',
|
|
||||||
overview: '项目概况',
|
|
||||||
preparedBy: '编制人',
|
|
||||||
reviewedBy: '复核人',
|
|
||||||
preparedCompany: '编制单位',
|
|
||||||
preparedDate: '编制日期',
|
|
||||||
desc: '其他说明'
|
|
||||||
},
|
|
||||||
placeholders: {
|
|
||||||
overview: '请输入项目概况',
|
|
||||||
preparedBy: '请输入编制人',
|
|
||||||
reviewedBy: '请输入复核人',
|
|
||||||
preparedCompany: '请输入编制单位'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<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'
|
||||||
@@ -15,7 +14,6 @@ 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
|
||||||
@@ -32,39 +30,23 @@ const props = withDefaults(
|
|||||||
categories: TypeLineCategory[]
|
categories: TypeLineCategory[]
|
||||||
storageKey?: string
|
storageKey?: string
|
||||||
defaultCategory?: string
|
defaultCategory?: string
|
||||||
persistActiveCategory?: boolean
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
scene: 'default',
|
scene: 'default',
|
||||||
title: undefined,
|
title: '配置',
|
||||||
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 || ''
|
||||||
if (!props.persistActiveCategory) return defaultKey
|
const savedKey = sessionStorage.getItem(cacheKey.value)
|
||||||
const savedKey = readStoredCategory(scopedCacheKey.value)
|
|
||||||
const validSavedKey = props.categories.some(item => item.key === savedKey)
|
const validSavedKey = props.categories.some(item => item.key === savedKey)
|
||||||
return validSavedKey ? (savedKey as string) : defaultKey
|
return validSavedKey ? (savedKey as string) : defaultKey
|
||||||
}
|
}
|
||||||
@@ -72,10 +54,8 @@ const resolveInitialCategory = () => {
|
|||||||
const activeCategory = ref(resolveInitialCategory())
|
const activeCategory = ref(resolveInitialCategory())
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.categories, props.defaultCategory, scopedCacheKey.value],
|
() => [props.categories, props.defaultCategory, cacheKey.value],
|
||||||
() => {
|
() => {
|
||||||
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
|
|
||||||
if (isCurrentValid) return
|
|
||||||
activeCategory.value = resolveInitialCategory()
|
activeCategory.value = resolveInitialCategory()
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
@@ -85,8 +65,7 @@ watch(
|
|||||||
|
|
||||||
const switchCategory = (cat: string) => {
|
const switchCategory = (cat: string) => {
|
||||||
activeCategory.value = cat
|
activeCategory.value = cat
|
||||||
if (!props.persistActiveCategory) return
|
sessionStorage.setItem(cacheKey.value, cat)
|
||||||
writeStoredCategory(scopedCacheKey.value, cat)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeComponent = computed(() => {
|
const activeComponent = computed(() => {
|
||||||
@@ -94,16 +73,12 @@ const activeComponent = computed(() => {
|
|||||||
return selected?.component || props.categories[0]?.component || null
|
return selected?.component || props.categories[0]?.component || null
|
||||||
})
|
})
|
||||||
|
|
||||||
const sideWidthStyle = computed(() => ({ width: 'var(--app-typeline-side-w)' }))
|
const copyBtnText = ref('复制')
|
||||||
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)
|
||||||
|
const titleRef = ref<HTMLElement | null>(null)
|
||||||
|
const isTitleOverflow = ref(false)
|
||||||
|
const subtitleRef = ref<HTMLElement | null>(null)
|
||||||
|
const isSubtitleOverflow = ref(false)
|
||||||
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
|
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let titleOverflowRafId: number | null = null
|
let titleOverflowRafId: number | null = null
|
||||||
|
|
||||||
@@ -119,15 +94,15 @@ const handleCopySubtitle = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
copyBtnText.value = t('typeLine.copied')
|
copyBtnText.value = '已复制'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('copy failed:', error)
|
console.error('copy failed:', error)
|
||||||
copyBtnText.value = t('typeLine.copyFailed')
|
copyBtnText.value = '复制失败'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
||||||
copyBtnTimer = setTimeout(() => {
|
copyBtnTimer = setTimeout(() => {
|
||||||
copyBtnText.value = t('typeLine.copy')
|
copyBtnText.value = '复制'
|
||||||
}, 1200)
|
}, 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,8 +201,8 @@ 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">
|
||||||
<div :style="sideWidthStyle" class="shrink-0 border-r px-4 py-3 flex flex-col gap-6 relative">
|
<div class="w-12/100 border-r p-2 flex flex-col gap-8 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>
|
||||||
@@ -256,25 +231,25 @@ useMotionValueEvent(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="['flex flex-col gap-6 relative ', (props.title || props.subtitle || props.metaText) ? 'mt-3' : 'mt-6']">
|
<div class="flex flex-col gap-10 relative">
|
||||||
<div :style="lineStyle" class="absolute top-3 bottom-3 w-[1.5px] bg-border/60"></div>
|
<div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
|
||||||
|
|
||||||
<div v-for="item in props.categories" :key="item.key"
|
<div v-for="item in props.categories" :key="item.key"
|
||||||
:style="itemGapStyle" class="relative flex items-center cursor-pointer group" @click="switchCategory(item.key)">
|
class="relative flex items-center gap-4 cursor-pointer group" @click="switchCategory(item.key)">
|
||||||
<div :class="[
|
<div :class="[
|
||||||
'z-10 rounded-full border-2 flex items-center justify-center transition-all duration-200',
|
'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
|
||||||
activeCategory === item.key
|
activeCategory === item.key
|
||||||
? 'bg-blue-600 border-blue-600'
|
? 'bg-primary border-primary scale-110'
|
||||||
: 'bg-background border-slate-300 group-hover:border-slate-400'
|
: 'bg-background border-muted-foreground'
|
||||||
]" :style="dotStyle">
|
]">
|
||||||
<div v-if="activeCategory === item.key" class="bg-white rounded-full" :style="dotInnerStyle"></div>
|
<div v-if="activeCategory === item.key" class="w-2 h-2 bg-background rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<span :class="[
|
<span :class="[
|
||||||
'transition-colors duration-200',
|
'text-sm transition-colors',
|
||||||
activeCategory === item.key
|
activeCategory === item.key
|
||||||
? 'font-semibold text-blue-600'
|
? 'font-bold text-primary'
|
||||||
: 'text-slate-500 group-hover:text-slate-700'
|
: 'text-muted-foreground group-hover:text-foreground'
|
||||||
]" :style="labelStyle">
|
]">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,9 +258,9 @@ useMotionValueEvent(
|
|||||||
<DialogRoot v-model:open="sheetOpen">
|
<DialogRoot v-model:open="sheetOpen">
|
||||||
<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-4 right-4 bottom-4 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[12px] leading-5 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground">
|
||||||
<img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-8 w-8 shrink-0 rounded-sm" />
|
<img src="/favicon.ico" alt="众为咨询" class="h-5 w-5 shrink-0 rounded-sm" />
|
||||||
<span>{{ t('typeLine.supportText') }}</span>
|
<span>本网站由众为工程咨询有限公司提供免费技术支持</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
@@ -324,8 +299,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="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-7 w-7 shrink-0 rounded-sm" />
|
<img src="/favicon.ico" alt="众为咨询" class="h-7 w-7 shrink-0 rounded-sm" />
|
||||||
<span class="text-2xl font-semibold leading-none">{{ t('typeLine.aboutTitle') }}</span>
|
<span class="text-2xl font-semibold leading-none">关于我们</span>
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,11 +309,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="t('typeLine.openOfficialSiteAria')" :title="t('typeLine.officialSiteTitle')">
|
aria-label="跳转到官网首页" title="官网首页">
|
||||||
<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" />
|
||||||
@@ -349,10 +324,11 @@ 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>
|
||||||
{{ t('typeLine.aboutParagraph1') }}
|
众为工程咨询有限公司 2009
|
||||||
|
年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ t('typeLine.aboutParagraph2') }}
|
公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,9 +340,9 @@ useMotionValueEvent(
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0 min-h-0 h-full flex flex-col">
|
<div class="w-88/100 min-h-0 h-full flex flex-col">
|
||||||
<ScrollArea class="h-full w-full min-h-0 rightMain">
|
<ScrollArea class="h-full w-full min-h-0 rightMain">
|
||||||
<div class="p-4 h-full min-h-0 flex flex-col">
|
<div class="p-3 h-full min-h-0 flex flex-col">
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
<component :is="activeComponent" />
|
<component :is="activeComponent" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
|
||||||
|
|
||||||
type AnyColDef<TRow> = ColDef<TRow> | ColGroupDef<TRow>
|
|
||||||
|
|
||||||
const numericFieldKeywords = [
|
|
||||||
'amount',
|
|
||||||
'area',
|
|
||||||
'cost',
|
|
||||||
'price',
|
|
||||||
'fee',
|
|
||||||
'budget',
|
|
||||||
'subtotal',
|
|
||||||
'total',
|
|
||||||
'ratio',
|
|
||||||
'rate',
|
|
||||||
'quantity',
|
|
||||||
'count',
|
|
||||||
'num',
|
|
||||||
'workday',
|
|
||||||
'workload',
|
|
||||||
'hourly',
|
|
||||||
'scale',
|
|
||||||
'value',
|
|
||||||
'coe',
|
|
||||||
'factor'
|
|
||||||
]
|
|
||||||
|
|
||||||
const isFiniteNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value)
|
|
||||||
|
|
||||||
const isNumericLikeString = (value: unknown) => {
|
|
||||||
if (typeof value !== 'string') return false
|
|
||||||
const normalized = value.replace(/[,\s]/g, '').replace(/%$/, '')
|
|
||||||
if (!normalized) return false
|
|
||||||
const asNumber = Number(normalized)
|
|
||||||
return Number.isFinite(asNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
const looksNumericByColumnDef = <TRow>(col: ColDef<TRow>) => {
|
|
||||||
const fieldLike = String(col.field ?? col.colId ?? '').toLowerCase()
|
|
||||||
const hasNumericFieldHint = fieldLike
|
|
||||||
? numericFieldKeywords.some(keyword => fieldLike.includes(keyword))
|
|
||||||
: false
|
|
||||||
const type = col.type
|
|
||||||
const typeList = Array.isArray(type) ? type : type ? [type] : []
|
|
||||||
const hasNumericType = typeList.some(item => {
|
|
||||||
const normalized = String(item).toLowerCase()
|
|
||||||
return normalized.includes('numeric') || normalized.includes('rightaligned')
|
|
||||||
})
|
|
||||||
return hasNumericFieldHint || col.cellDataType === 'number' || hasNumericType
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasRightAlignedHeaderClass = (value: unknown) => {
|
|
||||||
if (typeof value === 'string') return value.includes('ag-right-aligned-header')
|
|
||||||
if (Array.isArray(value)) return value.some(item => String(item).includes('ag-right-aligned-header'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeHeaderClass = <TRow>(col: ColDef<TRow>, mustRightAlign: boolean): ColDef<TRow>['headerClass'] => {
|
|
||||||
if (!mustRightAlign) return col.headerClass
|
|
||||||
|
|
||||||
const base = col.headerClass
|
|
||||||
if (!base) return 'ag-right-aligned-header'
|
|
||||||
if (hasRightAlignedHeaderClass(base)) return base
|
|
||||||
|
|
||||||
if (typeof base === 'function') {
|
|
||||||
return params => {
|
|
||||||
const result = base(params)
|
|
||||||
if (!result) return 'ag-right-aligned-header'
|
|
||||||
if (typeof result === 'string') {
|
|
||||||
return result.includes('ag-right-aligned-header')
|
|
||||||
? result
|
|
||||||
: `${result} ag-right-aligned-header`
|
|
||||||
}
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
return hasRightAlignedHeaderClass(result)
|
|
||||||
? result
|
|
||||||
: [...result, 'ag-right-aligned-header']
|
|
||||||
}
|
|
||||||
return 'ag-right-aligned-header'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof base === 'string') return `${base} ag-right-aligned-header`
|
|
||||||
if (Array.isArray(base)) return [...base, 'ag-right-aligned-header']
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeNumericCellClassRules = <TRow>(
|
|
||||||
col: ColDef<TRow>,
|
|
||||||
mustRightAlign: boolean
|
|
||||||
): ColDef<TRow>['cellClassRules'] => {
|
|
||||||
if (!mustRightAlign) return col.cellClassRules
|
|
||||||
|
|
||||||
const baseRules = col.cellClassRules ? { ...col.cellClassRules } : {}
|
|
||||||
const existing = baseRules['ag-right-aligned-cell']
|
|
||||||
if (existing) return baseRules
|
|
||||||
|
|
||||||
baseRules['ag-right-aligned-cell'] = params =>
|
|
||||||
looksNumericByColumnDef(params.colDef as ColDef<TRow>) ||
|
|
||||||
isFiniteNumber(params.value) ||
|
|
||||||
isNumericLikeString(params.value)
|
|
||||||
|
|
||||||
return baseRules
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] => {
|
|
||||||
const baseStyle = { whiteSpace: 'normal', lineHeight: '1.4' }
|
|
||||||
if (!cellStyle) return baseStyle
|
|
||||||
if (typeof cellStyle === 'function') {
|
|
||||||
return params => {
|
|
||||||
const next = cellStyle(params)
|
|
||||||
if (next && typeof next === 'object') {
|
|
||||||
return {
|
|
||||||
...next,
|
|
||||||
...baseStyle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return baseStyle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof cellStyle === 'object') {
|
|
||||||
return {
|
|
||||||
...cellStyle,
|
|
||||||
...baseStyle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cellStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
const enhanceLeafColumn = <TRow>(col: ColDef<TRow>): ColDef<TRow> => {
|
|
||||||
const editable = col.editable
|
|
||||||
const isReadonlyColumn = editable == null || editable === false
|
|
||||||
const shouldRightAlign = looksNumericByColumnDef(col)
|
|
||||||
if (!isReadonlyColumn) {
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
headerClass: mergeHeaderClass(col, shouldRightAlign),
|
|
||||||
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
headerClass: mergeHeaderClass(col, shouldRightAlign),
|
|
||||||
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign),
|
|
||||||
wrapText: true,
|
|
||||||
autoHeight: true,
|
|
||||||
cellStyle: mergeCellStyle(col.cellStyle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enhanceColumn = <TRow>(col: AnyColDef<TRow>): AnyColDef<TRow> => {
|
|
||||||
const maybeGroup = col as ColGroupDef<TRow>
|
|
||||||
if (Array.isArray(maybeGroup.children)) {
|
|
||||||
return {
|
|
||||||
...maybeGroup,
|
|
||||||
children: maybeGroup.children.map(child => enhanceColumn(child as AnyColDef<TRow>))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return enhanceLeafColumn(col as ColDef<TRow>)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const withReadonlyAutoHeight = <TRow>(
|
|
||||||
defs: Array<AnyColDef<TRow>>
|
|
||||||
): Array<AnyColDef<TRow>> => defs.map(def => enhanceColumn(def))
|
|
||||||
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import type { IHeaderComp, IHeaderParams } from 'ag-grid-community'
|
|
||||||
import { i18n } from '@/i18n'
|
|
||||||
|
|
||||||
export type ResetHeaderParams = IHeaderParams & {
|
|
||||||
onReset?: () => void | Promise<void>
|
|
||||||
resetTitle?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AgGridResetHeader implements IHeaderComp {
|
|
||||||
private params!: ResetHeaderParams
|
|
||||||
private eGui!: HTMLDivElement
|
|
||||||
private eLabel!: HTMLSpanElement
|
|
||||||
private eButton!: HTMLButtonElement
|
|
||||||
private onButtonClick = (event: MouseEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
void this.params.onReset?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(params: ResetHeaderParams) {
|
|
||||||
this.params = params
|
|
||||||
|
|
||||||
const eGui = document.createElement('div')
|
|
||||||
eGui.style.display = 'flex'
|
|
||||||
eGui.style.alignItems = 'center'
|
|
||||||
eGui.style.justifyContent = 'space-between'
|
|
||||||
eGui.style.gap = '6px'
|
|
||||||
eGui.style.width = '100%'
|
|
||||||
|
|
||||||
const eLabel = document.createElement('span')
|
|
||||||
eLabel.style.flex = '1'
|
|
||||||
eLabel.style.minWidth = '0'
|
|
||||||
eLabel.style.whiteSpace = 'normal'
|
|
||||||
eLabel.style.lineHeight = '1.2'
|
|
||||||
|
|
||||||
const eButton = document.createElement('button')
|
|
||||||
eButton.type = 'button'
|
|
||||||
eButton.textContent = '↻'
|
|
||||||
const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
|
|
||||||
eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
|
|
||||||
eButton.style.display = 'inline-flex'
|
|
||||||
eButton.style.alignItems = 'center'
|
|
||||||
eButton.style.justifyContent = 'center'
|
|
||||||
eButton.style.width = '18px'
|
|
||||||
eButton.style.height = '18px'
|
|
||||||
eButton.style.border = '1px solid #d1d5db'
|
|
||||||
eButton.style.borderRadius = '999px'
|
|
||||||
eButton.style.background = '#fff'
|
|
||||||
eButton.style.color = '#4b5563'
|
|
||||||
eButton.style.cursor = 'pointer'
|
|
||||||
eButton.style.fontSize = '12px'
|
|
||||||
eButton.style.lineHeight = '1'
|
|
||||||
eButton.style.flex = '0 0 auto'
|
|
||||||
eButton.addEventListener('click', this.onButtonClick)
|
|
||||||
|
|
||||||
eGui.append(eLabel, eButton)
|
|
||||||
|
|
||||||
this.eGui = eGui
|
|
||||||
this.eLabel = eLabel
|
|
||||||
this.eButton = eButton
|
|
||||||
this.refresh(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
getGui() {
|
|
||||||
return this.eGui
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh(params: ResetHeaderParams) {
|
|
||||||
this.params = params
|
|
||||||
this.eLabel.textContent = params.displayName || params.column?.getColDef().headerName || ''
|
|
||||||
const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
|
|
||||||
this.eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
|
|
||||||
this.eButton.style.visibility = params.onReset ? 'visible' : 'hidden'
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.eButton?.removeEventListener('click', this.onButtonClick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
export interface DataEntry {
|
|
||||||
key: string
|
|
||||||
value: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContractSegmentPackage {
|
|
||||||
version: number
|
|
||||||
exportedAt: string
|
|
||||||
packageType?: 'contract-segments'
|
|
||||||
project?: {
|
|
||||||
industry: string
|
|
||||||
}
|
|
||||||
storage?: {
|
|
||||||
localforageEntries: DataEntry[]
|
|
||||||
keyedEntries?: DataEntry[]
|
|
||||||
}
|
|
||||||
contracts: Array<{
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
order: number
|
|
||||||
createdAt: string
|
|
||||||
}>
|
|
||||||
projectIndustry?: string
|
|
||||||
localforageEntries?: DataEntry[]
|
|
||||||
keyedEntries?: DataEntry[]
|
|
||||||
pinia?: {
|
|
||||||
zxFwPricing?: {
|
|
||||||
contracts?: Record<string, unknown>
|
|
||||||
servicePricingStates?: Record<string, unknown>
|
|
||||||
htFeeMainStates?: Record<string, unknown>
|
|
||||||
htFeeMethodStates?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
piniaState?: {
|
|
||||||
zxFwPricing?: {
|
|
||||||
contracts?: Record<string, unknown>
|
|
||||||
servicePricingStates?: Record<string, unknown>
|
|
||||||
htFeeMainStates?: Record<string, unknown>
|
|
||||||
htFeeMethodStates?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
|
|
||||||
export const CONTRACT_SEGMENT_VERSION = 3
|
|
||||||
export const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
|
|
||||||
export const SERVICE_KEY_PREFIX = 'zxFW-'
|
|
||||||
export const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
|
|
||||||
export const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
|
|
||||||
export const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-'] as const
|
|
||||||
export const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
|
||||||
export const PROJECT_SCALE_KEY = 'xm-info-v3'
|
|
||||||
export const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const
|
|
||||||
|
|
||||||
export const formatExportTimestamp = (date: Date): string => {
|
|
||||||
const yyyy = date.getFullYear()
|
|
||||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const dd = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hh = String(date.getHours()).padStart(2, '0')
|
|
||||||
const mi = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
return `${yyyy}${mm}${dd}-${hh}${mi}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
||||||
Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
||||||
|
|
||||||
export const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
|
||||||
|
|
||||||
export const normalizeDataEntries = (value: unknown): DataEntry[] => {
|
|
||||||
if (!Array.isArray(value)) return []
|
|
||||||
return value
|
|
||||||
.filter(item => item && typeof item === 'object' && typeof (item as DataEntry).key === 'string')
|
|
||||||
.map(item => ({
|
|
||||||
key: String((item as DataEntry).key),
|
|
||||||
value: (item as DataEntry).value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
|
|
||||||
projectIndustry:
|
|
||||||
typeof payload.project?.industry === 'string' && payload.project.industry.trim()
|
|
||||||
? payload.project.industry.trim()
|
|
||||||
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
|
||||||
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
|
||||||
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
|
|
||||||
piniaState: payload.pinia ?? payload.piniaState
|
|
||||||
})
|
|
||||||
|
|
||||||
export const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
|
|
||||||
const payload = value as Partial<ContractSegmentPackage> | null
|
|
||||||
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isContractRelatedForageKey = (key: string, contractId: string) => {
|
|
||||||
if (key === `${CONTRACT_KEY_PREFIX}${contractId}`) return true
|
|
||||||
if (key === `${SERVICE_KEY_PREFIX}${contractId}`) return true
|
|
||||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${contractId}`) return true
|
|
||||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${contractId}`) return true
|
|
||||||
if (PRICING_KEY_PREFIXES.some(prefix => key.startsWith(`${prefix}${contractId}-`))) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isContractRelatedKeyedStateKey = (key: string, contractId: string) => {
|
|
||||||
if (key === `ht-base-info-${contractId}`) return true
|
|
||||||
if (key.startsWith(`work-content-${contractId}-`)) return true
|
|
||||||
if (key.startsWith(`work-content-htExtraFee-${contractId}-`)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
|
|
||||||
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
|
|
||||||
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
|
|
||||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${fromId}`) {
|
|
||||||
return `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${toId}`
|
|
||||||
}
|
|
||||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
|
|
||||||
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
|
|
||||||
}
|
|
||||||
if (key === `ht-base-info-${fromId}`) return `ht-base-info-${toId}`
|
|
||||||
if (key.startsWith(`work-content-${fromId}-`)) {
|
|
||||||
return key.replace(`work-content-${fromId}-`, `work-content-${toId}-`)
|
|
||||||
}
|
|
||||||
if (key.startsWith(`work-content-htExtraFee-${fromId}-`)) {
|
|
||||||
return key.replace(`work-content-htExtraFee-${fromId}-`, `work-content-htExtraFee-${toId}-`)
|
|
||||||
}
|
|
||||||
for (const prefix of PRICING_KEY_PREFIXES) {
|
|
||||||
if (key.startsWith(`${prefix}${fromId}-`)) {
|
|
||||||
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
export const generateContractId = (usedIds: Set<string>) => {
|
|
||||||
let nextId = ''
|
|
||||||
while (!nextId || usedIds.has(nextId)) {
|
|
||||||
nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
|
||||||
}
|
|
||||||
usedIds.add(nextId)
|
|
||||||
return nextId
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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
|
||||||
@@ -8,26 +9,6 @@ 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) {
|
||||||
@@ -49,12 +30,6 @@ 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
|
||||||
@@ -66,133 +41,3 @@ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||