From 6d14c587449b4e51ac91e4af70a13f9a5aec79e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=86=95?= Date: Wed, 8 Jul 2026 11:44:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=AD=E4=BB=A3=20ONE-OS=20=E5=A4=9A?= =?UTF-8?q?=E5=8E=9F=E5=9E=8B=EF=BC=9A=E7=A7=9F=E8=B5=81=E5=8F=B0=E8=B4=A6?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E3=80=81=E5=90=88=E5=90=8C=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=20Word=20=E5=AF=BC=E5=85=A5=EF=BC=8C=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=88=86=E9=A1=B5=E4=B8=8E=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 租赁业务台账:运维费汇总、明细钻取弹层、其他收入/减免批量导入 - 合同模板:Word 导入与 docx 预览,模板编辑增强 - 全局规范:TablePagination 统一为「共 x 条 → 页码 → 每页 N 条」,vm-checkbox 与筛选面板 - 新增车辆维修明细、还车应结款原型;车辆管理、收款记录、工作台、加氢站等同步对齐 - 更新侧栏、Legacy Shell、标注源与 to-prd 技能 Co-authored-by: Cursor --- .agents/skills/to-prd/SKILL.md | 75 + .axhub/make/axhub.config.json | 15 +- .axhub/make/sidebar-tree.json | 14 +- .../annotation-nodes.json | 986 ++++-- .../directory-tree.txt | 71 + .../markdown-nodes.json | 269 +- .local/extract-annotation-source/output.json | 1188 ++++++- package-lock.json | 235 +- package.json | 2 + scripts/sync-oneos-web-prototypes.mjs | 12 +- skills-lock.json | 9 +- src/common/TablePagination.tsx | 109 +- src/common/modal-detail-table.css | 7 + .../oneosWebLegacy/OneosWebLegacyShell.tsx | 120 +- src/common/vehicle-brand-model-catalog.js | 67 + src/common/vm-checkbox.css | 118 + src/common/vm-filter-panel.ts | 29 + src/common/vm-pagination.css | 51 +- .../.spec/prototype-review.md | 120 +- .../ContractTemplate.jsx | 679 +++- .../annotation-source.json | 66 +- .../contract-template-docx-fidelity.js | 27 + .../contract-template-section-headings.js | 173 + .../contract-template-seed.js | 18 +- .../contract-template-store.js | 28 +- .../contract-template-types.js | 73 +- .../contract-template-word-import.js | 341 ++ .../scripts/build-annotation-source.mjs | 52 +- .../styles/contract-template.css | 143 +- .../annotation-source.json | 2 +- .../components/FilterPanel.tsx | 2 +- .../components/MultiSelectField.tsx | 1 + src/prototypes/customer-management/index.tsx | 1 - .../annotation-source.json | 2 +- .../annotation-source.json | 133 +- .../lease-business-ledger/columnHeaderTips.ts | 6 +- .../components/AccidentLinkModal.tsx | 1 + .../components/FilterPanel.tsx | 234 +- .../components/LeaseLedgerImportModal.tsx | 79 + .../components/LeaseLedgerTable.tsx | 145 +- .../components/LedgerDetailPopover.tsx | 108 +- .../components/LedgerLineItemsCell.tsx | 189 +- .../components/MaintenanceLinkModal.tsx | 1 + .../components/ReceiptLinkModal.tsx | 1 + .../components/tableColumns.ts | 6 +- .../data/maintenance-ledger-records.json | 26 + .../data/return-settlement-fees.json | 23 + .../lease-business-ledger/data/rows.json | 16 +- .../lease-business-ledger/index.tsx | 136 +- .../scripts/field-annotation-spec.mjs | 15 +- .../lease-business-ledger/styles/index.css | 353 +- src/prototypes/lease-business-ledger/types.ts | 25 +- .../lease-business-ledger/utils/constants.ts | 23 + .../utils/ledger-batch-import.ts | 385 +++ .../lease-business-ledger/utils/ledger.ts | 59 +- .../annotation-source.json | 2 +- .../lease-business-line-overview/lines.ts | 4 +- .../LeaseContractManagement.jsx | 123 +- .../annotation-source.json | 2 +- .../lease-contract-vehicle-clause.js | 6 + .../lease-order-vars.js | 10 +- .../styles/index.css | 1 + .../styles/lease-contract.css | 6 +- src/prototypes/ledger-shared/DESIGN.md | 12 +- .../ledger-shared/styles/ledger-page.css | 115 +- src/prototypes/oneos-web-finance/index.tsx | 38 +- .../requirements-prd-kpi-balance-alert.md | 206 ++ .../.spec/requirements-prd-list.md | 26 +- .../.spec/requirements-prd.md | 6 +- .../.spec/requirements.md | 4 +- .../annotation-source.json | 174 +- .../scripts/build-annotation-source.mjs | 14 +- .../pages/03-站点信息.jsx | 155 +- .../styles/h2-station-vm-filter.css | 24 +- .../oneos-web-ledger-data/index.tsx | 4 +- .../oneos-web-workbench/pages/01-工作台.jsx | 713 +++- .../payment-records/annotation-source.json | 28 +- .../components/AddPaymentModal.tsx | 54 +- .../components/BillLinkModal.tsx | 4 +- .../components/FilterPanel.tsx | 273 +- .../components/PaymentKpiDetailModal.tsx | 12 +- .../components/PaymentRecordsTable.tsx | 24 +- .../payment-records/data/records.json | 12 + src/prototypes/payment-records/index.tsx | 3 + .../payment-records/styles/index.css | 2 +- src/prototypes/payment-records/types.ts | 6 + .../payment-records/utils/payment.ts | 23 + .../annotation-source.json | 2 +- .../components/FilterPanel.tsx | 72 +- .../components/SelfLedgerTable.tsx | 16 +- .../self-operated-business-ledger/index.tsx | 1 - .../annotation-source.json | 2 +- src/prototypes/supplier-management/index.tsx | 1 - .../supplier-management/styles/index.css | 2 +- .../annotation-source.json | 2 +- .../.spec/requirements-prd.md | 129 + .../MaintenanceLedgerPage.jsx | 2941 +++++++++++++++++ .../annotation-source.json | 406 +++ .../vehicle-maintenance-ledger/globals.ts | 18 + .../vehicle-maintenance-ledger/index.tsx | 41 + .../styles/index.css | 640 ++++ .../vehicle-management/annotation-source.json | 2 +- .../components/DateRangeFilterField.tsx | 8 +- .../components/DetailAccidentRecordsTab.tsx | 19 +- .../DetailAnnualReviewRecordsTab.tsx | 19 +- .../components/DetailFaultRecordsTab.tsx | 19 +- .../components/DetailLeaseRecordsTab.tsx | 19 +- .../components/DetailMovementRecordsTab.tsx | 19 +- .../components/DetailTransferRecordsTab.tsx | 19 +- .../components/DetailViolationRecordsTab.tsx | 19 +- .../components/FilterPickerField.tsx | 175 +- .../components/MultiPlateFilterField.tsx | 82 + .../components/TablePagination.tsx | 1 + src/prototypes/vehicle-management/style.css | 139 +- .../vehicle-return-settlement/index.tsx | 33 + src/prototypes/vm-shared/DESIGN.md | 121 +- .../contract-template-management/PRD.md | 121 +- .../styles/vehicle-management.css | 1 + 118 files changed, 12360 insertions(+), 1884 deletions(-) create mode 100644 .agents/skills/to-prd/SKILL.md create mode 100644 .local/extract-annotation-source/directory-tree.txt create mode 100644 src/common/vehicle-brand-model-catalog.js create mode 100644 src/common/vm-checkbox.css create mode 100644 src/common/vm-filter-panel.ts create mode 100644 src/prototypes/contract-template-management/contract-template-docx-fidelity.js create mode 100644 src/prototypes/contract-template-management/contract-template-section-headings.js create mode 100644 src/prototypes/contract-template-management/contract-template-word-import.js create mode 100644 src/prototypes/lease-business-ledger/components/LeaseLedgerImportModal.tsx create mode 100644 src/prototypes/lease-business-ledger/data/maintenance-ledger-records.json create mode 100644 src/prototypes/lease-business-ledger/data/return-settlement-fees.json create mode 100644 src/prototypes/lease-business-ledger/utils/constants.ts create mode 100644 src/prototypes/lease-business-ledger/utils/ledger-batch-import.ts create mode 100644 src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md create mode 100644 src/prototypes/vehicle-maintenance-ledger/.spec/requirements-prd.md create mode 100644 src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx create mode 100644 src/prototypes/vehicle-maintenance-ledger/annotation-source.json create mode 100644 src/prototypes/vehicle-maintenance-ledger/globals.ts create mode 100644 src/prototypes/vehicle-maintenance-ledger/index.tsx create mode 100644 src/prototypes/vehicle-maintenance-ledger/styles/index.css create mode 100644 src/prototypes/vehicle-management/components/MultiPlateFilterField.tsx create mode 100644 src/prototypes/vehicle-return-settlement/index.tsx diff --git a/.agents/skills/to-prd/SKILL.md b/.agents/skills/to-prd/SKILL.md new file mode 100644 index 0000000..e5f1141 --- /dev/null +++ b/.agents/skills/to-prd/SKILL.md @@ -0,0 +1,75 @@ +--- +name: to-prd +description: Turn the current conversation into a PRD and publish it to the project issue tracker — no interview, just synthesis of what you've already discussed. +disable-model-invocation: true +--- + +This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know. + +The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not. + +## Process + +1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching. + +2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can. The fewer seams across the codebase, the better - the ideal number is one. + +Check with the user that these seams match their expectations. + +3. Write the PRD using the template below, then publish it to the project issue tracker. Apply the `ready-for-agent` triage label - no need for additional triage. + + + +## Problem Statement + +The problem that the user is facing, from the user's perspective. + +## Solution + +The solution to the problem, from the user's perspective. + +## User Stories + +A LONG, numbered list of user stories. Each user story should be in the format of: + +1. As an , I want a , so that + + +1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending + + +This list of user stories should be extremely extensive and cover all aspects of the feature. + +## Implementation Decisions + +A list of implementation decisions that were made. This can include: + +- The modules that will be built/modified +- The interfaces of those modules that will be modified +- Technical clarifications from the developer +- Architectural decisions +- Schema changes +- API contracts +- Specific interactions + +Do NOT include specific file paths or code snippets. They may end up being outdated very quickly. + +Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits. + +## Testing Decisions + +A list of testing decisions that were made. Include: + +- A description of what makes a good test (only test external behavior, not implementation details) +- Which modules will be tested +- Prior art for the tests (i.e. similar types of tests in the codebase) + +## Out of Scope + +A description of the things that are out of scope for this PRD. + +## Further Notes + +Any further notes about the feature. + + diff --git a/.axhub/make/axhub.config.json b/.axhub/make/axhub.config.json index 35830c1..952115b 100644 --- a/.axhub/make/axhub.config.json +++ b/.axhub/make/axhub.config.json @@ -28,8 +28,8 @@ "productionBranch": "main" }, "s3": { - "accessKeyId": "", - "secretAccessKey": "", + "accessKeyId": "LTAI5t9EV2UByZDBcNSgmm2q", + "secretAccessKey": "pozhgZ58J0XJ2pqKyM3pY1tZLClata", "region": "cn-hangzhou", "bucket": "model-lnoneos", "prefix": "", @@ -39,10 +39,15 @@ "githubPages": { "repository": "", "branch": "gh-pages", - "sourceDirectory": "/" + "sourceDirectory": "/", + "pathPrefix": "" }, "publishSettings": { - "includeSource": true + "includeSource": true, + "visibleTargets": [ + "axhub", + "s3" + ] } } -} +} \ No newline at end of file diff --git a/.axhub/make/sidebar-tree.json b/.axhub/make/sidebar-tree.json index d1bd8cc..9c9df69 100644 --- a/.axhub/make/sidebar-tree.json +++ b/.axhub/make/sidebar-tree.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-07-06T05:10:19.174Z", + "updatedAt": "2026-07-07T09:35:00.000Z", "prototypes": [ { "id": "folder-1782874576229-r8atbu", @@ -118,6 +118,12 @@ "kind": "item", "title": "车辆氢费明细", "itemKey": "prototypes/vehicle-h2-fee-ledger" + }, + { + "id": "item:prototypes:vehicle-maintenance-ledger", + "kind": "item", + "title": "维修保养台账", + "itemKey": "prototypes/vehicle-maintenance-ledger" } ] }, @@ -131,6 +137,12 @@ "kind": "item", "title": "收款记录", "itemKey": "prototypes/payment-records" + }, + { + "id": "item:prototypes:vehicle-return-settlement", + "kind": "item", + "title": "还车应结款", + "itemKey": "prototypes/vehicle-return-settlement" } ] } diff --git a/.local/extract-annotation-source/annotation-nodes.json b/.local/extract-annotation-source/annotation-nodes.json index eaa9a4b..e413de1 100644 --- a/.local/extract-annotation-source/annotation-nodes.json +++ b/.local/extract-annotation-source/annotation-nodes.json @@ -1,287 +1,821 @@ [ { - "id": "ct-list-filter", + "id": "lbl-filter", "index": 1, "title": "筛选条件", "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-filter\"]" + ], + "fingerprint": "lbl-filter", + "path": [] + }, + "aiPrompt": "年份、月份、部门、业务员等筛选。", + "annotationText": "## 2. 筛选区\n\n默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。\n\n| 筛选项 | 说明 |\n|---|---|\n| 年份 / 月份 | 账单所属年月 |\n| 业务部门 / 业务员 | 精确匹配 |\n| 车牌号码 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 明细状态 | 草稿 / 已确认 / 部分收款 / 已收款 |\n| 付款日期 | 日期区间 |\n\n---", "hasMarkdown": true, - "annotationText": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 文本输入 | 支持回车触发查询 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 适用品牌 | 下拉 | 选项来自车型主数据品牌列表 |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认", - "aiPrompt": "列表页筛选区字段与查询逻辑。", "color": "#2563eb", "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-list-filter\"]" - ], - "fingerprint": "ct-list-filter", - "path": [] - } + "createdAt": 1740787200000, + "updatedAt": 1740787200000, + "controls": [] }, { - "id": "ct-list-toolbar", + "id": "lbl-kpi", "index": 2, - "title": "新增模板", + "title": "KPI 统计", "pageId": "list", - "hasMarkdown": false, - "annotationText": "进入新增页,默认从「合同主体」章节开始编辑。", - "aiPrompt": "列表工具栏新增入口。", - "color": "#32a06e", - "images": [], - "controls": [], "locator": { "selectors": [ - "[data-annotation-id=\"ct-list-toolbar\"]" + "[data-annotation-id=\"lbl-kpi\"]" ], - "fingerprint": "ct-list-toolbar", + "fingerprint": "lbl-kpi", "path": [] - } + }, + "aiPrompt": "六张统计卡片口径。", + "annotationText": "## 3. KPI 统计卡片(6 张)\n\n台账条数、应收总计、实收总计、未收总计、成本总计、盈亏总计。\n\n- **台账条数**:员工仅统计本人录入;主管统计全组。点击切换列表为显示全部。\n- **未收总计**:点击筛选下方列表为未收款数据。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1740787200000, + "controls": [] }, { - "id": "ct-list-table", + "id": "lbl-kpi-count", "index": 3, - "title": "Word 模板列表", + "title": "台账条数", "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-kpi-count\"]" + ], + "fingerprint": "lbl-kpi-count", + "path": [] + }, + "aiPrompt": "台账条数 KPI 点击切换列表。", + "annotationText": "## 台账条数 KPI\n\n业务服务组员工只能看到自己录入的数据条数以及金额;业务服务组主管可以查看所有业务服务组人员录入的数据总和。点击可切换列表显示全部数据。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-kpi-unreceived", + "index": 4, + "title": "未收总计", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-kpi-unreceived\"]" + ], + "fingerprint": "lbl-kpi-unreceived", + "path": [] + }, + "aiPrompt": "未收总计 KPI 筛选未收款。", + "annotationText": "## 未收总计 KPI\n\n点击未收总计卡片,下方列表仅展示未收款数据(状态不等于已收款)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-toolbar", + "index": 5, + "title": "列表工具栏", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-toolbar\"]" + ], + "fingerprint": "lbl-toolbar", + "path": [] + }, + "aiPrompt": "导入、导出、标记确认、提交审批。", + "annotationText": "## 4. 列表工具栏\n\n左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板并上传 Excel(原型 Toast) |\n| 导出 | 导出当前筛选结果 |\n| 标记为已确认 | 勾选草稿行后二次确认 |\n| 提交收款审批 | 勾选行提交审批 |\n\n添加行已移至表格底部;列表采用下拉加载更多,不做分页。\n\n---", "hasMarkdown": true, - "annotationText": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同类型 | `contractType` | 展示用户自定义的合同类型文案;已发布模板在类型后以 Tag 显示「启用」或「停用」 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名 |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 切换模板是否可供租赁合同创建选用 |\n| 编辑 | 未启用或草稿 | 进入编辑页 |\n| 更多 → 版本日志 | 已发布 | 查看发布归档快照 |\n| 更多 → 删除 | 未处于启用锁定 | 弹窗确认后删除 |\n\n**多套模板并存**:同一合同类型可维护多套模板;租赁合同创建时先选合同类型,再自动套用该类型下已启用的标准模板(`isDefault` 优先)。\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要。", - "aiPrompt": "主表与子表嵌套列表结构。", "color": "#0f766e", "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-list-table\"]" - ], - "fingerprint": "ct-list-table", - "path": [] - } + "createdAt": 1740787200000, + "updatedAt": 1740787200000, + "controls": [] }, { - "id": "ct-editor-topbar", - "index": 4, - "title": "编辑页顶栏", - "pageId": "editor", - "hasMarkdown": true, - "annotationText": "### 8.2 顶栏\n\n| 元素 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布 |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**降为草稿**(清空版本号) |\n| **保存并发布** | 新建已发布记录 + 版本号 | 原记录变已发布 + 版本号 | **新建一条已发布记录**(新版本),原版本保留 |\n\n**校验**:保存/发布前,合同主体(`sectionContents.main`)不能为空,且须已填写合同类型(见 §8.4)。", - "aiPrompt": "返回、保存草稿、保存并发布。", - "color": "#64748b", - "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-editor-topbar\"]" - ], - "fingerprint": "ct-editor-topbar", - "path": [] - } - }, - { - "id": "ct-section-tabs", - "index": 5, - "title": "章节 Tab", - "pageId": "editor", - "hasMarkdown": true, - "annotationText": "### 8.3 章节 Tab\n\n- 共 9 个章节,**全部在横向滚动条中展示**(带序号 1–9),当前章节自动滚入视野。\n- 右上角 **章节列表** 可搜索并跳转(适合长标题章节)。\n- 当前章节全称在 Tab 下方展示,避免标题被截断后找不到位置。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**;其他 Tab 仅展示该章节切片。", - "aiPrompt": "9 个合同章节切换。", - "color": "#2563eb", - "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-section-tabs\"]" - ], - "fingerprint": "ct-section-tabs", - "path": [] - } - }, - { - "id": "ct-contract-type", + "id": "lbl-btn-mark-confirmed", "index": 6, - "title": "合同类型", - "pageId": "editor", - "hasMarkdown": true, - "annotationText": "### 8.4 合同类型(仅合同主体 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 合同类型输入框 | **必填**;用户自行填写,如「正式合同」「现代18吨试用合同」 |\n| 展示位置 | 章节 Tab 下方配置卡片,与附件章节的「适用车型」卡片交互一致 |\n| 用途 | 同步至列表页「合同类型」列;租赁合同创建时按类型匹配标准模板 |\n\n**规则**:\n\n- 仅当选中 **合同主体** 章节 Tab 时显示本卡片。\n- 未填写时无法保存或发布,并提示「请输入合同类型」。\n- 切换至租赁订单等附件章节时,显示「适用车型」卡片,本卡片隐藏。\n- 保存/发布时写入模板 `contractType` 字段。", - "aiPrompt": "合同主体章节下的合同类型配置。", - "color": "#7c3aed", - "images": [], - "controls": [], + "title": "标记为已确认", + "pageId": "list", "locator": { "selectors": [ - "[data-annotation-id=\"ct-contract-type\"]" + "[data-annotation-id=\"lbl-btn-mark-confirmed\"]" ], - "fingerprint": "ct-contract-type", + "fingerprint": "lbl-btn-mark-confirmed", "path": [] - } + }, + "aiPrompt": "草稿批量确认与权限收口。", + "annotationText": "## 标记为已确认\n\n勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改。\n\n---", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] }, { - "id": "ct-vehicle-binding", + "id": "lbl-btn-add-row", "index": 7, - "title": "适用车型绑定", - "pageId": "editor", - "hasMarkdown": true, - "annotationText": "### 8.5 适用车型绑定(附件章节 Tab:租赁订单~安全责任协议)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌型号选择器 | 点击输入框弹出卡片;支持搜索筛选 |\n| 已选区 | 弹层内展示已选「品牌 · 型号」,可逐项移除 |\n| 品牌列 | 左侧列表切换品牌 |\n| 型号区 | 右侧多选型号(点击切换选中) |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定。\n- 未绑定任何车型时表示全部车型适用。", - "aiPrompt": "附件章节适用车型配置。", - "color": "#059669", - "images": [], - "controls": [], + "title": "添加行", + "pageId": "list", "locator": { "selectors": [ - "[data-annotation-id=\"ct-vehicle-binding\"]" + "[data-annotation-id=\"lbl-btn-add-row\"]" ], - "fingerprint": "ct-vehicle-binding", + "fingerprint": "lbl-btn-add-row", "path": [] - } + }, + "aiPrompt": "表格底部新增行。", + "annotationText": "## 添加行\n\n位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制。\n\n---", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] }, { - "id": "ct-editor-panel", + "id": "lbl-table", "index": 8, - "title": "编辑区", - "pageId": "editor", + "title": "租赁台账宽表", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-table\"]" + ], + "fingerprint": "lbl-table", + "path": [] + }, + "aiPrompt": "运营明细台账列表与行操作。", + "annotationText": "## 5. 列表字段\n\n对齐 Excel「租赁车辆收入明细表」全量字段:年份、月份、业务部门(含业务员)、付款日期、车牌(含车型)、客户(含项目)、增值服务、享免政策、提车/合同/起止日期、平均天数、押金、合同标的租金、应收与收入拆分、减免、实收/未收、开票与付款信息、成本拆分、盈亏、GPS里程、减免明细、资产归属、签约公司、备注。\n\n宽表支持横向滚动;序号与操作列固定。", "hasMarkdown": true, - "annotationText": "### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 富文本工具栏(节选)\n\n| 按钮 | 说明 |\n|---|---|\n| **红线** | 在章节中选中内容后标记;新增租赁合同时**修改红线内文字**将自动变为非标合同,**不修改**则仍为标准流程 |\n| **取消**(红线旁) | 选中已标红线内容后点击,取消红线;取消后修改该部分不再触发非标流程 |\n| **条件** | 在**合同主体及租赁订单~安全责任协议**各章节圈定具体内容,使**部分条款**仅对指定车型生效(创建合同时按订单车辆命中显隐) |\n| **取消**(条件旁) | 选中已设条件的条款后点击,解除该部分的车型限定 |\n| **锁定** | 选中章节内内容后标记;新增租赁合同时该部分**无法被篡改** |\n| **解除**(锁定旁) | 选中已锁定内容后点击,解除锁定 |\n\n**叠加规则**:红线、条件与锁定可作用于**同一段内容**,互不排斥。\n\n**与章节「适用车型」的区别**:\n\n- **适用车型**(附件章节 Tab 上方卡片):控制整章是否在创建合同时对某车型**可见/不可见**。\n- **条件**(工具栏):仅作用于章节内的**部分段落**,不影响整章显隐。\n\n#### 8.6.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 选中段落或文字;\n2. 点击「条件」并绑定「品牌 · 型号」;\n3. 创建合同时:订单车辆任意一辆命中 → 段落纳入正文,否则隐藏。", - "aiPrompt": "Word 版式分页编辑区。", - "color": "#7c3aed", - "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-editor-panel\"]" - ], - "fingerprint": "ct-editor-panel", - "path": [] - } - }, - { - "id": "ct-rte-risk-redline", - "index": 9, - "title": "红线", - "pageId": "editor", - "hasMarkdown": false, - "annotationText": "红线功能主要用于在章节中选择特定内容,点击红线将其设置为篡改后自动变成非标合同。不修改红线内内容不会导致流程变为非标。", - "aiPrompt": "风控红线条款标记。", - "color": "#dc2626", - "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-rte-risk-redline\"]" - ], - "fingerprint": "ct-rte-risk-redline", - "path": [] - } - }, - { - "id": "ct-rte-unrisk-redline", - "index": 10, - "title": "取消红线", - "pageId": "editor", - "hasMarkdown": false, - "annotationText": "取消功能主要用于取消被选中的部分的红线内容。取消后,在新增租赁合同时修改该部分内容,不会导致流程变为非标准流程。", - "aiPrompt": "取消风控红线标记。", - "color": "#94a3b8", - "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-rte-unrisk-redline\"]" - ], - "fingerprint": "ct-rte-unrisk-redline", - "path": [] - } - }, - { - "id": "ct-rte-vehicle-clause", - "index": 11, - "title": "条件", - "pageId": "editor", - "hasMarkdown": false, - "annotationText": "条件可在合同主体及租赁订单~安全责任协议全部章节使用:圈定章节内具体内容,使部分条款仅对指定车型生效。与各章节「适用车型」(控制整章是否可见)不同,条件只作用于章节内部分段落。", - "aiPrompt": "条件条款(段落级车型显隐)。", "color": "#2563eb", "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-rte-vehicle-clause\"]" - ], - "fingerprint": "ct-rte-vehicle-clause", - "path": [] - } + "createdAt": 1740787200000, + "updatedAt": 1782700000000, + "controls": [] }, { - "id": "ct-rte-unvehicle-clause", - "index": 12, - "title": "取消条件", - "pageId": "editor", - "hasMarkdown": false, - "annotationText": "当条款被设置条件后,可以通过选中特定条款内容,点击取消来取消该部分内容的车型限定。", - "aiPrompt": "取消条件条款标记。", + "id": "lbl-col-status", + "index": 9, + "title": "状态流转", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-status\"]" + ], + "fingerprint": "lbl-col-status", + "path": [] + }, + "aiPrompt": "状态流转字段说明。", + "annotationText": "## 状态流转\n\n1. 新增/导入 → **草稿**(业务服务组可删改)\n2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)\n3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款**\n\n---", + "hasMarkdown": true, "color": "#64748b", "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-rte-unvehicle-clause\"]" - ], - "fingerprint": "ct-rte-unvehicle-clause", - "path": [] - } + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] }, { - "id": "ct-rte-inline-lock", - "index": 13, - "title": "锁定", - "pageId": "editor", - "hasMarkdown": false, - "annotationText": "锁定主要用于选中章节内的内容;锁定之后该部分内容无法在新增租赁合同时被篡改。红线、条件以及锁定可以在同一段内容同时生效。", - "aiPrompt": "内容锁定区标记。", - "color": "#b45309", - "images": [], - "controls": [], + "id": "lbl-col-dept", + "index": 10, + "title": "业务部门", + "pageId": "list", "locator": { "selectors": [ - "[data-annotation-id=\"ct-rte-inline-lock\"]" + "[data-annotation-id=\"lbl-col-dept\"]" ], - "fingerprint": "ct-rte-inline-lock", + "fingerprint": "lbl-col-dept", "path": [] - } - }, - { - "id": "ct-rte-uninline-lock", - "index": 14, - "title": "解除锁定", - "pageId": "editor", - "hasMarkdown": false, - "annotationText": "选中已锁定内容后点击解除;解除后新增租赁合同时可再次编辑该部分。", - "aiPrompt": "解除内容锁定标记。", - "color": "#94a3b8", - "images": [], - "controls": [], - "locator": { - "selectors": [ - "[data-annotation-id=\"ct-rte-uninline-lock\"]" - ], - "fingerprint": "ct-rte-uninline-lock", - "path": [] - } - }, - { - "id": "ct-preview-panel", - "index": 15, - "title": "实时预览", - "pageId": "editor", + }, + "aiPrompt": "业务部门字段说明。", + "annotationText": "## 业务部门列\n\n业务部门与业务员合并为一列:上方业务部门,下方业务员。\n\n---", "hasMarkdown": true, - "annotationText": "### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充", - "aiPrompt": "A4 分页预览与模拟车型。", - "color": "#0ea5e9", + "color": "#64748b", "images": [], - "controls": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-plate", + "index": 11, + "title": "车牌号码", + "pageId": "list", "locator": { "selectors": [ - "[data-annotation-id=\"ct-preview-panel\"]" + "[data-annotation-id=\"lbl-col-plate\"]" ], - "fingerprint": "ct-preview-panel", + "fingerprint": "lbl-col-plate", "path": [] - } + }, + "aiPrompt": "车牌号码字段说明。", + "annotationText": "## 车牌号码列\n\n车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-customer", + "index": 12, + "title": "客户名称", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-customer\"]" + ], + "fingerprint": "lbl-col-customer", + "path": [] + }, + "aiPrompt": "客户名称字段说明。", + "annotationText": "## 客户名称列\n\n客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-pickup-date", + "index": 13, + "title": "提车日期", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-pickup-date\"]" + ], + "fingerprint": "lbl-col-pickup-date", + "path": [] + }, + "aiPrompt": "提车日期字段说明。", + "annotationText": "## 提车日期\n\n等于交车管理中该车辆运维 E 签宝签字完成的时间。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-deposit", + "index": 14, + "title": "保证金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-deposit\"]" + ], + "fingerprint": "lbl-col-deposit", + "path": [] + }, + "aiPrompt": "保证金字段说明。", + "annotationText": "## 保证金\n\n与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-contract-rent", + "index": 15, + "title": "合同标的租金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-contract-rent\"]" + ], + "fingerprint": "lbl-col-contract-rent", + "path": [] + }, + "aiPrompt": "合同标的租金字段说明。", + "annotationText": "## 合同标的租金\n\n等于该车辆在签订该合同时合同中的租金(月租金标准)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-receivable-rent", + "index": 16, + "title": "应收租金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-receivable-rent\"]" + ], + "fingerprint": "lbl-col-receivable-rent", + "path": [] + }, + "aiPrompt": "应收租金字段说明。", + "annotationText": "## 应收租金\n\n按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-monthly-income", + "index": 17, + "title": "月度收入", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-monthly-income\"]" + ], + "fingerprint": "lbl-col-monthly-income", + "path": [] + }, + "aiPrompt": "月度收入字段说明。", + "annotationText": "## 月度收入\n\n租金 + 服务费 − 里程减免金额 − 其他减免金额(后两项为减项)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-monthly-rent", + "index": 18, + "title": "月度租金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-monthly-rent\"]" + ], + "fingerprint": "lbl-col-monthly-rent", + "path": [] + }, + "aiPrompt": "月度租金字段说明。", + "annotationText": "## 月度租金\n\n根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-maintenance-income", + "index": 19, + "title": "维保包干收入", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-maintenance-income\"]" + ], + "fingerprint": "lbl-col-maintenance-income", + "path": [] + }, + "aiPrompt": "维保包干收入字段说明。", + "annotationText": "## 维保包干收入\n\n合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-insurance-surcharge", + "index": 20, + "title": "保险上浮费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-insurance-surcharge\"]" + ], + "fingerprint": "lbl-col-insurance-surcharge", + "path": [] + }, + "aiPrompt": "保险上浮费字段说明。", + "annotationText": "## 保险上浮费\n\n事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-ops-income", + "index": 21, + "title": "运维费(收入侧)", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-ops-income\"]" + ], + "fingerprint": "lbl-col-ops-income", + "path": [] + }, + "aiPrompt": "运维费(收入侧)字段说明。", + "annotationText": "## 运维费(收入侧)\n\n维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-other-income", + "index": 22, + "title": "其他收入", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-other-income\"]" + ], + "fingerprint": "lbl-col-other-income", + "path": [] + }, + "aiPrompt": "其他收入字段说明。", + "annotationText": "## 其他收入\n\n手工填写,预留作为特殊情况记录使用。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-received-amount", + "index": 23, + "title": "实收金额", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-received-amount\"]" + ], + "fingerprint": "lbl-col-received-amount", + "path": [] + }, + "aiPrompt": "实收金额字段说明。", + "annotationText": "## 实收金额\n\n根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-unreceived", + "index": 24, + "title": "未收金额", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-unreceived\"]" + ], + "fingerprint": "lbl-col-unreceived", + "path": [] + }, + "aiPrompt": "未收金额字段说明。", + "annotationText": "## 未收金额\n\n未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-invoice-date", + "index": 25, + "title": "开票日期", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-invoice-date\"]" + ], + "fingerprint": "lbl-col-invoice-date", + "path": [] + }, + "aiPrompt": "开票日期字段说明。", + "annotationText": "## 开票日期\n\n账单开票后由财务维护,自动反写,只读。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-actual-payment", + "index": 26, + "title": "实际付款日期", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-actual-payment\"]" + ], + "fingerprint": "lbl-col-actual-payment", + "path": [] + }, + "aiPrompt": "实际付款日期字段说明。", + "annotationText": "## 实际付款日期\n\n关联收款记录时取对应到账记录的付款日期;未关联时为空。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-payment-method", + "index": 27, + "title": "付款方式", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-payment-method\"]" + ], + "fingerprint": "lbl-col-payment-method", + "path": [] + }, + "aiPrompt": "付款方式字段说明。", + "annotationText": "## 付款方式\n\n由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-vehicle-standard-cost", + "index": 28, + "title": "车辆标准成本", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-vehicle-standard-cost\"]" + ], + "fingerprint": "lbl-col-vehicle-standard-cost", + "path": [] + }, + "aiPrompt": "车辆标准成本字段说明。", + "annotationText": "## 车辆标准成本\n\n固定成本,按车型明细维护,只读。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-vehicle-actual-cost", + "index": 29, + "title": "车辆实际成本", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-vehicle-actual-cost\"]" + ], + "fingerprint": "lbl-col-vehicle-actual-cost", + "path": [] + }, + "aiPrompt": "车辆实际成本字段说明。", + "annotationText": "## 车辆实际成本\n\n车辆标准成本 ×(实际天数 / 30),自动计算。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-insurance-fee", + "index": 30, + "title": "保险费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-insurance-fee\"]" + ], + "fingerprint": "lbl-col-insurance-fee", + "path": [] + }, + "aiPrompt": "保险费字段说明。", + "annotationText": "## 保险费\n\n暂保留,不可编辑和导入;后续可能自动计算。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-hydrogen-fee", + "index": 31, + "title": "氢费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-hydrogen-fee\"]" + ], + "fingerprint": "lbl-col-hydrogen-fee", + "path": [] + }, + "aiPrompt": "氢费字段说明。", + "annotationText": "## 氢费\n\n表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-ops-cost", + "index": 32, + "title": "运维费(成本侧)", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-ops-cost\"]" + ], + "fingerprint": "lbl-col-ops-cost", + "path": [] + }, + "aiPrompt": "运维费(成本侧)字段说明。", + "annotationText": "## 运维费(成本侧)\n\n与收入侧运维费平进平出,自动同步。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-brokerage-fee", + "index": 33, + "title": "居间费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-brokerage-fee\"]" + ], + "fingerprint": "lbl-col-brokerage-fee", + "path": [] + }, + "aiPrompt": "居间费字段说明。", + "annotationText": "## 居间费\n\n手动输入。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-other-cost", + "index": 34, + "title": "其他(成本侧)", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-other-cost\"]" + ], + "fingerprint": "lbl-col-other-cost", + "path": [] + }, + "aiPrompt": "其他(成本侧)字段说明。", + "annotationText": "## 其他(成本侧)\n\n前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-gps-mileage", + "index": 35, + "title": "GPS里程", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-gps-mileage\"]" + ], + "fingerprint": "lbl-col-gps-mileage", + "path": [] + }, + "aiPrompt": "GPS里程字段说明。", + "annotationText": "## GPS里程\n\n从 BI 里程取账单时间段的数据,自动展示。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-deduction-detail", + "index": 36, + "title": "其他减免金额明细内容", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-deduction-detail\"]" + ], + "fingerprint": "lbl-col-deduction-detail", + "path": [] + }, + "aiPrompt": "其他减免金额明细内容字段说明。", + "annotationText": "## 其他减免金额明细内容\n\n手动输入,备注明细内容。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-asset-ownership", + "index": 37, + "title": "资产归属", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-asset-ownership\"]" + ], + "fingerprint": "lbl-col-asset-ownership", + "path": [] + }, + "aiPrompt": "资产归属字段说明。", + "annotationText": "## 资产归属\n\n显示车辆实际归属公司名称,按车辆档案自动获取。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-signing-company", + "index": 38, + "title": "签约公司", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-signing-company\"]" + ], + "fingerprint": "lbl-col-signing-company", + "path": [] + }, + "aiPrompt": "签约公司字段说明。", + "annotationText": "## 签约公司\n\n租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-remark", + "index": 39, + "title": "备注", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-remark\"]" + ], + "fingerprint": "lbl-col-remark", + "path": [] + }, + "aiPrompt": "备注字段说明。", + "annotationText": "## 备注\n\n手动输入。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] } ] \ No newline at end of file diff --git a/.local/extract-annotation-source/directory-tree.txt b/.local/extract-annotation-source/directory-tree.txt new file mode 100644 index 0000000..56be96c --- /dev/null +++ b/.local/extract-annotation-source/directory-tree.txt @@ -0,0 +1,71 @@ +[folder] ONE-OS 原型导航 + [link] 合同模板管理 → /prototypes/contract-template-management + [link] 保险采购 → /prototypes/insurance-procurement + [link] 车辆管理 → /prototypes/vehicle-management + [link] 租赁合同管理 → /prototypes/lease-contract-management + [link] 客户管理 → /prototypes/customer-management + [link] 租赁业务台账(当前) → /prototypes/lease-business-ledger + [link] 车辆氢费明细 → /prototypes/vehicle-h2-fee-ledger + [link] 自营业务台账 → /prototypes/self-operated-business-ledger + [folder] ONE-OS Web 端 + [link] 工作台 → /prototypes/oneos-web-workbench + [link] 登录 → /prototypes/oneos-web-login + [link] 车辆管理 → /prototypes/oneos-web-vehicle-asset + [link] 合同模板管理 → /prototypes/oneos-web-contract-template + [link] 帮助中心 → /prototypes/oneos-web-help-center + [link] 财务管理 → /prototypes/oneos-web-finance + [link] 采购管理 → /prototypes/oneos-web-procurement + [link] 车辆租赁合同 → /prototypes/oneos-web-lease-contract + [link] 加氢站管理 → /prototypes/oneos-web-h2-station + [link] 数据分析 → /prototypes/oneos-web-data-analysis + [link] 台账数据 → /prototypes/oneos-web-ledger-data + [link] 业务管理 → /prototypes/oneos-web-business + [link] 运维管理 → /prototypes/oneos-web-ops + [link] 需求说明 → /prototypes/oneos-web-requirements +[folder] 租赁业务台账说明 + [markdown] 模块总览 + [link] 租赁业务台账原型 → ./index.html + [markdown] PRD 全文 (src/prototypes/lease-business-ledger/.spec/requirements-prd.md) + [folder] 页面标注 + [markdown] 筛选条件 + [folder] KPI 统计 + [markdown] KPI 统计 + [markdown] 台账条数 + [markdown] 未收总计 + [folder] 列表工具栏 + [markdown] 列表工具栏 + [markdown] 标记为已确认 + [markdown] 添加行 + [folder] 租赁台账宽表 + [markdown] 租赁台账宽表 + [markdown] 状态流转 + [markdown] 业务部门 + [markdown] 车牌号码 + [markdown] 客户名称 + [markdown] 提车日期 + [markdown] 保证金 + [markdown] 合同标的租金 + [markdown] 应收租金 + [markdown] 月度收入 + [markdown] 月度租金 + [markdown] 维保包干收入 + [markdown] 保险上浮费 + [markdown] 运维费(收入侧) + [markdown] 其他收入 + [markdown] 实收金额 + [markdown] 未收金额 + [markdown] 开票日期 + [markdown] 实际付款日期 + [markdown] 付款方式 + [markdown] 车辆标准成本 + [markdown] 车辆实际成本 + [markdown] 保险费 + [markdown] 氢费 + [markdown] 运维费(成本侧) + [markdown] 居间费 + [markdown] 其他(成本侧) + [markdown] GPS里程 + [markdown] 其他减免金额明细内容 + [markdown] 资产归属 + [markdown] 签约公司 + [markdown] 备注 \ No newline at end of file diff --git a/.local/extract-annotation-source/markdown-nodes.json b/.local/extract-annotation-source/markdown-nodes.json index 855bd89..5f8e120 100644 --- a/.local/extract-annotation-source/markdown-nodes.json +++ b/.local/extract-annotation-source/markdown-nodes.json @@ -1,57 +1,248 @@ [ { - "id": "ct-doc-overview", + "id": "lbl-doc-overview", "title": "模块总览", - "markdown": "## 模块定位\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 合同模板管理 |\n| 目标用户 | 法务人员 |\n| 核心任务 | 维护 Word 版商用车租赁合同模板,按章节编辑,发布版本并设标准模板 |\n| 数据模型 | 一份 Word 文件 = 一条记录,内含 9 个章节内容 |\n| PRD 文档 | 见目录「PRD 全文 → PRD 完整文档」 |\n| 资源文件 | `src/resources/contract-template-management/PRD.md` |\n---\n### 产品目标\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;已发布修改产生新版本 |\n| 标准模板唯一 | 同一合同分类同时仅一套标准模板生效 |" + "markdownPath": null, + "preview": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务台账 |\n| 目标用户 | 租赁业务客服、业务服务组主管 |\n| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收" }, { - "id": "ct-doc-prd-full", - "title": "PRD 完整文档", - "markdown": "# 合同模板管理 · 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.0 |\n| 模块名称 | 合同模板管理 |\n| 所属系统 | ONE-OS 业务管理 |\n| 目标读者 | 前端 / 后端 / 测试 / 法务产品对接人 |\n| 交互原型 | `/prototypes/contract-template-management` |\n| 关联原型 | [车辆管理](/prototypes/vehicle-management)(车型主数据口径参考) |\n| 文档状态 | 已对齐原型,待研发排期 |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 背景\n\n商用车租赁合同以 Word 文档形式维护,内容长、结构复杂,包含合同主体与 8 个附件章节。不同车型可能需要不同的附件条款组合。法务人员需要:\n\n- 以 **Word 版式保真** 的方式维护整份合同;\n- 按 **章节** 管理内容,并配置 **适用车型**;\n- 发布 **版本**,并指定 **标准模板** 供客服草拟新合同时默认使用;\n- 在合同主体及租赁订单~安全责任协议各章节中配置 **条件条款**(按订单车辆命中显隐)。\n\n### 1.2 产品目标\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;已发布修改产生新版本 |\n| 标准模板唯一 | 同一合同分类同时仅一套标准模板生效 |\n\n### 1.3 非目标(本期不做)\n\n- 采购合同、服务合同等其他分类的完整业务闭环(数据结构预留 `category`,当前仅启用 `lease`)\n- 在线多人协同编辑、审批流配置界面\n- Word 导出回写(本期以 HTML 结构化存储 + 预览为主)\n- 列表页顶部章节 Tab(**仅编辑页**保留章节 Tab)\n\n---\n\n## 2. 用户与场景\n\n### 2.1 目标用户\n\n| 角色 | 诉求 |\n|---|---|\n| 法务人员(主用户) | 导入/编辑 Word 模板、发布版本、设标准模板 |\n| 客服人员(间接用户) | 草拟新合同时默认引用标准模板(下游模块消费) |\n| 运营 / 风控(间接用户) | 查看风控红线条款配置情况 |\n\n### 2.2 核心用户故事\n\n1. **作为法务**,我希望导入一份完整 Word 合同,系统自动拆成 9 个章节,以便分章节维护。\n2. **作为法务**,我希望为「租赁订单」等附件章节绑定适用车型,以便创建合同时只纳入匹配车辆的条款。\n3. **作为法务**,我希望在合同主体及租赁订单~安全责任协议各章节为某段落设置「条件条款」并绑定车型,以便不同车型看到不同正文。\n4. **作为法务**,我希望保存草稿不产生版本号,发布后才生成版本号,以便区分未上线内容。\n5. **作为法务**,我希望将某已发布版本设为「标准合同」,且系统自动禁用旧标准,以便客服始终使用唯一标准。\n6. **作为法务**,我希望在列表展开行查看各章节的风控条款数、条件条款数,以便快速评估模板完整度。\n\n---\n\n## 3. 名词解释\n\n| 名词 | 定义 |\n|---|---|\n| Word 文档 / 模板记录 | 列表主表一行,对应一份完整合同文件 |\n| 章节 | 合同内的逻辑切片,共 9 个(1 个主体 + 8 个附件) |\n| 草稿 | `status = draft`,无版本号 |\n| 已发布 | `status = published`,有版本号 `vX.XX` |\n| 标准模板 | `isDefault = true` 且已发布的模板;同分类唯一 |\n| 锁定区 | 编辑区内不可随意修改的段落,HTML 标记 `data-inline-lock=\"1\"` |\n| 风控红线条款 | 需重点合规关注的条款,HTML 标记 `data-risk-redline=\"1\"` |\n| 条件条款 | 仅合同主体可用,按订单车辆命中显隐,HTML 标记 `data-vehicle-clause=\"1\"` |\n| 适用车型绑定 | 附件章节级别配置,决定该章节是否纳入某份合同 |\n| 模板占位符 | 形如 `{{contractCode}}` 的变量,创建合同时由系统替换 |\n\n---\n\n## 4. 信息架构\n\n```text\n合同模板管理\n├── 列表页\n│ ├── 筛选区\n│ ├── 工具栏(新增)\n│ └── 嵌套表格\n│ ├── 主表:Word 文件\n│ └── 子表:9 个章节(展开行)\n└── 编辑 / 新增页\n ├── 顶栏(返回、保存、保存并发布)\n ├── 章节 Tab(仅本页展示)\n ├── 条件条款说明(各章节 Tab 均可使用工具栏「条件」)\n ├── 适用车型绑定(附件章节 Tab 时显示)\n └── 工作区\n ├── 左侧:Word 版式编辑区\n └── 右侧:A4 实时预览\n```\n\n---\n\n## 5. 数据模型\n\n### 5.1 模板记录(Word 文档级)\n\n一条记录代表 **一份 Word 合同文件**,不按章节拆成多行。\n\n```typescript\ninterface ContractTemplateDocument {\n id: string; // 唯一 ID\n category: 'lease'; // 合同分类,当前固定商用车租赁\n fileName: string; // Word 文件名,如「现代18吨车型-数智中心版.docx」\n versionName: string; // 展示用版本名称\n versionNo: string | null; // 已发布:v1.00;草稿:null\n status: 'draft' | 'published';\n isDefault: boolean; // 是否标准模板\n sectionContents: Record; // 各章节 HTML\n sectionVehicleBindings: Record; // 各章节车型绑定\n creator: string;\n createTime: string; // YYYY-MM-DD HH:mm\n updater: string;\n updateTime: string;\n remark?: string; // 内部备注(列表不展示)\n}\n\ninterface VehicleBinding {\n brand: string; // 品牌,如「现代」\n model: string; // 型号,如「18吨氢燃料电池车」\n}\n```\n\n### 5.2 章节枚举(SectionKey)\n\n| key | 章节名称 | 车型绑定 | 条件条款 |\n|---|---|:---:|:---:|\n| `main` | 合同主体 | 否 | 是 |\n| `lease_order` | 租赁订单 | 是 | 是 |\n| `vehicle_cost` | 车辆产生的费用 | 是 | 是 |\n| `safety_notice` | 安全责任告知书 | 是 | 是 |\n| `dynamic_supervision` | 重型普通货运车辆租赁动态监管责任告知与确认书 | 是 | 是 |\n| `safety_commitment` | 安全责任承诺书 | 是 | 是 |\n| `vehicle_spec` | 车辆使用规范和驾驶员要求 | 是 | 是 |\n| `authorization` | 授权委托书 | 是 | 是 |\n| `safety_agreement` | 安全责任协议 | 是 | 是 |\n\n### 5.3 章节内容 HTML 标记规范\n\n研发需保证存储、渲染、统计口径一致:\n\n| 类型 | 选择器 / 属性 | 统计函数 |\n|---|---|---|\n| 锁定区 | `.ct-inline-lock[data-inline-lock=\"1\"]` | `countLockRegions` |\n| 风控红线 | `.ct-risk-redline[data-risk-redline=\"1\"]` | `countRiskRedlineRegions` |\n| 条件条款 | `.ct-vehicle-clause[data-vehicle-clause=\"1\"]` | `countVehicleClauseRegions` |\n| 条件条款车型 | `data-vehicle-bindings`(JSON 数组) | 预览命中逻辑 |\n\n---\n\n## 6. 业务规则(全局)\n\n### 6.1 版本号规则\n\n- 格式:`v{major}.{minor}`,如 `v1.00`、`v1.01`\n- **仅发布时生成**;保存草稿时 `versionNo = null`\n- 同分类下递增:取当前已发布版本最大 minor +1;minor ≥ 99 时 major +1、minor 归 0\n- 首版默认 `v1.00`\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布 |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**降为草稿**(清空版本号) |\n| **保存并发布** | 新建已发布记录 + 版本号 | 原记录变已发布 + 版本号 | **新建一条已发布记录**(新版本),原版本保留 |\n\n**校验**:保存/发布前,合同主体(`sectionContents.main`)不能为空。\n\n### 6.3 标准模板规则\n\n1. 同一 `category` **同时仅允许一条** `isDefault = true` 且 `status = published` 的记录。\n2. 仅 **已发布且非当前标准** 的记录可执行「设为标准模板」。\n3. 切换标准时:**先禁用**原标准(`isDefault → false`),再启用新标准(`isDefault → true`)。\n4. **标准模板不可删除**;操作列不展示「设为标准模板」「删除」。\n5. 下游「客服草拟新合同」默认引用当前标准模板(本期由其他模块消费)。\n\n### 6.4 删除规则\n\n- 仅 **非标准模板** 可删除。\n- 删除前二次确认;删除后不可恢复。\n\n### 6.5 列表排序\n\n1. 标准模板置顶;\n2. 其余按 `updateTime` 降序。\n\n### 6.6 筛选匹配规则\n\n| 字段 | 匹配逻辑 |\n|---|---|\n| 合同名称 | 模糊匹配 `fileName`、`versionName`、`versionNo`、`creator`、`updater` |\n| 状态 | 精确匹配 `draft` / `published` |\n| 创建人 | 精确匹配 `creator` |\n| 适用品牌 | 文档内 **任一附件章节** 的车型绑定命中该品牌;无绑定的文档在筛品牌时视为「全部车型」可通过 |\n\n> **已移除**:筛选项「适用型号」、主表列「备注」(产品确认不再展示)。\n\n---\n\n## 7. 列表页需求\n\n### 7.1 页面结构\n\n筛选区 + 工具栏 + 嵌套表格。**列表页不展示章节 Tab。**\n\n### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 文本输入 | 支持回车触发查询 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 适用品牌 | 下拉 | 选项来自车型主数据品牌列表 |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认\n\n### 7.3 工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增 | 进入新增页,默认章节为「合同主体」,模式为 create |\n\n### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `fileName` | 超长省略;标准模板显示金色标签「标准合同」 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 编辑 | 始终 | 进入编辑页,加载整份 `sectionContents` |\n| 设为标准模板 | 已发布 && 非标准 | 弹窗确认后切换标准 |\n| 删除 | 非标准 | 弹窗确认后删除 |\n\n**设为标准模板弹窗文案要点**:\n\n- 说明:同一分类同时只能有一套标准模板;客服草拟新合同时将默认使用新标准。\n- 若存在旧标准:明示将先禁用「{旧标准文件名}」,再设置「{新标准文件名}」。\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n| 内容状态 | 有内容:「已配置」;无内容:「未配置」 |\n\n---\n\n## 8. 编辑 / 新增页需求\n\n### 8.1 页面结构\n\n顶栏 → 章节 Tab → 条件说明 / 车型绑定(按章节显隐)→ 左右分栏工作区。\n\n### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n### 8.3 章节 Tab\n\n- 共 9 个章节;**前 4 个直显**,其余收入「更多」下拉。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**。\n- **其他 Tab**:仅展示该章节切片内容。\n\n章节顺序:\n\n1. 合同主体 \n2. 租赁订单 \n3. 车辆产生的费用 \n4. 安全责任告知书 \n5. 重型普通货运车辆租赁动态监管责任告知与确认书 \n6. 安全责任承诺书 \n7. 车辆使用规范和驾驶员要求 \n8. 授权委托书 \n9. 安全责任协议 \n\n### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n### 8.5 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。\n\n### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.6.3 条件条款交互\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充\n\n### 8.8 空状态\n\n编辑区无内容时,预览区展示空状态引导;新增模式下突出「导入 Word 模板」按钮。\n\n---\n\n## 9. Word 导入与章节拆分\n\n### 9.1 导入范围\n\n- 一次导入 **整份合同**;\n- 导入后写入各 `sectionContents[key]`;\n- 默认进入「合同主体」章节编辑。\n\n### 9.2 拆分策略\n\n1. 对导入 HTML 做 Word 导出清洗(`sanitizeWordExportHtml`);\n2. 从附件章节起,按 **章节标记字符串** 定位切分点;\n3. 切分点优先落在 `

`、``、`` 之后,避免打断标签;\n4. `main` = 第一个切分点之前的全文;\n5. 各附件章节 = 相邻切分点之间的片段。\n\n### 9.3 章节标记(节选)\n\n研发需维护可扩展的 `markers` 列表,示例:\n\n| 章节 | 标记示例 |\n|---|---|\n| 租赁订单 | `附件 1:租赁订单`、`附件1:租赁订单` |\n| 车辆产生的费用 | `附件2:车辆产生的费用` |\n| 安全责任告知书 | `附件3:安全责任告知书` |\n| … | 完整列表见原型 `DOCUMENT_SECTION_TABS` |\n\n### 9.4 误匹配过滤\n\n以下情况 **不应** 作为切分点:\n\n- 标记出现在「详见附件…」「以附件…」等引用语境;\n- 标记过短且不在 `` 标题语境内;\n- 标记前为「(」「、」等不完整标题上下文。\n\n---\n\n## 10. 模板占位符\n\n### 10.1 变量分组\n\n**甲方签约主体**(创建合同时按所选签约公司自动替换):\n\n| key | 标签 |\n|---|---|\n| `lessorName` | 甲方全称 |\n| `lessorAddress` | 甲方地址 |\n| `lessorContact` | 甲方联系人 |\n| `lessorPhone` | 甲方电话 |\n| `lessorEmail` | 甲方邮箱 |\n| `lessorAccountName` | 收款户名 |\n| `lessorBankName` | 开户行 |\n| `lessorBankAccount` | 银行账号 |\n\n**合同通用**(创建合同时由系统或客服填写):\n\n| key | 标签 |\n|---|---|\n| `contractCode` | 合同编号 |\n| `customerName` | 乙方公司全称 |\n| `creditCode` | 乙方统一社会信用代码 |\n| `customerAddress` | 乙方地址 |\n| `customerContact` | 乙方联系人 |\n| `customerPhone` | 乙方联系电话 |\n| `signDateYear` | 签订年份 |\n| `signDateMonth` | 签订月份 |\n| `signDateDay` | 签订日期 |\n\n### 10.2 插入规则\n\n- 编辑区光标处插入 `{{key}}`;\n- 存储为 HTML 文本,不做运行时强校验;\n- 缺失变量创建合同时按空字符串或业务默认值处理(由下游定义)。\n\n---\n\n## 11. 车型主数据\n\n品牌 · 型号下拉数据与车辆管理模块保持一致,当前原型内置示例:\n\n| 品牌 | 型号示例 |\n|---|---|\n| 现代 | 18吨氢燃料电池车、帕力安牌4.5吨冷链车 |\n| 苏龙 | 9.6米氢燃料电池车 |\n| 柳汽乘龙 | 9.6米氢燃料电池车 |\n| 飞驰 | 集卡头、半挂车 |\n| 跃进 | 4.2米冷链车 |\n| 宇通 | 氢燃料电池客车 |\n\n> 正式环境应由车辆主数据服务提供;绑定存储仅保存品牌 + 型号字符串。\n\n---\n\n## 12. 与其他模块的关系\n\n```mermaid\nflowchart LR\n A[合同模板管理] -->|标准模板 + 章节内容| B[租赁合同创建]\n A -->|车型绑定口径| C[车辆管理]\n A -->|占位符变量定义| B\n B -->|订单车辆列表| A\n```\n\n| 模块 | 关系 |\n|---|---|\n| 租赁合同创建 | 读取标准模板或指定版本;按订单车辆过滤条件条款与附件章节 |\n| 车辆管理 | 车型品牌/型号枚举口径一致 |\n| 非标准合同审批 | 合同正文出现 `data-nonstd-trigger` 时触发(标记规范已预留) |\n\n---\n\n## 13. 权限与审计(建议)\n\n| 能力 | 建议权限 |\n|---|---|\n| 查看列表 | 法务、客服(只读) |\n| 新增 / 编辑 / 发布 | 法务 |\n| 设为标准模板 | 法务负责人 |\n| 删除 | 法务负责人 |\n\n**审计字段**:`creator`、`createTime`、`updater`、`updateTime` 必须在每次保存时更新。\n\n---\n\n## 14. 接口需求(建议)\n\n### 14.1 列表查询\n\n```\nGET /api/contract-templates?category=lease&keyword=&status=&creator=&vehicleBrand=&page=1&pageSize=10\n```\n\n响应需包含主表字段;章节摘要可列表内嵌或展开时按需加载。\n\n### 14.2 详情\n\n```\nGET /api/contract-templates/{id}\n```\n\n返回完整 `sectionContents`、`sectionVehicleBindings`。\n\n### 14.3 创建 / 更新\n\n```\nPOST /api/contract-templates\nPUT /api/contract-templates/{id}\n```\n\n请求体含章节 HTML 与绑定;`publish: boolean` 控制是否发布。\n\n### 14.4 设为标准\n\n```\nPOST /api/contract-templates/{id}/set-default\n```\n\n服务端事务:同 category 旧标准 `isDefault=false`,新标准 `isDefault=true`。\n\n### 14.5 删除\n\n```\nDELETE /api/contract-templates/{id}\n```\n\n拒绝删除 `isDefault=true` 的记录。\n\n### 14.6 Word 导入\n\n```\nPOST /api/contract-templates/import-word\nContent-Type: multipart/form-data\n```\n\n返回拆分后的 `sectionContents` 供前端预览确认(或后端直接落库,由研发评估)。\n\n---\n\n## 15. 非功能需求\n\n| 类别 | 要求 |\n|---|---|\n| 性能 | 单份合同 HTML ≤ 2MB 时,章节切换 < 500ms(前端缓存) |\n| 兼容性 | Chrome / Edge 最新两个大版本;编辑区最低宽度 1280px 推荐 |\n| 可访问性 | 表单控件有 `aria-label`;操作按钮具备可读文案 |\n| 安全 | 导入 Word 需做 XSS 清洗;仅白名单标签/属性入库 |\n| 可追溯 | 已发布版本不可原地覆盖内容,只能新发版 |\n\n---\n\n## 16. 验收标准\n\n### 16.1 列表页\n\n- [ ] 筛选项包含:合同名称、状态、创建人、适用品牌;**不含**适用型号、备注列\n- [ ] 主表每行对应一份 Word 文件,可展开 9 个章节子表\n- [ ] 子表展示:章节名、适用车型、风控条款数、条件条款数、内容状态\n- [ ] 标准模板金色标签置顶,且不可删除\n- [ ] 切换标准模板时旧标准自动禁用\n\n### 16.2 编辑页\n\n- [ ] 章节 Tab 仅在编辑页展示;前 4 个直显 + 更多\n- [ ] 导入 Word 后 9 章节正确拆分\n- [ ] 附件章节可绑定车型;各章节均可设条件条款\n- [ ] 预览支持连续/单页切换;各章节可模拟车型验证条件条款\n- [ ] 保存草稿无版本号;发布生成版本号\n- [ ] 编辑已发布并发布时产生新版本,旧版本保留\n\n### 16.3 数据一致性\n\n- [ ] 列表子表「风控条款数」与编辑区统计一致\n- [ ] 列表子表「条件条款数」与各章节实际标记数一致\n- [ ] 筛选「适用品牌」与子表车型绑定逻辑一致\n\n### 16.4 标注与文档(原型)\n\n- [ ] 原型已接入 `@axhub/annotation`,关键区域有 `data-annotation-id`\n- [ ] 目录路由可切换列表页 / 新增页\n\n---\n\n## 17. 原型实现映射(供研发对照)\n\n| 能力 | 原型文件 / 符号 |\n|---|---|\n| 主逻辑 | `src/prototypes/contract-template-management/ContractTemplate.jsx` |\n| 入口与标注 | `src/prototypes/contract-template-management/index.tsx` |\n| 标注数据 | `src/prototypes/contract-template-management/annotation-source.json` |\n| 占位符与出租方 | `src/prototypes/contract-template-management/contract-template-vars.js` |\n| 样式 | `src/prototypes/contract-template-management/styles/` |\n\n---\n\n## 18. 附录:标准模板确认弹窗(原文案)\n\n**标题**:确认设为标准模板\n\n**正文**:\n\n> 同一分类同时只能有一套标准模板生效。确认后,客服草拟新合同时将默认使用新标准模板。\n>\n> (若存在旧标准)将先禁用当前标准模板「{旧标准名称}」,再设置「{新标准名称}」为新的标准模板。\n\n**按钮**:取消 / 确认设置\n\n---\n\n## 19. 修订记录\n\n| 版本 | 日期 | 作者 | 说明 |\n|---|---|---|---|\n| v1.0 | 2026-06-25 | 产品 | 基于原型与需求讨论首版;明确 Word 文档级模型、嵌套列表、标准模板唯一、移除适用型号筛选与备注列 |" + "id": "lbl-doc-prd", + "title": "PRD 全文", + "markdownPath": "src/prototypes/lease-business-ledger/.spec/requirements-prd.md", + "preview": "# 租赁业务台账 · 产品需求说明(PRD)\n\n> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | " }, { - "id": "ct-doc-prd-overview", - "title": "背景 · 数据模型 · 业务规则", - "markdown": "# 合同模板管理 · 全局说明\n\n## 1. 背景与目标\n\n### 1.1 背景\n\n商用车租赁合同以 Word 文档形式维护,内容长、结构复杂,包含合同主体与 8 个附件章节。不同车型可能需要不同的附件条款组合。法务人员需要:\n\n- 以 **Word 版式保真** 的方式维护整份合同;\n- 按 **章节** 管理内容,并配置 **适用车型**;\n- 发布 **版本**,并指定 **标准模板** 供客服草拟新合同时默认使用;\n- 在合同主体及租赁订单~安全责任协议各章节中配置 **条件条款**(按订单车辆命中显隐)。\n\n### 1.2 产品目标\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;已发布修改产生新版本 |\n| 标准模板唯一 | 同一合同分类同时仅一套标准模板生效 |\n\n### 1.3 非目标(本期不做)\n\n- 采购合同、服务合同等其他分类的完整业务闭环(数据结构预留 `category`,当前仅启用 `lease`)\n- 在线多人协同编辑、审批流配置界面\n- Word 导出回写(本期以 HTML 结构化存储 + 预览为主)\n- 列表页顶部章节 Tab(**仅编辑页**保留章节 Tab)\n\n---\n\n## 2. 用户与场景\n\n### 2.1 目标用户\n\n| 角色 | 诉求 |\n|---|---|\n| 法务人员(主用户) | 导入/编辑 Word 模板、发布版本、设标准模板 |\n| 客服人员(间接用户) | 草拟新合同时默认引用标准模板(下游模块消费) |\n| 运营 / 风控(间接用户) | 查看风控红线条款配置情况 |\n\n### 2.2 核心用户故事\n\n1. **作为法务**,我希望导入一份完整 Word 合同,系统自动拆成 9 个章节,以便分章节维护。\n2. **作为法务**,我希望为「租赁订单」等附件章节绑定适用车型,以便创建合同时只纳入匹配车辆的条款。\n3. **作为法务**,我希望在合同主体及租赁订单~安全责任协议各章节为某段落设置「条件条款」并绑定车型,以便不同车型看到不同正文。\n4. **作为法务**,我希望保存草稿不产生版本号,发布后才生成版本号,以便区分未上线内容。\n5. **作为法务**,我希望将某已发布版本设为「标准合同」,且系统自动禁用旧标准,以便客服始终使用唯一标准。\n6. **作为法务**,我希望在列表展开行查看各章节的风控条款数、条件条款数,以便快速评估模板完整度。\n\n---\n\n## 3. 名词解释\n\n| 名词 | 定义 |\n|---|---|\n| Word 文档 / 模板记录 | 列表主表一行,对应一份完整合同文件 |\n| 章节 | 合同内的逻辑切片,共 9 个(1 个主体 + 8 个附件) |\n| 草稿 | `status = draft`,无版本号 |\n| 已发布 | `status = published`,有版本号 `vX.XX` |\n| 标准模板 | `isDefault = true` 且已发布的模板;同分类唯一 |\n| 锁定区 | 编辑区内不可随意修改的段落,HTML 标记 `data-inline-lock=\"1\"` |\n| 风控红线条款 | 需重点合规关注的条款,HTML 标记 `data-risk-redline=\"1\"` |\n| 条件条款 | 仅合同主体可用,按订单车辆命中显隐,HTML 标记 `data-vehicle-clause=\"1\"` |\n| 适用车型绑定 | 附件章节级别配置,决定该章节是否纳入某份合同 |\n| 模板占位符 | 形如 `{{contractCode}}` 的变量,创建合同时由系统替换 |\n\n---\n\n## 4. 信息架构\n\n```text\n合同模板管理\n├── 列表页\n│ ├── 筛选区\n│ ├── 工具栏(新增)\n│ └── 嵌套表格\n│ ├── 主表:Word 文件\n│ └── 子表:9 个章节(展开行)\n└── 编辑 / 新增页\n ├── 顶栏(返回、保存、保存并发布)\n ├── 章节 Tab(仅本页展示)\n ├── 条件条款说明(各章节 Tab 均可使用工具栏「条件」)\n ├── 适用车型绑定(附件章节 Tab 时显示)\n └── 工作区\n ├── 左侧:Word 版式编辑区\n └── 右侧:A4 实时预览\n```\n\n---\n\n## 5. 数据模型\n\n### 5.1 模板记录(Word 文档级)\n\n一条记录代表 **一份 Word 合同文件**,不按章节拆成多行。\n\n```typescript\ninterface ContractTemplateDocument {\n id: string; // 唯一 ID\n category: 'lease'; // 合同分类,当前固定商用车租赁\n fileName: string; // Word 文件名,如「现代18吨车型-数智中心版.docx」\n versionName: string; // 展示用版本名称\n versionNo: string | null; // 已发布:v1.00;草稿:null\n status: 'draft' | 'published';\n isDefault: boolean; // 是否标准模板\n sectionContents: Record; // 各章节 HTML\n sectionVehicleBindings: Record; // 各章节车型绑定\n creator: string;\n createTime: string; // YYYY-MM-DD HH:mm\n updater: string;\n updateTime: string;\n remark?: string; // 内部备注(列表不展示)\n}\n\ninterface VehicleBinding {\n brand: string; // 品牌,如「现代」\n model: string; // 型号,如「18吨氢燃料电池车」\n}\n```\n\n### 5.2 章节枚举(SectionKey)\n\n| key | 章节名称 | 车型绑定 | 条件条款 |\n|---|---|:---:|:---:|\n| `main` | 合同主体 | 否 | 是 |\n| `lease_order` | 租赁订单 | 是 | 是 |\n| `vehicle_cost` | 车辆产生的费用 | 是 | 是 |\n| `safety_notice` | 安全责任告知书 | 是 | 是 |\n| `dynamic_supervision` | 重型普通货运车辆租赁动态监管责任告知与确认书 | 是 | 是 |\n| `safety_commitment` | 安全责任承诺书 | 是 | 是 |\n| `vehicle_spec` | 车辆使用规范和驾驶员要求 | 是 | 是 |\n| `authorization` | 授权委托书 | 是 | 是 |\n| `safety_agreement` | 安全责任协议 | 是 | 是 |\n\n### 5.3 章节内容 HTML 标记规范\n\n研发需保证存储、渲染、统计口径一致:\n\n| 类型 | 选择器 / 属性 | 统计函数 |\n|---|---|---|\n| 锁定区 | `.ct-inline-lock[data-inline-lock=\"1\"]` | `countLockRegions` |\n| 风控红线 | `.ct-risk-redline[data-risk-redline=\"1\"]` | `countRiskRedlineRegions` |\n| 条件条款 | `.ct-vehicle-clause[data-vehicle-clause=\"1\"]` | `countVehicleClauseRegions` |\n| 条件条款车型 | `data-vehicle-bindings`(JSON 数组) | 预览命中逻辑 |\n\n---\n\n## 6. 业务规则(全局)\n\n### 6.1 版本号规则\n\n- 格式:`v{major}.{minor}`,如 `v1.00`、`v1.01`\n- **仅发布时生成**;保存草稿时 `versionNo = null`\n- 同分类下递增:取当前已发布版本最大 minor +1;minor ≥ 99 时 major +1、minor 归 0\n- 首版默认 `v1.00`\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布 |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**降为草稿**(清空版本号) |\n| **保存并发布** | 新建已发布记录 + 版本号 | 原记录变已发布 + 版本号 | **新建一条已发布记录**(新版本),原版本保留 |\n\n**校验**:保存/发布前,合同主体(`sectionContents.main`)不能为空。\n\n### 6.3 标准模板规则\n\n1. 同一 `category` **同时仅允许一条** `isDefault = true` 且 `status = published` 的记录。\n2. 仅 **已发布且非当前标准** 的记录可执行「设为标准模板」。\n3. 切换标准时:**先禁用**原标准(`isDefault → false`),再启用新标准(`isDefault → true`)。\n4. **标准模板不可删除**;操作列不展示「设为标准模板」「删除」。\n5. 下游「客服草拟新合同」默认引用当前标准模板(本期由其他模块消费)。\n\n### 6.4 删除规则\n\n- 仅 **非标准模板** 可删除。\n- 删除前二次确认;删除后不可恢复。\n\n### 6.5 列表排序\n\n1. 标准模板置顶;\n2. 其余按 `updateTime` 降序。\n\n### 6.6 筛选匹配规则\n\n| 字段 | 匹配逻辑 |\n|---|---|\n| 合同名称 | 模糊匹配 `fileName`、`versionName`、`versionNo`、`creator`、`updater` |\n| 状态 | 精确匹配 `draft` / `published` |\n| 创建人 | 精确匹配 `creator` |\n| 适用品牌 | 文档内 **任一附件章节** 的车型绑定命中该品牌;无绑定的文档在筛品牌时视为「全部车型」可通过 |\n\n> **已移除**:筛选项「适用型号」、主表列「备注」(产品确认不再展示)。\n\n---\n\n---\n\n## 5. 数据模型\n\n### 5.1 模板记录(Word 文档级)\n\n一条记录代表 **一份 Word 合同文件**,不按章节拆成多行。\n\n```typescript\ninterface ContractTemplateDocument {\n id: string; // 唯一 ID\n category: 'lease'; // 合同分类,当前固定商用车租赁\n fileName: string; // Word 文件名,如「现代18吨车型-数智中心版.docx」\n versionName: string; // 展示用版本名称\n versionNo: string | null; // 已发布:v1.00;草稿:null\n status: 'draft' | 'published';\n isDefault: boolean; // 是否标准模板\n sectionContents: Record; // 各章节 HTML\n sectionVehicleBindings: Record; // 各章节车型绑定\n creator: string;\n createTime: string; // YYYY-MM-DD HH:mm\n updater: string;\n updateTime: string;\n remark?: string; // 内部备注(列表不展示)\n}\n\ninterface VehicleBinding {\n brand: string; // 品牌,如「现代」\n model: string; // 型号,如「18吨氢燃料电池车」\n}\n```\n\n### 5.2 章节枚举(SectionKey)\n\n| key | 章节名称 | 车型绑定 | 条件条款 |\n|---|---|:---:|:---:|\n| `main` | 合同主体 | 否 | 是 |\n| `lease_order` | 租赁订单 | 是 | 是 |\n| `vehicle_cost` | 车辆产生的费用 | 是 | 是 |\n| `safety_notice` | 安全责任告知书 | 是 | 是 |\n| `dynamic_supervision` | 重型普通货运车辆租赁动态监管责任告知与确认书 | 是 | 是 |\n| `safety_commitment` | 安全责任承诺书 | 是 | 是 |\n| `vehicle_spec` | 车辆使用规范和驾驶员要求 | 是 | 是 |\n| `authorization` | 授权委托书 | 是 | 是 |\n| `safety_agreement` | 安全责任协议 | 是 | 是 |\n\n### 5.3 章节内容 HTML 标记规范\n\n研发需保证存储、渲染、统计口径一致:\n\n| 类型 | 选择器 / 属性 | 统计函数 |\n|---|---|---|\n| 锁定区 | `.ct-inline-lock[data-inline-lock=\"1\"]` | `countLockRegions` |\n| 风控红线 | `.ct-risk-redline[data-risk-redline=\"1\"]` | `countRiskRedlineRegions` |\n| 条件条款 | `.ct-vehicle-clause[data-vehicle-clause=\"1\"]` | `countVehicleClauseRegions` |\n| 条件条款车型 | `data-vehicle-bindings`(JSON 数组) | 预览命中逻辑 |\n\n---\n\n## 6. 业务规则(全局)\n\n### 6.1 版本号规则\n\n- 格式:`v{major}.{minor}`,如 `v1.00`、`v1.01`\n- **仅发布时生成**;保存草稿时 `versionNo = null`\n- 同分类下递增:取当前已发布版本最大 minor +1;minor ≥ 99 时 major +1、minor 归 0\n- 首版默认 `v1.00`\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布 |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**降为草稿**(清空版本号) |\n| **保存并发布** | 新建已发布记录 + 版本号 | 原记录变已发布 + 版本号 | **新建一条已发布记录**(新版本),原版本保留 |\n\n**校验**:保存/发布前,合同主体(`sectionContents.main`)不能为空。\n\n### 6.3 标准模板规则\n\n1. 同一 `category` **同时仅允许一条** `isDefault = true` 且 `status = published` 的记录。\n2. 仅 **已发布且非当前标准** 的记录可执行「设为标准模板」。\n3. 切换标准时:**先禁用**原标准(`isDefault → false`),再启用新标准(`isDefault → true`)。\n4. **标准模板不可删除**;操作列不展示「设为标准模板」「删除」。\n5. 下游「客服草拟新合同」默认引用当前标准模板(本期由其他模块消费)。\n\n### 6.4 删除规则\n\n- 仅 **非标准模板** 可删除。\n- 删除前二次确认;删除后不可恢复。\n\n### 6.5 列表排序\n\n1. 标准模板置顶;\n2. 其余按 `updateTime` 降序。\n\n### 6.6 筛选匹配规则\n\n| 字段 | 匹配逻辑 |\n|---|---|\n| 合同名称 | 模糊匹配 `fileName`、`versionName`、`versionNo`、`creator`、`updater` |\n| 状态 | 精确匹配 `draft` / `published` |\n| 创建人 | 精确匹配 `creator` |\n| 适用品牌 | 文档内 **任一附件章节** 的车型绑定命中该品牌;无绑定的文档在筛品牌时视为「全部车型」可通过 |\n\n> **已移除**:筛选项「适用型号」、主表列「备注」(产品确认不再展示)。\n\n---" - }, - { - "id": "ct-doc-prd-list", - "title": "列表页 PRD", - "markdown": "# 合同模板管理 · 列表页 PRD\n\n## 7. 列表页需求\n\n### 7.1 页面结构\n\n筛选区 + 工具栏 + 嵌套表格。**列表页不展示章节 Tab。**\n\n### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 文本输入 | 支持回车触发查询 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 适用品牌 | 下拉 | 选项来自车型主数据品牌列表 |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认\n\n### 7.3 工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增 | 进入新增页,默认章节为「合同主体」,模式为 create |\n\n### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `fileName` | 超长省略;标准模板显示金色标签「标准合同」 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 编辑 | 始终 | 进入编辑页,加载整份 `sectionContents` |\n| 设为标准模板 | 已发布 && 非标准 | 弹窗确认后切换标准 |\n| 删除 | 非标准 | 弹窗确认后删除 |\n\n**设为标准模板弹窗文案要点**:\n\n- 说明:同一分类同时只能有一套标准模板;客服草拟新合同时将默认使用新标准。\n- 若存在旧标准:明示将先禁用「{旧标准文件名}」,再设置「{新标准文件名}」。\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n| 内容状态 | 有内容:「已配置」;无内容:「未配置」 |\n\n---" - }, - { - "id": "ct-doc-prd-editor", - "title": "编辑页 PRD", - "markdown": "# 合同模板管理 · 编辑/新增页 PRD\n\n## 8. 编辑 / 新增页需求\n\n### 8.1 页面结构\n\n顶栏 → 章节 Tab → 条件说明 / 车型绑定(按章节显隐)→ 左右分栏工作区。\n\n### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n### 8.3 章节 Tab\n\n- 共 9 个章节;**前 4 个直显**,其余收入「更多」下拉。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**。\n- **其他 Tab**:仅展示该章节切片内容。\n\n章节顺序:\n\n1. 合同主体 \n2. 租赁订单 \n3. 车辆产生的费用 \n4. 安全责任告知书 \n5. 重型普通货运车辆租赁动态监管责任告知与确认书 \n6. 安全责任承诺书 \n7. 车辆使用规范和驾驶员要求 \n8. 授权委托书 \n9. 安全责任协议 \n\n### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n### 8.5 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。\n\n### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.6.3 条件条款交互\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充\n\n### 8.8 空状态\n\n编辑区无内容时,预览区展示空状态引导;新增模式下突出「导入 Word 模板」按钮。\n\n---\n\n---\n\n## 9. Word 导入与章节拆分\n\n### 9.1 导入范围\n\n- 一次导入 **整份合同**;\n- 导入后写入各 `sectionContents[key]`;\n- 默认进入「合同主体」章节编辑。\n\n### 9.2 拆分策略\n\n1. 对导入 HTML 做 Word 导出清洗(`sanitizeWordExportHtml`);\n2. 从附件章节起,按 **章节标记字符串** 定位切分点;\n3. 切分点优先落在 `

`、``、`` 之后,避免打断标签;\n4. `main` = 第一个切分点之前的全文;\n5. 各附件章节 = 相邻切分点之间的片段。\n\n### 9.3 章节标记(节选)\n\n研发需维护可扩展的 `markers` 列表,示例:\n\n| 章节 | 标记示例 |\n|---|---|\n| 租赁订单 | `附件 1:租赁订单`、`附件1:租赁订单` |\n| 车辆产生的费用 | `附件2:车辆产生的费用` |\n| 安全责任告知书 | `附件3:安全责任告知书` |\n| … | 完整列表见原型 `DOCUMENT_SECTION_TABS` |\n\n### 9.4 误匹配过滤\n\n以下情况 **不应** 作为切分点:\n\n- 标记出现在「详见附件…」「以附件…」等引用语境;\n- 标记过短且不在 `` 标题语境内;\n- 标记前为「(」「、」等不完整标题上下文。\n\n---\n\n---\n\n## 10. 模板占位符\n\n### 10.1 变量分组\n\n**甲方签约主体**(创建合同时按所选签约公司自动替换):\n\n| key | 标签 |\n|---|---|\n| `lessorName` | 甲方全称 |\n| `lessorAddress` | 甲方地址 |\n| `lessorContact` | 甲方联系人 |\n| `lessorPhone` | 甲方电话 |\n| `lessorEmail` | 甲方邮箱 |\n| `lessorAccountName` | 收款户名 |\n| `lessorBankName` | 开户行 |\n| `lessorBankAccount` | 银行账号 |\n\n**合同通用**(创建合同时由系统或客服填写):\n\n| key | 标签 |\n|---|---|\n| `contractCode` | 合同编号 |\n| `customerName` | 乙方公司全称 |\n| `creditCode` | 乙方统一社会信用代码 |\n| `customerAddress` | 乙方地址 |\n| `customerContact` | 乙方联系人 |\n| `customerPhone` | 乙方联系电话 |\n| `signDateYear` | 签订年份 |\n| `signDateMonth` | 签订月份 |\n| `signDateDay` | 签订日期 |\n\n### 10.2 插入规则\n\n- 编辑区光标处插入 `{{key}}`;\n- 存储为 HTML 文本,不做运行时强校验;\n- 缺失变量创建合同时按空字符串或业务默认值处理(由下游定义)。\n\n---" - }, - { - "id": "ct-doc-prd-acceptance", - "title": "验收标准", - "markdown": "## 16. 验收标准\n\n### 16.1 列表页\n\n- [ ] 筛选项包含:合同名称、状态、创建人、适用品牌;**不含**适用型号、备注列\n- [ ] 主表每行对应一份 Word 文件,可展开 9 个章节子表\n- [ ] 子表展示:章节名、适用车型、风控条款数、条件条款数、内容状态\n- [ ] 标准模板金色标签置顶,且不可删除\n- [ ] 切换标准模板时旧标准自动禁用\n\n### 16.2 编辑页\n\n- [ ] 章节 Tab 仅在编辑页展示;前 4 个直显 + 更多\n- [ ] 导入 Word 后 9 章节正确拆分\n- [ ] 附件章节可绑定车型;各章节均可设条件条款\n- [ ] 预览支持连续/单页切换;各章节可模拟车型验证条件条款\n- [ ] 保存草稿无版本号;发布生成版本号\n- [ ] 编辑已发布并发布时产生新版本,旧版本保留\n\n### 16.3 数据一致性\n\n- [ ] 列表子表「风控条款数」与编辑区统计一致\n- [ ] 列表子表「条件条款数」与各章节实际标记数一致\n- [ ] 筛选「适用品牌」与子表车型绑定逻辑一致\n\n### 16.4 标注与文档(原型)\n\n- [ ] 原型已接入 `@axhub/annotation`,关键区域有 `data-annotation-id`\n- [ ] 目录路由可切换列表页 / 新增页\n\n---" - }, - { - "id": "ct-doc-node-filter", + "id": "lbl-doc-lbl-filter", "title": "筛选条件", - "markdown": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 文本输入 | 支持回车触发查询 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 适用品牌 | 下拉 | 选项来自车型主数据品牌列表 |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认" + "markdownPath": null, + "preview": "## 2. 筛选区\n\n默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。\n\n| 筛选项 | 说明 |\n|---|---|\n| 年份 / 月份 | 账单所属年月 |\n| 业务部门 / 业务员 | 精确匹配 |\n| 车牌号码 | 精确" }, { - "id": "ct-doc-node-table", - "title": "嵌套列表", - "markdown": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `fileName` | 超长省略;标准模板显示金色标签「标准合同」 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 编辑 | 始终 | 进入编辑页,加载整份 `sectionContents` |\n| 设为标准模板 | 已发布 && 非标准 | 弹窗确认后切换标准 |\n| 删除 | 非标准 | 弹窗确认后删除 |\n\n**设为标准模板弹窗文案要点**:\n\n- 说明:同一分类同时只能有一套标准模板;客服草拟新合同时将默认使用新标准。\n- 若存在旧标准:明示将先禁用「{旧标准文件名}」,再设置「{新标准文件名}」。\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n| 内容状态 | 有内容:「已配置」;无内容:「未配置」 |\n\n### 6.3 标准模板规则\n\n1. 同一 `category` **同时仅允许一条** `isDefault = true` 且 `status = published` 的记录。\n2. 仅 **已发布且非当前标准** 的记录可执行「设为标准模板」。\n3. 切换标准时:**先禁用**原标准(`isDefault → false`),再启用新标准(`isDefault → true`)。\n4. **标准模板不可删除**;操作列不展示「设为标准模板」「删除」。\n5. 下游「客服草拟新合同」默认引用当前标准模板(本期由其他模块消费)。" + "id": "lbl-doc-lbl-kpi", + "title": "KPI 统计", + "markdownPath": null, + "preview": "## 3. KPI 统计卡片(6 张)\n\n台账条数、应收总计、实收总计、未收总计、成本总计、盈亏总计。\n\n- **台账条数**:员工仅统计本人录入;主管统计全组。点击切换列表为显示全部。\n- **未收总计**:点击筛选下方列表为未收款数据。" }, { - "id": "ct-doc-node-tabs", - "title": "章节 Tab", - "markdown": "### 8.3 章节 Tab\n\n- 共 9 个章节;**前 4 个直显**,其余收入「更多」下拉。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**。\n- **其他 Tab**:仅展示该章节切片内容。\n\n章节顺序:\n\n1. 合同主体 \n2. 租赁订单 \n3. 车辆产生的费用 \n4. 安全责任告知书 \n5. 重型普通货运车辆租赁动态监管责任告知与确认书 \n6. 安全责任承诺书 \n7. 车辆使用规范和驾驶员要求 \n8. 授权委托书 \n9. 安全责任协议" + "id": "lbl-doc-lbl-kpi-count", + "title": "台账条数", + "markdownPath": null, + "preview": "## 台账条数 KPI\n\n业务服务组员工只能看到自己录入的数据条数以及金额;业务服务组主管可以查看所有业务服务组人员录入的数据总和。点击可切换列表显示全部数据。\n\n---" }, { - "id": "ct-doc-node-workspace", - "title": "编辑与预览", - "markdown": "## 编辑与预览\n\n### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.6.3 条件条款交互\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n---\n\n### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充" + "id": "lbl-doc-lbl-kpi-unreceived", + "title": "未收总计", + "markdownPath": null, + "preview": "## 未收总计 KPI\n\n点击未收总计卡片,下方列表仅展示未收款数据(状态不等于已收款)。\n\n---" }, { - "id": "ct-doc-annotation-hint", - "title": "标注查看提示", - "markdown": "## 标注查看提示\n\n右侧标注工具栏可浏览目录、切换主题与颜色筛选。列表页与编辑页标注按当前页面自动显隐。\n\n**PRD 同步**:目录「PRD 全文」已与 `src/resources/contract-template-management/PRD.md` 对齐;修改 PRD 后需重新同步至 `annotation-source.json`。" + "id": "lbl-doc-lbl-toolbar", + "title": "列表工具栏", + "markdownPath": null, + "preview": "## 4. 列表工具栏\n\n左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板并上传 Excel(原型 Toast) |\n| 导出 | 导出当前筛选结果" + }, + { + "id": "lbl-doc-lbl-btn-mark-confirmed", + "title": "标记为已确认", + "markdownPath": null, + "preview": "## 标记为已确认\n\n勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改。\n\n---" + }, + { + "id": "lbl-doc-lbl-btn-add-row", + "title": "添加行", + "markdownPath": null, + "preview": "## 添加行\n\n位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制。\n\n---" + }, + { + "id": "lbl-doc-lbl-table", + "title": "租赁台账宽表", + "markdownPath": null, + "preview": "## 5. 列表字段\n\n对齐 Excel「租赁车辆收入明细表」全量字段:年份、月份、业务部门(含业务员)、付款日期、车牌(含车型)、客户(含项目)、增值服务、享免政策、提车/合同/起止日期、平均天数、押金、合同标的租金、应收与收入拆分、减免" + }, + { + "id": "lbl-doc-lbl-col-status", + "title": "状态流转", + "markdownPath": null, + "preview": "## 状态流转\n\n1. 新增/导入 → **草稿**(业务服务组可删改)\n2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)\n3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款" + }, + { + "id": "lbl-doc-lbl-col-dept", + "title": "业务部门", + "markdownPath": null, + "preview": "## 业务部门列\n\n业务部门与业务员合并为一列:上方业务部门,下方业务员。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-plate", + "title": "车牌号码", + "markdownPath": null, + "preview": "## 车牌号码列\n\n车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-customer", + "title": "客户名称", + "markdownPath": null, + "preview": "## 客户名称列\n\n客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-pickup-date", + "title": "提车日期", + "markdownPath": null, + "preview": "## 提车日期\n\n等于交车管理中该车辆运维 E 签宝签字完成的时间。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-deposit", + "title": "保证金", + "markdownPath": null, + "preview": "## 保证金\n\n与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-contract-rent", + "title": "合同标的租金", + "markdownPath": null, + "preview": "## 合同标的租金\n\n等于该车辆在签订该合同时合同中的租金(月租金标准)。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-receivable-rent", + "title": "应收租金", + "markdownPath": null, + "preview": "## 应收租金\n\n按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-monthly-income", + "title": "月度收入", + "markdownPath": null, + "preview": "## 月度收入\n\n租金 + 服务费 − 里程减免金额 − 其他减免金额(后两项为减项)。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-monthly-rent", + "title": "月度租金", + "markdownPath": null, + "preview": "## 月度租金\n\n根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-maintenance-income", + "title": "维保包干收入", + "markdownPath": null, + "preview": "## 维保包干收入\n\n合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-insurance-surcharge", + "title": "保险上浮费", + "markdownPath": null, + "preview": "## 保险上浮费\n\n事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-ops-income", + "title": "运维费(收入侧)", + "markdownPath": null, + "preview": "## 运维费(收入侧)\n\n维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-other-income", + "title": "其他收入", + "markdownPath": null, + "preview": "## 其他收入\n\n手工填写,预留作为特殊情况记录使用。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-received-amount", + "title": "实收金额", + "markdownPath": null, + "preview": "## 实收金额\n\n根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-unreceived", + "title": "未收金额", + "markdownPath": null, + "preview": "## 未收金额\n\n未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-invoice-date", + "title": "开票日期", + "markdownPath": null, + "preview": "## 开票日期\n\n账单开票后由财务维护,自动反写,只读。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-actual-payment", + "title": "实际付款日期", + "markdownPath": null, + "preview": "## 实际付款日期\n\n关联收款记录时取对应到账记录的付款日期;未关联时为空。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-payment-method", + "title": "付款方式", + "markdownPath": null, + "preview": "## 付款方式\n\n由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-vehicle-standard-cost", + "title": "车辆标准成本", + "markdownPath": null, + "preview": "## 车辆标准成本\n\n固定成本,按车型明细维护,只读。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-vehicle-actual-cost", + "title": "车辆实际成本", + "markdownPath": null, + "preview": "## 车辆实际成本\n\n车辆标准成本 ×(实际天数 / 30),自动计算。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-insurance-fee", + "title": "保险费", + "markdownPath": null, + "preview": "## 保险费\n\n暂保留,不可编辑和导入;后续可能自动计算。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-hydrogen-fee", + "title": "氢费", + "markdownPath": null, + "preview": "## 氢费\n\n表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账)。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-ops-cost", + "title": "运维费(成本侧)", + "markdownPath": null, + "preview": "## 运维费(成本侧)\n\n与收入侧运维费平进平出,自动同步。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-brokerage-fee", + "title": "居间费", + "markdownPath": null, + "preview": "## 居间费\n\n手动输入。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-other-cost", + "title": "其他(成本侧)", + "markdownPath": null, + "preview": "## 其他(成本侧)\n\n前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-gps-mileage", + "title": "GPS里程", + "markdownPath": null, + "preview": "## GPS里程\n\n从 BI 里程取账单时间段的数据,自动展示。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-deduction-detail", + "title": "其他减免金额明细内容", + "markdownPath": null, + "preview": "## 其他减免金额明细内容\n\n手动输入,备注明细内容。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-asset-ownership", + "title": "资产归属", + "markdownPath": null, + "preview": "## 资产归属\n\n显示车辆实际归属公司名称,按车辆档案自动获取。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-signing-company", + "title": "签约公司", + "markdownPath": null, + "preview": "## 签约公司\n\n租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。\n\n---" + }, + { + "id": "lbl-doc-lbl-col-remark", + "title": "备注", + "markdownPath": null, + "preview": "## 备注\n\n手动输入。\n\n---" } ] \ No newline at end of file diff --git a/.local/extract-annotation-source/output.json b/.local/extract-annotation-source/output.json index 00c4b7d..5901731 100644 --- a/.local/extract-annotation-source/output.json +++ b/.local/extract-annotation-source/output.json @@ -1,8 +1,8 @@ { - "title": "站点信息 - 原型预览(开发)", + "title": "租赁业务台账 - 原型预览(开发)", "errors": [ - "Warning: [antd: Tag] `bordered={false}` is deprecated. Please use `variant=\"filled\"` instead.", - "Warning: [antd: Modal] `destroyOnClose` is deprecated. Please use `destroyOnHidden` instead." + "Warning: Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.%s closed \n at LeaseBusinessLedgerApp (http://localhost:51720/prototypes/lease-business-ledger/index.tsx?t=1782980649549:55:33)\n at AxhubPreviewErrorBoundary (http://localhost:51720/prototypes/lease-business-ledger/__axhub-preview-loader.js:8:5)", + "Warning: Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.%s closed \n at LeaseBusinessLedgerApp (http://localhost:51720/prototypes/lease-business-ledger/index.tsx?t=1782980649549:55:33)\n at AxhubPreviewErrorBoundary (http://localhost:51720/prototypes/lease-business-ledger/__axhub-preview-loader.js:8:5)" ], "source": { "directory": { @@ -13,10 +13,45 @@ "title": "ONE-OS 原型导航", "defaultExpanded": true, "children": [ + { + "type": "link", + "id": "nav-link-contract-template-management", + "title": "合同模板管理", + "href": "/prototypes/contract-template-management", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-insurance-procurement", + "title": "保险采购", + "href": "/prototypes/insurance-procurement", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-vehicle-management", + "title": "车辆管理", + "href": "/prototypes/vehicle-management", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-lease-contract-management", + "title": "租赁合同管理", + "href": "/prototypes/lease-contract-management", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-customer-management", + "title": "客户管理", + "href": "/prototypes/customer-management", + "target": "self" + }, { "type": "link", "id": "nav-link-lease-business-ledger", - "title": "租赁业务台账", + "title": "租赁业务台账(当前)", "href": "/prototypes/lease-business-ledger", "target": "self" }, @@ -144,101 +179,293 @@ }, { "type": "folder", - "id": "h2-site-doc-root", - "title": "站点信息说明", + "id": "lbl-doc-root", + "title": "租赁业务台账说明", "defaultExpanded": true, "children": [ { "type": "markdown", - "id": "h2-site-doc-overview", + "id": "lbl-doc-overview", "title": "模块总览", - "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 加氢站管理 → 站点信息 |\n| 用户 | 运营、财务结算、管理员 |\n| 核心任务 | 站点主数据、运营配置、氢费对账结算 |\n| 页面结构 | 筛选 + KPI + 工具栏 + 列表 + 分页 |\n\n## 2. 关联模块\n\n- **车辆氢费明细**:氢费数据来源与对账回写目标\n- **加氢站合包**:加氢订单、加氢记录仍在合包入口\n\n## 3. 验收重点\n\n1. KPI 高频/低频/无加氢与氢费明细一致\n2. 对账单两阶段流程与跨页回写\n3. 列表布局与租赁合同 vm 规范一致" + "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务台账 |\n| 目标用户 | 租赁业务客服、业务服务组主管 |\n| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页) |\n\n---" }, { "type": "link", - "id": "h2-site-link-vh2", - "title": "车辆氢费明细", - "href": "../vehicle-h2-fee-ledger/index.html", + "id": "lbl-link-self", + "title": "租赁业务台账原型", + "href": "./index.html", "target": "self" }, { - "type": "link", - "id": "h2-site-link-bundle", - "title": "加氢站管理(合包)", - "href": "../oneos-web-h2-station/index.html", - "target": "self" - }, - { - "type": "folder", - "id": "h2-site-doc-prd", + "type": "markdown", + "id": "lbl-doc-prd", "title": "PRD 全文", - "defaultExpanded": true, - "children": [ - { - "type": "markdown", - "id": "h2-site-doc-prd-full", - "title": "PRD 完整文档", - "markdown": "# 加氢站管理 · 站点信息 — 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.0 |\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢站合包](/prototypes/oneos-web-h2-station) |\n| 文档状态 | 已对齐原型 |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 业务背景\n\n加氢站是氢费成本结算的关键节点。运营侧需要维护站点主数据(签约、营业、成本价、预付余额),并在财务周期内按站点与加氢站完成对账、收票与付款闭环。\n\n### 1.2 本期目标\n\n- 站点台账的查询、新建、编辑、查看、删除与批量导入\n- 营业状态、成本价格、预付余额等运营配置\n- **按站点生成氢费对账单 → 填写收票信息 → 提交结算**,并与「车辆氢费明细」联动回写\n\n### 1.3 用户角色\n\n| 角色 | 典型诉求 |\n|---|---|\n| 加氢站运营 | 维护站点资料、营业与价格;查看加氢量与余额 |\n| 财务/结算 | 按周期生成对账单、登记收票、完成站点付款闭环 |\n| 管理员 | 批量导入站点、删除无效站点、配置系统账号 |\n\n### 1.4 非目标(本期不做)\n\n- 对账单审批流、ERP 自动过账\n- 发票 OCR 识别与验真\n- 加氢订单、加氢记录(仍在合包 `oneos-web-h2-station`)\n- 后端接口联调(原型使用 Mock + 共享 Store)\n\n---\n\n## 2. 信息架构\n\n```text\n站点信息\n├── 列表页\n│ ├── 筛选区(4 项)\n│ ├── KPI 卡片(6 张)\n│ ├── 工具栏\n│ └── 站点台账表 + 分页\n├── 新建 / 编辑(同页内嵌视图)\n│ ├── 基本信息\n│ ├── 系统账号绑定\n│ └── 供应商 / 付款信息\n└── 弹窗 / 抽屉\n ├── 查看详情\n ├── 营业状态 / 价格配置\n ├── 加氢量下钻 / 预付余额下钻\n ├── 生成对账单(两阶段)\n └── 查看对账记录\n```\n\n---\n\n## 3. 列表与 KPI\n\n> 详见 `.spec/requirements-prd-list.md`\n\n- 筛选:名称、签约、地区、营业状态\n- KPI:全部 / 签约·普通 / 高频≥3 / 低频1–2 / 无加氢0\n- 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作\n- 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体\n\n---\n\n## 4. 站点主数据\n\n### 4.1 新建站点\n\n同页内嵌视图:基本信息、系统账号绑定(可选)、供应商信息(新建或关联)。\n\n### 4.2 编辑 / 查看\n\n| 模式 | 交互 |\n|---|---|\n| 编辑 | 整页表单;系统账号仅 admin 可改 |\n| 查看 | 弹窗分块展示站点、账号、供应商、付款信息 |\n\n### 4.3 批量导入\n\n下载 CSV 模板 → 上传 → 解析预览 → 确认导入;站点名称不可重复。\n\n---\n\n## 5. 运营配置\n\n### 5.1 营业状态\n\n卡片式弹窗:营业状态、营业时间按钮组;非全天须配置时段;展示状态变更记录。\n\n### 5.2 价格配置\n\n配置成本价格(元/kg)与生效时间;到达生效时间后刷新列表「当前成本价格」;展示价格调整记录。\n\n### 5.3 下钻分析\n\n- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细\n- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出\n\n---\n\n## 6. 对账与结算(重点)\n\n> 详见 `.spec/requirements-prd-statement.md`\n\n与「车辆氢费明细」配合完成站点侧氢费结算闭环:\n\n1. 拉取已对账且未结算的记录\n2. 两阶段:选期生成 → 填收票提交\n3. 提交后更新预付余额、写对账历史、回写台账(对账日期、收票日期、已付款)\n\n---\n\n## 7. 数据与集成\n\n| 能力 | 实现 |\n|---|---|\n| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |\n| 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` |\n| 页面实现 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |\n| 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` |\n\n---\n\n## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---\n\n## 9. 修订记录\n\n| 版本 | 日期 | 说明 |\n|---|---|---|\n| v1.0 | 2026-07-01 | 独立入口、vm 规范对齐、标注目录与 PRD 接入 |\n", - "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md" - }, - { - "type": "markdown", - "id": "h2-site-doc-prd-list", - "title": "列表页 PRD", - "markdown": "# 加氢站管理 · 站点信息 — 列表页 PRD\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标用户 | 加氢站运营、财务结算、平台管理员 |\n| 核心任务 | 查询站点台账、维护主数据、查看运营指标、发起对账与充值 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 设计规范 | 与租赁合同管理一致的 vm 布局(筛选 / KPI / 表格 / 分页) |\n\n---\n\n## 1. 页面结构\n\n用户进入「加氢站管理 → 站点信息」后,列表页自上而下为:\n\n1. **筛选区** — 名称、签约、地区、营业状态\n2. **KPI 分类** — 全部 / 签约·普通 / 高频 / 低频 / 无加氢\n3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点\n4. **站点列表** — 主数据 + 运营指标 + 操作列\n5. **分页栏** — 每页 5/10/20/50 条\n\n---\n\n## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---\n\n## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 高频加氢站 | 该站加氢次数 ≥ 3 |\n| 低频加氢站 | 有加氢但次数 < 3 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---\n\n## 7. 双入口说明\n\n| 入口 | 路径 |\n|---|---|\n| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |\n| 合包 | ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录 |\n", - "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md" - }, - { - "type": "markdown", - "id": "h2-site-doc-prd-statement", - "title": "对账与结算 PRD", - "markdown": "# 加氢站管理 · 站点信息 — 对账与结算 PRD\n\n> 本模块与「车辆氢费明细」配合完成**站点侧氢费结算闭环**。\n\n---\n\n## 1. 数据来源\n\n对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录:\n\n1. 对账状态 = **已对账**(台账已完成「完成对账」)\n2. **尚未**被本站点历史对账单结算过(未绑定 `statementRecordId`)\n3. 加氢时间落在用户选择的 **[账单开始日期, 账单结束日期]** 内\n4. 加氢站名称与当前操作站点一致\n\n> 对账单明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。\n\n---\n\n## 2. 两阶段交互\n\n**阶段一 · 选期生成**\n\n- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)\n- 默认开始日期建议 = 上次结束日 + 1 天\n- 点击「生成对账单」后进入阶段二,日期锁定不可改\n- 无符合条件记录时提示调整日期\n\n**阶段二 · 结算提交**\n\n统计卡(明细表上方):\n\n| 指标 | 说明 |\n|---|---|\n| 加氢次数 | 本账单包含的已对账记录笔数 |\n| 加氢总量 | kg 合计 |\n| 成本总金额 | 成本总价合计 |\n\n明细表下方**必填**结算项:\n\n| 字段 | 规则 |\n|---|---|\n| 结算后加氢站预付款余额 | **只读**,= 当前预付余额 − 成本总金额;不足为负并标「已欠费」 |\n| 收票日期 | 必填 |\n| 收票金额 | 必填,默认填入成本总金额 |\n| 发票附件 | 必填,支持上传 |\n\n---\n\n## 3. 提交后的系统行为\n\n提交成功后需**原子性**完成:\n\n1. 写入对账历史(供「查看对账记录」查询)\n2. 扣减站点预付余额\n3. 标记台账记录已结算(绑定 `statementRecordId`)\n4. **回写车辆氢费明细**:\n\n| 车辆氢费明细字段 | 回写值 |\n|---|---|\n| 对账日期 | 操作完成时间 |\n| 收票日期 | 操作完成日期 |\n| 加氢站付款状态 | 已付款 |\n\n原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `h2VehicleLedgerBridge` 模拟跨页回写。\n\n---\n\n## 4. 查看对账记录\n\n入口:列表操作列 → 更多 → **查看对账记录**\n\n历史列表:对账日期、对账人、账单区间、加氢次数、加氢金额、结算后余额、查看明细。\n\n查看明细:账单区间、统计三卡、收票信息、发票附件、成本明细表。\n\n---\n\n## 5. 校验与异常\n\n| 场景 | 处理 |\n|---|---|\n| 日期未填 / 开始晚于结束 | 阻止生成 |\n| 区间内无已对账未结算记录 | 阻止生成,提示调整日期 |\n| 收票日期/金额/附件未填 | 阻止提交 |\n| 收票金额 ≤ 0 | 阻止提交 |\n| 预付余额结算后为负 | 允许提交,标「已欠费」 |\n\n---\n\n## 6. 研发要点\n\n1. **对账单幂等**:同一笔已对账记录只能进入一次成功提交的对账单\n2. **上次结束时间**:取该站点已成功提交对账单的最大 `endDate`\n3. **余额计算**:`balanceAfter = prepaidBalance - sum(costTotal)`\n4. **跨模块同步**:生产对接统一台账;原型使用 Bridge Store\n5. **明细字段范围**:对账弹窗不展示客户侧加氢单价/总价\n", - "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-statement.md" - }, - { - "type": "markdown", - "id": "h2-site-doc-scope", - "title": "范围与决策", - "markdown": "# 站点信息独立原型 — 范围与决策\n\n## 背景\n\n「站点信息」从 ONE-OS Web 合包 `oneos-web-h2-station` 拆出,作为 OneOS → 加氢站管理下的独立 Axhub 入口,便于与正式菜单路径对齐。\n\n## 范围\n\n| 包含 | 不包含 |\n|------|--------|\n| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单、加氢记录(仍在合包) |\n| 营业状态、价格配置、余额下钻 | 后端接口联调 |\n| 两阶段对账单、充值单、批量导入 | |\n\n## 数据来源\n\n- **氢费/加氢明细**:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」原型共享 Store。\n- 先打开「车辆氢费明细」时,台账全量行会同步至 Store;仅打开加氢站模块时使用 canonical seed(10 条演示数据)。\n- 对账单提交 patch 经 `window.H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细。\n\n## KPI 口径(已确认)\n\n全部 / 签约·普通 / **高频(≥3 次)** / **低频(1–2 次)** / **无加氢(0 次)**,与《站点管理使用说明书》及页面内嵌 PRD 一致。\n\n## 角色演示\n\n页面右上角提供「平台管理员 / 加氢站账号」切换,影响编辑页系统账号字段可编辑性。\n\n## 双入口说明\n\n- **本原型**:OneOS → 加氢站管理 → 站点信息\n- **合包**:ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录\n", - "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements.md" - }, - { - "type": "markdown", - "id": "h2-site-doc-acceptance", - "title": "验收标准", - "markdown": "## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---" - } - ] + "markdown": "# 租赁业务台账 · 产品需求说明(PRD)\n\n> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务台账 |\n| 目标用户 | 租赁业务客服、业务服务组主管 |\n| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页) |\n\n---\n\n## 2. 筛选区\n\n默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。\n\n| 筛选项 | 说明 |\n|---|---|\n| 年份 / 月份 | 账单所属年月 |\n| 业务部门 / 业务员 | 精确匹配 |\n| 车牌号码 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 明细状态 | 草稿 / 已确认 / 部分收款 / 已收款 |\n| 付款日期 | 日期区间 |\n\n---\n\n## 3. KPI 统计卡片(6 张)\n\n台账条数、应收总计、实收总计、未收总计、成本总计、盈亏总计。\n\n- **台账条数**:员工仅统计本人录入;主管统计全组。点击切换列表为显示全部。\n- **未收总计**:点击筛选下方列表为未收款数据。\n\n### 台账条数 KPI\n\n业务服务组员工只能看到自己录入的数据条数以及金额;业务服务组主管可以查看所有业务服务组人员录入的数据总和。点击可切换列表显示全部数据。\n\n### 未收总计 KPI\n\n点击未收总计卡片,下方列表仅展示未收款数据(状态不等于已收款)。\n\n---\n\n## 4. 列表工具栏\n\n左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板并上传 Excel(原型 Toast) |\n| 导出 | 导出当前筛选结果 |\n| 标记为已确认 | 勾选草稿行后二次确认 |\n| 提交收款审批 | 勾选行提交审批 |\n\n添加行已移至表格底部;列表采用下拉加载更多,不做分页。\n\n### 标记为已确认\n\n勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改。\n\n### 添加行\n\n位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制。\n\n---\n\n## 5. 列表字段\n\n对齐 Excel「租赁车辆收入明细表」全量字段:年份、月份、业务部门(含业务员)、付款日期、车牌(含车型)、客户(含项目)、增值服务、享免政策、提车/合同/起止日期、平均天数、押金、合同标的租金、应收与收入拆分、减免、实收/未收、开票与付款信息、成本拆分、盈亏、GPS里程、减免明细、资产归属、签约公司、备注。\n\n宽表支持横向滚动;序号与操作列固定。\n\n---\n\n## 6. 状态流转\n\n1. 新增/导入 → **草稿**(业务服务组可删改)\n2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)\n3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款**\n\n---\n\n## 7. 字段说明\n\n### 业务部门列\n\n业务部门与业务员合并为一列:上方业务部门,下方业务员。\n\n### 车牌号码列\n\n车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。\n\n### 客户名称列\n\n客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。\n\n### 提车日期\n\n等于交车管理中该车辆运维 E 签宝签字完成的时间。\n\n### 保证金\n\n与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示。\n\n### 合同标的租金\n\n等于该车辆在签订该合同时合同中的租金(月租金标准)。\n\n### 应收租金\n\n按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。\n\n### 月度收入\n\n租金 + 服务费 − 里程减免金额 − 其他减免金额(后两项为减项)。\n\n### 月度租金\n\n根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。\n\n### 维保包干收入\n\n合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。\n\n### 保险上浮费\n\n事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。\n\n### 运维费(收入侧)\n\n维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。\n\n### 其他收入\n\n手工填写,预留作为特殊情况记录使用。\n\n### 实收金额\n\n根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配。\n\n### 未收金额\n\n未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。\n\n### 开票日期\n\n账单开票后由财务维护,自动反写,只读。\n\n### 实际付款日期\n\n关联收款记录时取对应到账记录的付款日期;未关联时为空。\n\n### 付款方式\n\n由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。\n\n### 车辆标准成本\n\n固定成本,按车型明细维护,只读。\n\n### 车辆实际成本\n\n车辆标准成本 ×(实际天数 / 30),自动计算。\n\n### 保险费\n\n暂保留,不可编辑和导入;后续可能自动计算。\n\n### 氢费\n\n表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账)。\n\n### 运维费(成本侧)\n\n与收入侧运维费平进平出,自动同步。\n\n### 居间费\n\n手动输入。\n\n### 其他(成本侧)\n\n前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。\n\n### GPS里程\n\n从 BI 里程取账单时间段的数据,自动展示。\n\n### 其他减免金额明细内容\n\n手动输入,备注明细内容。\n\n### 资产归属\n\n显示车辆实际归属公司名称,按车辆档案自动获取。\n\n### 签约公司\n\n租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。\n\n### 备注\n\n手动输入。", + "markdownPath": "src/prototypes/lease-business-ledger/.spec/requirements-prd.md" }, { "type": "folder", - "id": "h2-site-doc-page", + "id": "lbl-doc-page-annotations", "title": "页面标注", "defaultExpanded": true, "children": [ { "type": "markdown", - "id": "h2-site-doc-node-h2-site-filter", + "id": "lbl-doc-lbl-filter", "title": "筛选条件", - "markdown": "## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---" + "markdown": "## 2. 筛选区\n\n默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。\n\n| 筛选项 | 说明 |\n|---|---|\n| 年份 / 月份 | 账单所属年月 |\n| 业务部门 / 业务员 | 精确匹配 |\n| 车牌号码 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 明细状态 | 草稿 / 已确认 / 部分收款 / 已收款 |\n| 付款日期 | 日期区间 |\n\n---" }, { - "type": "markdown", - "id": "h2-site-doc-node-h2-site-kpi", - "title": "KPI 快捷筛选", - "markdown": "## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 高频加氢站 | 该站加氢次数 ≥ 3 |\n| 低频加氢站 | 有加氢但次数 < 3 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)。\n\n---" + "type": "folder", + "id": "lbl-doc-folder-kpi", + "title": "KPI 统计", + "defaultExpanded": false, + "children": [ + { + "type": "markdown", + "id": "lbl-doc-lbl-kpi", + "title": "KPI 统计", + "markdown": "## 3. KPI 统计卡片(6 张)\n\n台账条数、应收总计、实收总计、未收总计、成本总计、盈亏总计。\n\n- **台账条数**:员工仅统计本人录入;主管统计全组。点击切换列表为显示全部。\n- **未收总计**:点击筛选下方列表为未收款数据。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-kpi-count", + "title": "台账条数", + "markdown": "## 台账条数 KPI\n\n业务服务组员工只能看到自己录入的数据条数以及金额;业务服务组主管可以查看所有业务服务组人员录入的数据总和。点击可切换列表显示全部数据。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-kpi-unreceived", + "title": "未收总计", + "markdown": "## 未收总计 KPI\n\n点击未收总计卡片,下方列表仅展示未收款数据(状态不等于已收款)。\n\n---" + } + ] }, { - "type": "markdown", - "id": "h2-site-doc-node-h2-site-statement", - "title": "工具栏与对账", - "markdown": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---" + "type": "folder", + "id": "lbl-doc-folder-toolbar", + "title": "列表工具栏", + "defaultExpanded": false, + "children": [ + { + "type": "markdown", + "id": "lbl-doc-lbl-toolbar", + "title": "列表工具栏", + "markdown": "## 4. 列表工具栏\n\n左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板并上传 Excel(原型 Toast) |\n| 导出 | 导出当前筛选结果 |\n| 标记为已确认 | 勾选草稿行后二次确认 |\n| 提交收款审批 | 勾选行提交审批 |\n\n添加行已移至表格底部;列表采用下拉加载更多,不做分页。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-btn-mark-confirmed", + "title": "标记为已确认", + "markdown": "## 标记为已确认\n\n勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-btn-add-row", + "title": "添加行", + "markdown": "## 添加行\n\n位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制。\n\n---" + } + ] }, { - "type": "markdown", - "id": "h2-site-doc-node-h2-site-table", - "title": "站点列表", - "markdown": "## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---" + "type": "folder", + "id": "lbl-doc-folder-table", + "title": "租赁台账宽表", + "defaultExpanded": true, + "children": [ + { + "type": "markdown", + "id": "lbl-doc-lbl-table", + "title": "租赁台账宽表", + "markdown": "## 5. 列表字段\n\n对齐 Excel「租赁车辆收入明细表」全量字段:年份、月份、业务部门(含业务员)、付款日期、车牌(含车型)、客户(含项目)、增值服务、享免政策、提车/合同/起止日期、平均天数、押金、合同标的租金、应收与收入拆分、减免、实收/未收、开票与付款信息、成本拆分、盈亏、GPS里程、减免明细、资产归属、签约公司、备注。\n\n宽表支持横向滚动;序号与操作列固定。" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-status", + "title": "状态流转", + "markdown": "## 状态流转\n\n1. 新增/导入 → **草稿**(业务服务组可删改)\n2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)\n3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款**\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-dept", + "title": "业务部门", + "markdown": "## 业务部门列\n\n业务部门与业务员合并为一列:上方业务部门,下方业务员。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-plate", + "title": "车牌号码", + "markdown": "## 车牌号码列\n\n车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-customer", + "title": "客户名称", + "markdown": "## 客户名称列\n\n客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-pickup-date", + "title": "提车日期", + "markdown": "## 提车日期\n\n等于交车管理中该车辆运维 E 签宝签字完成的时间。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-deposit", + "title": "保证金", + "markdown": "## 保证金\n\n与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-contract-rent", + "title": "合同标的租金", + "markdown": "## 合同标的租金\n\n等于该车辆在签订该合同时合同中的租金(月租金标准)。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-receivable-rent", + "title": "应收租金", + "markdown": "## 应收租金\n\n按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-monthly-income", + "title": "月度收入", + "markdown": "## 月度收入\n\n租金 + 服务费 − 里程减免金额 − 其他减免金额(后两项为减项)。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-monthly-rent", + "title": "月度租金", + "markdown": "## 月度租金\n\n根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-maintenance-income", + "title": "维保包干收入", + "markdown": "## 维保包干收入\n\n合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-insurance-surcharge", + "title": "保险上浮费", + "markdown": "## 保险上浮费\n\n事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-ops-income", + "title": "运维费(收入侧)", + "markdown": "## 运维费(收入侧)\n\n维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-other-income", + "title": "其他收入", + "markdown": "## 其他收入\n\n手工填写,预留作为特殊情况记录使用。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-received-amount", + "title": "实收金额", + "markdown": "## 实收金额\n\n根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-unreceived", + "title": "未收金额", + "markdown": "## 未收金额\n\n未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-invoice-date", + "title": "开票日期", + "markdown": "## 开票日期\n\n账单开票后由财务维护,自动反写,只读。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-actual-payment", + "title": "实际付款日期", + "markdown": "## 实际付款日期\n\n关联收款记录时取对应到账记录的付款日期;未关联时为空。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-payment-method", + "title": "付款方式", + "markdown": "## 付款方式\n\n由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-vehicle-standard-cost", + "title": "车辆标准成本", + "markdown": "## 车辆标准成本\n\n固定成本,按车型明细维护,只读。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-vehicle-actual-cost", + "title": "车辆实际成本", + "markdown": "## 车辆实际成本\n\n车辆标准成本 ×(实际天数 / 30),自动计算。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-insurance-fee", + "title": "保险费", + "markdown": "## 保险费\n\n暂保留,不可编辑和导入;后续可能自动计算。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-hydrogen-fee", + "title": "氢费", + "markdown": "## 氢费\n\n表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账)。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-ops-cost", + "title": "运维费(成本侧)", + "markdown": "## 运维费(成本侧)\n\n与收入侧运维费平进平出,自动同步。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-brokerage-fee", + "title": "居间费", + "markdown": "## 居间费\n\n手动输入。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-other-cost", + "title": "其他(成本侧)", + "markdown": "## 其他(成本侧)\n\n前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-gps-mileage", + "title": "GPS里程", + "markdown": "## GPS里程\n\n从 BI 里程取账单时间段的数据,自动展示。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-deduction-detail", + "title": "其他减免金额明细内容", + "markdown": "## 其他减免金额明细内容\n\n手动输入,备注明细内容。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-asset-ownership", + "title": "资产归属", + "markdown": "## 资产归属\n\n显示车辆实际归属公司名称,按车辆档案自动获取。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-signing-company", + "title": "签约公司", + "markdown": "## 签约公司\n\n租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。\n\n---" + }, + { + "type": "markdown", + "id": "lbl-doc-lbl-col-remark", + "title": "备注", + "markdown": "## 备注\n\n手动输入。\n\n---" + } + ] } ] } @@ -248,91 +475,822 @@ }, "nodes": [ { - "id": "h2-site-filter", + "id": "lbl-filter", "index": 1, "title": "筛选条件", - "pageId": "site-info", + "pageId": "list", "locator": { "selectors": [ - ".h2-station-page .vm-filter-card", - "[data-annotation-id=\"h2-site-filter\"]" + "[data-annotation-id=\"lbl-filter\"]" ], - "fingerprint": "filter|site-info", + "fingerprint": "lbl-filter", "path": [] }, - "aiPrompt": "筛选字段与查询/重置行为。", - "annotationText": "## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---", + "aiPrompt": "年份、月份、部门、业务员等筛选。", + "annotationText": "## 2. 筛选区\n\n默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。\n\n| 筛选项 | 说明 |\n|---|---|\n| 年份 / 月份 | 账单所属年月 |\n| 业务部门 / 业务员 | 精确匹配 |\n| 车牌号码 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 明细状态 | 草稿 / 已确认 / 部分收款 / 已收款 |\n| 付款日期 | 日期区间 |\n\n---", "hasMarkdown": true, "color": "#2563eb", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1740787200000, + "updatedAt": 1740787200000, "controls": [] }, { - "id": "h2-site-kpi", + "id": "lbl-kpi", "index": 2, - "title": "KPI 快捷筛选", - "pageId": "site-info", + "title": "KPI 统计", + "pageId": "list", "locator": { "selectors": [ - ".h2-station-page .vm-kpi-row", - "[data-annotation-id=\"h2-site-kpi\"]" + "[data-annotation-id=\"lbl-kpi\"]" ], - "fingerprint": "kpi|site-info", + "fingerprint": "lbl-kpi", "path": [] }, - "aiPrompt": "六张 KPI 卡片口径与点击筛选。", - "annotationText": "## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 高频加氢站 | 该站加氢次数 ≥ 3 |\n| 低频加氢站 | 有加氢但次数 < 3 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)。\n\n---", + "aiPrompt": "六张统计卡片口径。", + "annotationText": "## 3. KPI 统计卡片(6 张)\n\n台账条数、应收总计、实收总计、未收总计、成本总计、盈亏总计。\n\n- **台账条数**:员工仅统计本人录入;主管统计全组。点击切换列表为显示全部。\n- **未收总计**:点击筛选下方列表为未收款数据。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1740787200000, + "controls": [] + }, + { + "id": "lbl-kpi-count", + "index": 3, + "title": "台账条数", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-kpi-count\"]" + ], + "fingerprint": "lbl-kpi-count", + "path": [] + }, + "aiPrompt": "台账条数 KPI 点击切换列表。", + "annotationText": "## 台账条数 KPI\n\n业务服务组员工只能看到自己录入的数据条数以及金额;业务服务组主管可以查看所有业务服务组人员录入的数据总和。点击可切换列表显示全部数据。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-kpi-unreceived", + "index": 4, + "title": "未收总计", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-kpi-unreceived\"]" + ], + "fingerprint": "lbl-kpi-unreceived", + "path": [] + }, + "aiPrompt": "未收总计 KPI 筛选未收款。", + "annotationText": "## 未收总计 KPI\n\n点击未收总计卡片,下方列表仅展示未收款数据(状态不等于已收款)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-toolbar", + "index": 5, + "title": "列表工具栏", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-toolbar\"]" + ], + "fingerprint": "lbl-toolbar", + "path": [] + }, + "aiPrompt": "导入、导出、标记确认、提交审批。", + "annotationText": "## 4. 列表工具栏\n\n左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板并上传 Excel(原型 Toast) |\n| 导出 | 导出当前筛选结果 |\n| 标记为已确认 | 勾选草稿行后二次确认 |\n| 提交收款审批 | 勾选行提交审批 |\n\n添加行已移至表格底部;列表采用下拉加载更多,不做分页。\n\n---", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1740787200000, + "controls": [] + }, + { + "id": "lbl-btn-mark-confirmed", + "index": 6, + "title": "标记为已确认", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-btn-mark-confirmed\"]" + ], + "fingerprint": "lbl-btn-mark-confirmed", + "path": [] + }, + "aiPrompt": "草稿批量确认与权限收口。", + "annotationText": "## 标记为已确认\n\n勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改。\n\n---", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-btn-add-row", + "index": 7, + "title": "添加行", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-btn-add-row\"]" + ], + "fingerprint": "lbl-btn-add-row", + "path": [] + }, + "aiPrompt": "表格底部新增行。", + "annotationText": "## 添加行\n\n位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制。\n\n---", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-table", + "index": 8, + "title": "租赁台账宽表", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-table\"]" + ], + "fingerprint": "lbl-table", + "path": [] + }, + "aiPrompt": "运营明细台账列表与行操作。", + "annotationText": "## 5. 列表字段\n\n对齐 Excel「租赁车辆收入明细表」全量字段:年份、月份、业务部门(含业务员)、付款日期、车牌(含车型)、客户(含项目)、增值服务、享免政策、提车/合同/起止日期、平均天数、押金、合同标的租金、应收与收入拆分、减免、实收/未收、开票与付款信息、成本拆分、盈亏、GPS里程、减免明细、资产归属、签约公司、备注。\n\n宽表支持横向滚动;序号与操作列固定。", "hasMarkdown": true, "color": "#2563eb", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1740787200000, + "updatedAt": 1782700000000, "controls": [] }, { - "id": "h2-site-statement", - "index": 3, - "title": "工具栏与对账", - "pageId": "site-info", + "id": "lbl-col-status", + "index": 9, + "title": "状态流转", + "pageId": "list", "locator": { "selectors": [ - ".h2-station-page .vm-table-toolbar", - "[data-annotation-id=\"h2-site-toolbar\"]" + "[data-annotation-id=\"lbl-col-status\"]" ], - "fingerprint": "toolbar|statement", + "fingerprint": "lbl-col-status", "path": [] }, - "aiPrompt": "工具栏操作与对账单氢费联动。", - "annotationText": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---", + "aiPrompt": "状态流转字段说明。", + "annotationText": "## 状态流转\n\n1. 新增/导入 → **草稿**(业务服务组可删改)\n2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)\n3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款**\n\n---", "hasMarkdown": true, - "color": "#d97706", + "color": "#64748b", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1782700000000, + "updatedAt": 1782700000000, "controls": [] }, { - "id": "h2-site-table", - "index": 4, - "title": "站点列表", - "pageId": "site-info", + "id": "lbl-col-dept", + "index": 10, + "title": "业务部门", + "pageId": "list", "locator": { "selectors": [ - ".h2-station-page .vm-table-card", - "[data-annotation-id=\"h2-site-table\"]" + "[data-annotation-id=\"lbl-col-dept\"]" ], - "fingerprint": "table|site-info", + "fingerprint": "lbl-col-dept", "path": [] }, - "aiPrompt": "台账表格列、下钻与行内操作。", - "annotationText": "## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---", + "aiPrompt": "业务部门字段说明。", + "annotationText": "## 业务部门列\n\n业务部门与业务员合并为一列:上方业务部门,下方业务员。\n\n---", "hasMarkdown": true, - "color": "#32a06e", + "color": "#64748b", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-plate", + "index": 11, + "title": "车牌号码", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-plate\"]" + ], + "fingerprint": "lbl-col-plate", + "path": [] + }, + "aiPrompt": "车牌号码字段说明。", + "annotationText": "## 车牌号码列\n\n车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-customer", + "index": 12, + "title": "客户名称", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-customer\"]" + ], + "fingerprint": "lbl-col-customer", + "path": [] + }, + "aiPrompt": "客户名称字段说明。", + "annotationText": "## 客户名称列\n\n客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-pickup-date", + "index": 13, + "title": "提车日期", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-pickup-date\"]" + ], + "fingerprint": "lbl-col-pickup-date", + "path": [] + }, + "aiPrompt": "提车日期字段说明。", + "annotationText": "## 提车日期\n\n等于交车管理中该车辆运维 E 签宝签字完成的时间。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-deposit", + "index": 14, + "title": "保证金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-deposit\"]" + ], + "fingerprint": "lbl-col-deposit", + "path": [] + }, + "aiPrompt": "保证金字段说明。", + "annotationText": "## 保证金\n\n与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-contract-rent", + "index": 15, + "title": "合同标的租金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-contract-rent\"]" + ], + "fingerprint": "lbl-col-contract-rent", + "path": [] + }, + "aiPrompt": "合同标的租金字段说明。", + "annotationText": "## 合同标的租金\n\n等于该车辆在签订该合同时合同中的租金(月租金标准)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-receivable-rent", + "index": 16, + "title": "应收租金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-receivable-rent\"]" + ], + "fingerprint": "lbl-col-receivable-rent", + "path": [] + }, + "aiPrompt": "应收租金字段说明。", + "annotationText": "## 应收租金\n\n按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-monthly-income", + "index": 17, + "title": "月度收入", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-monthly-income\"]" + ], + "fingerprint": "lbl-col-monthly-income", + "path": [] + }, + "aiPrompt": "月度收入字段说明。", + "annotationText": "## 月度收入\n\n租金 + 服务费 − 里程减免金额 − 其他减免金额(后两项为减项)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-monthly-rent", + "index": 18, + "title": "月度租金", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-monthly-rent\"]" + ], + "fingerprint": "lbl-col-monthly-rent", + "path": [] + }, + "aiPrompt": "月度租金字段说明。", + "annotationText": "## 月度租金\n\n根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-maintenance-income", + "index": 19, + "title": "维保包干收入", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-maintenance-income\"]" + ], + "fingerprint": "lbl-col-maintenance-income", + "path": [] + }, + "aiPrompt": "维保包干收入字段说明。", + "annotationText": "## 维保包干收入\n\n合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-insurance-surcharge", + "index": 20, + "title": "保险上浮费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-insurance-surcharge\"]" + ], + "fingerprint": "lbl-col-insurance-surcharge", + "path": [] + }, + "aiPrompt": "保险上浮费字段说明。", + "annotationText": "## 保险上浮费\n\n事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-ops-income", + "index": 21, + "title": "运维费(收入侧)", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-ops-income\"]" + ], + "fingerprint": "lbl-col-ops-income", + "path": [] + }, + "aiPrompt": "运维费(收入侧)字段说明。", + "annotationText": "## 运维费(收入侧)\n\n维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-other-income", + "index": 22, + "title": "其他收入", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-other-income\"]" + ], + "fingerprint": "lbl-col-other-income", + "path": [] + }, + "aiPrompt": "其他收入字段说明。", + "annotationText": "## 其他收入\n\n手工填写,预留作为特殊情况记录使用。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-received-amount", + "index": 23, + "title": "实收金额", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-received-amount\"]" + ], + "fingerprint": "lbl-col-received-amount", + "path": [] + }, + "aiPrompt": "实收金额字段说明。", + "annotationText": "## 实收金额\n\n根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-unreceived", + "index": 24, + "title": "未收金额", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-unreceived\"]" + ], + "fingerprint": "lbl-col-unreceived", + "path": [] + }, + "aiPrompt": "未收金额字段说明。", + "annotationText": "## 未收金额\n\n未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1782700000000, + "updatedAt": 1782700000000, + "controls": [] + }, + { + "id": "lbl-col-invoice-date", + "index": 25, + "title": "开票日期", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-invoice-date\"]" + ], + "fingerprint": "lbl-col-invoice-date", + "path": [] + }, + "aiPrompt": "开票日期字段说明。", + "annotationText": "## 开票日期\n\n账单开票后由财务维护,自动反写,只读。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-actual-payment", + "index": 26, + "title": "实际付款日期", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-actual-payment\"]" + ], + "fingerprint": "lbl-col-actual-payment", + "path": [] + }, + "aiPrompt": "实际付款日期字段说明。", + "annotationText": "## 实际付款日期\n\n关联收款记录时取对应到账记录的付款日期;未关联时为空。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-payment-method", + "index": 27, + "title": "付款方式", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-payment-method\"]" + ], + "fingerprint": "lbl-col-payment-method", + "path": [] + }, + "aiPrompt": "付款方式字段说明。", + "annotationText": "## 付款方式\n\n由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-vehicle-standard-cost", + "index": 28, + "title": "车辆标准成本", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-vehicle-standard-cost\"]" + ], + "fingerprint": "lbl-col-vehicle-standard-cost", + "path": [] + }, + "aiPrompt": "车辆标准成本字段说明。", + "annotationText": "## 车辆标准成本\n\n固定成本,按车型明细维护,只读。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-vehicle-actual-cost", + "index": 29, + "title": "车辆实际成本", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-vehicle-actual-cost\"]" + ], + "fingerprint": "lbl-col-vehicle-actual-cost", + "path": [] + }, + "aiPrompt": "车辆实际成本字段说明。", + "annotationText": "## 车辆实际成本\n\n车辆标准成本 ×(实际天数 / 30),自动计算。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-insurance-fee", + "index": 30, + "title": "保险费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-insurance-fee\"]" + ], + "fingerprint": "lbl-col-insurance-fee", + "path": [] + }, + "aiPrompt": "保险费字段说明。", + "annotationText": "## 保险费\n\n暂保留,不可编辑和导入;后续可能自动计算。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-hydrogen-fee", + "index": 31, + "title": "氢费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-hydrogen-fee\"]" + ], + "fingerprint": "lbl-col-hydrogen-fee", + "path": [] + }, + "aiPrompt": "氢费字段说明。", + "annotationText": "## 氢费\n\n表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账)。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-ops-cost", + "index": 32, + "title": "运维费(成本侧)", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-ops-cost\"]" + ], + "fingerprint": "lbl-col-ops-cost", + "path": [] + }, + "aiPrompt": "运维费(成本侧)字段说明。", + "annotationText": "## 运维费(成本侧)\n\n与收入侧运维费平进平出,自动同步。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-brokerage-fee", + "index": 33, + "title": "居间费", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-brokerage-fee\"]" + ], + "fingerprint": "lbl-col-brokerage-fee", + "path": [] + }, + "aiPrompt": "居间费字段说明。", + "annotationText": "## 居间费\n\n手动输入。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-other-cost", + "index": 34, + "title": "其他(成本侧)", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-other-cost\"]" + ], + "fingerprint": "lbl-col-other-cost", + "path": [] + }, + "aiPrompt": "其他(成本侧)字段说明。", + "annotationText": "## 其他(成本侧)\n\n前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-gps-mileage", + "index": 35, + "title": "GPS里程", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-gps-mileage\"]" + ], + "fingerprint": "lbl-col-gps-mileage", + "path": [] + }, + "aiPrompt": "GPS里程字段说明。", + "annotationText": "## GPS里程\n\n从 BI 里程取账单时间段的数据,自动展示。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-deduction-detail", + "index": 36, + "title": "其他减免金额明细内容", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-deduction-detail\"]" + ], + "fingerprint": "lbl-col-deduction-detail", + "path": [] + }, + "aiPrompt": "其他减免金额明细内容字段说明。", + "annotationText": "## 其他减免金额明细内容\n\n手动输入,备注明细内容。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-asset-ownership", + "index": 37, + "title": "资产归属", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-asset-ownership\"]" + ], + "fingerprint": "lbl-col-asset-ownership", + "path": [] + }, + "aiPrompt": "资产归属字段说明。", + "annotationText": "## 资产归属\n\n显示车辆实际归属公司名称,按车辆档案自动获取。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-signing-company", + "index": 38, + "title": "签约公司", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-signing-company\"]" + ], + "fingerprint": "lbl-col-signing-company", + "path": [] + }, + "aiPrompt": "签约公司字段说明。", + "annotationText": "## 签约公司\n\n租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, + "controls": [] + }, + { + "id": "lbl-col-remark", + "index": 39, + "title": "备注", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"lbl-col-remark\"]" + ], + "fingerprint": "lbl-col-remark", + "path": [] + }, + "aiPrompt": "备注字段说明。", + "annotationText": "## 备注\n\n手动输入。\n\n---", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1782711877197, "controls": [] } ] diff --git a/package-lock.json b/package-lock.json index 6bf1048..4bb4e90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.11", "dependencies": { "@axhub/annotation": "^1.0.10", + "docx-preview": "^0.3.7", "lucide-react": "^0.562.0", + "mammoth": "^1.12.0", "xlsx": "^0.18.5" }, "devDependencies": { @@ -2585,6 +2587,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/adler-32": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", @@ -2693,6 +2704,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -2706,6 +2737,12 @@ "node": ">=6.0.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -2825,6 +2862,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2877,6 +2920,30 @@ "node": ">=8" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/docx-preview": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz", + "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==", + "license": "Apache-2.0", + "dependencies": { + "jszip": ">=3.0.0" + } + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.378", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", @@ -3183,12 +3250,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-mobile": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3294,6 +3379,27 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3586,6 +3692,17 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3656,6 +3773,39 @@ "node": ">=10" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/motion": { "version": "12.41.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.41.0.tgz", @@ -3767,6 +3917,12 @@ "wrappy": "1" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3787,9 +3943,17 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, "license": "(MIT AND Zlib)" }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3853,6 +4017,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -3905,6 +4075,21 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -3950,6 +4135,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3985,6 +4176,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4017,6 +4214,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -4043,6 +4246,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -4185,6 +4397,12 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -4224,6 +4442,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -5006,6 +5230,15 @@ "node": ">=0.8" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index d7e59d6..f926554 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ }, "dependencies": { "@axhub/annotation": "^1.0.10", + "docx-preview": "^0.3.7", "lucide-react": "^0.562.0", + "mammoth": "^1.12.0", "xlsx": "^0.18.5" } } diff --git a/scripts/sync-oneos-web-prototypes.mjs b/scripts/sync-oneos-web-prototypes.mjs index 7052e1d..6bbe39b 100644 --- a/scripts/sync-oneos-web-prototypes.mjs +++ b/scripts/sync-oneos-web-prototypes.mjs @@ -23,7 +23,9 @@ const XLL_MODULES = [ /** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */ const STANDALONE_MODULES = [ { slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true }, + { slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true }, { slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true }, + { slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true }, ]; const MODULES = [ @@ -32,12 +34,18 @@ const MODULES = [ { slug: 'oneos-web-vehicle-asset', title: '车辆管理', files: ['车辆管理.jsx', '车辆管理-查看.jsx'] }, { slug: 'oneos-web-contract-template', title: '合同模板管理', files: ['合同模板管理.jsx'] }, { slug: 'oneos-web-help-center', title: '帮助中心', dir: '帮助中心' }, - { slug: 'oneos-web-finance', title: '财务管理', dir: '财务管理', excludeDirs: ['文档'] }, + { + slug: 'oneos-web-finance', + title: '财务管理', + dir: '财务管理', + excludeDirs: ['文档'], + excludeFiles: ['还车应结款.jsx', '还车应结款-查看.jsx', '还车应结款-费用明细.jsx'], + }, { slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' }, { slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' }, { slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'], excludeFiles: ['站点信息.jsx'] }, { slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx'] }, - { slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'] }, + { slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'], excludeFiles: ['车辆维修明细.jsx'] }, { slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] }, { slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' }, { slug: 'oneos-web-requirements', title: '需求说明', dir: '需求说明', docsOnly: true }, diff --git a/skills-lock.json b/skills-lock.json index ff044d1..45635e1 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,4 +1,11 @@ { "version": 1, - "skills": {} + "skills": { + "to-prd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-prd/SKILL.md", + "computedHash": "5a733e19d825f22e83de52e68b8840c9fd2fa7501282277a122c1aa28a922059" + } + } } diff --git a/src/common/TablePagination.tsx b/src/common/TablePagination.tsx index 39eb635..25bf188 100644 --- a/src/common/TablePagination.tsx +++ b/src/common/TablePagination.tsx @@ -7,12 +7,17 @@ export const DEFAULT_PAGE_SIZE = 20; /** 全局可选每页条数 */ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; +/** 加氢站等场景的紧凑每页条数选项 */ +export const COMPACT_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const; + export interface TablePaginationProps { page: number; pageSize: number; total: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; + /** 每页条数下拉选项,默认 PAGE_SIZE_OPTIONS */ + pageSizeOptions?: readonly number[]; } function buildPageItems(current: number, totalPages: number): Array { @@ -40,66 +45,74 @@ export function TablePagination({ total, onPageChange, onPageSizeChange, + pageSizeOptions = PAGE_SIZE_OPTIONS, }: TablePaginationProps) { const totalPages = Math.max(1, Math.ceil(total / pageSize)); const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]); - - if (total === 0) return null; + const sizeOptions = pageSizeOptions.length > 0 ? pageSizeOptions : PAGE_SIZE_OPTIONS; return (
- + + 共 {total} 条 + -
- - - {pageItems.map((item, index) => ( - item === 'ellipsis' ? ( - - ) : ( + {total > 0 && ( + <> +
- ) - ))} - -
+ {pageItems.map((item, index) => ( + item === 'ellipsis' ? ( + + ) : ( + + ) + ))} + + +
+ + + + )}
); } diff --git a/src/common/modal-detail-table.css b/src/common/modal-detail-table.css index 21c4b85..3309a5f 100644 --- a/src/common/modal-detail-table.css +++ b/src/common/modal-detail-table.css @@ -76,9 +76,16 @@ .ln-modal-detail-table .ant-table-thead > tr > th.ln-modal-detail-table__money, .ln-modal-detail-table .ant-table-tbody > tr > td.ln-modal-detail-table__money { + text-align: left; font-variant-numeric: tabular-nums; } +.ln-modal-detail-table .ant-table-thead > tr > th.ant-table-cell-align-right, +.ln-modal-detail-table .ant-table-tbody > tr > td.ant-table-cell-align-right, +.ln-modal-detail-table .ant-table-summary .ant-table-cell-align-right { + text-align: left !important; +} + /* 左侧冻结列(收款时间等) */ .ln-modal-detail-table .ant-table-cell-fix-left, .ln-modal-detail-table .ant-table-cell-fix-start { diff --git a/src/common/oneosWebLegacy/OneosWebLegacyShell.tsx b/src/common/oneosWebLegacy/OneosWebLegacyShell.tsx index 7195802..51caac8 100644 --- a/src/common/oneosWebLegacy/OneosWebLegacyShell.tsx +++ b/src/common/oneosWebLegacy/OneosWebLegacyShell.tsx @@ -1,6 +1,6 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { ConfigProvider, Layout, Menu, Typography } from 'antd'; -import { defineHashPageRoute, useHashPage } from '../useHashPage'; +import { clearHostPrototypeRouteInfo, defineHashPageRoute, useHashPage } from '../useHashPage'; import { ensureOneosWebLegacyGlobals } from './legacyGlobals'; import '../../resources/oneos-web-legacy/styles/oneos-app.css'; @@ -28,13 +28,79 @@ const oneosTheme = { }, }; -export function OneosWebLegacyShell({ +function OneosWebLegacyPageLayout({ + children, + moduleTitle, + pages, + activePageId, + onSelectPage, +}: { + children: React.ReactNode; + moduleTitle: string; + pages: OneosWebLegacyPage[]; + activePageId?: string; + onSelectPage?: (pageId: string) => void; +}) { + const showMenu = pages.length > 1 && activePageId && onSelectPage; + + return ( + + + {showMenu ? ( + +
{moduleTitle}
+ ({ + key: item.id, + label: item.title, + onClick: () => onSelectPage(item.id), + }))} + /> + + ) : null} + + {children} + + + + ); +} + +function OneosWebLegacySinglePageShell({ pages }: OneosWebLegacyShellProps) { + const active = pages[0]; + + useEffect(() => { + clearHostPrototypeRouteInfo(); + }, []); + + if (!active) { + return ( + + 未配置可预览页面 + + ); + } + + const ActivePage = active.component; + + return ( + + + + ); +} + +function OneosWebLegacyMultiPageShell({ moduleTitle, pages, defaultPageId, }: OneosWebLegacyShellProps) { - ensureOneosWebLegacyGlobals(); - const route = useMemo( () => defineHashPageRoute( pages.map((page) => ({ id: page.id, title: page.title })), @@ -54,33 +120,25 @@ export function OneosWebLegacyShell({ } const ActivePage = active.component; - const showMenu = pages.length > 1; return ( - - - {showMenu ? ( - -
{moduleTitle}
- ({ - key: item.id, - label: item.title, - onClick: () => setPage(item.id), - }))} - /> - - ) : null} - - - - - + + + ); } + +export function OneosWebLegacyShell(props: OneosWebLegacyShellProps) { + ensureOneosWebLegacyGlobals(); + + if (props.pages.length <= 1) { + return ; + } + + return ; +} diff --git a/src/common/vehicle-brand-model-catalog.js b/src/common/vehicle-brand-model-catalog.js new file mode 100644 index 0000000..5edde92 --- /dev/null +++ b/src/common/vehicle-brand-model-catalog.js @@ -0,0 +1,67 @@ +/** + * 品牌·型号主数据:优先来自车辆管理种子数据,并与租赁合同演示枚举合并。 + */ +import vehicles from '../prototypes/vehicle-management/data/vehicles.json'; + +/** 租赁合同/条件条款演示用补充车型(车辆台账中可能尚未收录) */ +var LEASE_DEMO_EXTRA_CATALOG = [ + { brand: '现代', models: ['18吨氢燃料电池车', '9.6米厢式货车', '帕力安牌4.5吨冷链车'] }, + { brand: '苏龙', models: ['9.6米氢燃料电池车', '海格牌18吨双飞翼货车'] }, + { brand: '飞驰', models: ['集卡头', '半挂车'] }, + { brand: '跃进', models: ['4.2米冷链车', '4.5吨冷链车'] }, + { brand: '宇通', models: ['氢燃料电池客车'] }, +]; + +function mergeCatalogs() { + var map = new Map(); + var sources = arguments; + for (var s = 0; s < sources.length; s++) { + var list = sources[s] || []; + list.forEach(function (item) { + if (!item || !item.brand) return; + if (!map.has(item.brand)) map.set(item.brand, new Set()); + var models = item.models || []; + models.forEach(function (model) { + if (model) map.get(item.brand).add(model); + }); + }); + } + return Array.from(map.entries()) + .map(function (entry) { + return { + brand: entry[0], + models: Array.from(entry[1]).sort(function (a, b) { + return String(a).localeCompare(String(b), 'zh-CN'); + }), + }; + }) + .sort(function (a, b) { + return a.brand.localeCompare(b.brand, 'zh-CN'); + }); +} + +function buildFromVehicleManagement() { + var map = new Map(); + (vehicles || []).forEach(function (row) { + if (!row || !row.brand || !row.model) return; + if (!map.has(row.brand)) map.set(row.brand, new Set()); + map.get(row.brand).add(row.model); + }); + return Array.from(map.entries()).map(function (entry) { + return { + brand: entry[0], + models: Array.from(entry[1]).sort(function (a, b) { + return String(a).localeCompare(String(b), 'zh-CN'); + }), + }; + }); +} + +var cachedCatalog = null; + +export function getVehicleBrandModelCatalog() { + if (!cachedCatalog) { + cachedCatalog = mergeCatalogs(buildFromVehicleManagement(), LEASE_DEMO_EXTRA_CATALOG); + } + return cachedCatalog; +} diff --git a/src/common/vm-checkbox.css b/src/common/vm-checkbox.css new file mode 100644 index 0000000..22fe1f4 --- /dev/null +++ b/src/common/vm-checkbox.css @@ -0,0 +1,118 @@ +/** + * ONE-OS vm-page 多选框 — 全局样式 + * 规范:src/prototypes/vm-shared/DESIGN.md + */ + +.vm-checkbox, +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + display: inline-grid; + place-content: center; + width: 18px; + height: 18px; + margin: 0; + flex-shrink: 0; + border: 1.5px solid var(--ln-hairline-strong); + border-radius: 6px; + background-color: var(--ln-surface-card); + background-repeat: no-repeat; + background-position: center; + box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.05); + cursor: pointer; + vertical-align: middle; + transition: + border-color 0.18s ease, + background-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.14s cubic-bezier(0.34, 1.3, 0.64, 1); +} + +.vm-checkbox:hover:not(:disabled), +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--ln-primary) 55%, var(--ln-hairline-strong)); + box-shadow: + inset 0 1px 2px rgba(15, 23, 42, 0.04), + 0 0 0 3px color-mix(in srgb, var(--ln-primary) 14%, transparent); +} + +.vm-checkbox:active:not(:disabled), +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:active:not(:disabled) { + transform: scale(0.92); +} + +.vm-checkbox:checked, +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:checked { + border-color: var(--ln-primary-focus); + background-color: var(--ln-primary); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.75 8.25 6.5 11 12.25 5'/%3E%3C/svg%3E"); + background-size: 13px 13px; + box-shadow: + 0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.vm-checkbox:indeterminate, +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:indeterminate { + border-color: var(--ln-primary-focus); + background-color: var(--ln-primary); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round'%3E%3Cpath d='M4.25 8h7.5'/%3E%3C/svg%3E"); + background-size: 13px 13px; + box-shadow: + 0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.vm-checkbox:focus-visible, +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: + inset 0 1px 2px rgba(15, 23, 42, 0.04), + 0 0 0 3px color-mix(in srgb, var(--ln-primary) 28%, transparent); +} + +.vm-checkbox:checked:focus-visible, +.vm-checkbox:indeterminate:focus-visible, +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:checked:focus-visible, +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:indeterminate:focus-visible { + box-shadow: + 0 1px 2px color-mix(in srgb, var(--ln-primary) 28%, transparent), + 0 0 0 3px color-mix(in srgb, var(--ln-primary) 28%, transparent); +} + +.vm-checkbox:disabled, +.vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* 表格勾选列 */ +.vm-col-check, +.ldb-col-check, +.cm-col-check { + width: 48px; + text-align: center; + vertical-align: middle; +} + +.vm-col-check .vm-checkbox, +.ldb-col-check .vm-checkbox, +.cm-col-check .vm-checkbox, +.vm-col-check > input[type="checkbox"], +.ldb-col-check > input[type="checkbox"], +.cm-col-check > input[type="checkbox"] { + display: inline-grid; +} + + +@media (prefers-reduced-motion: reduce) { + .vm-checkbox, + .vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"] { + transition: none; + } + + .vm-checkbox:active:not(:disabled), + .vm-page :is(.vm-col-check, .ldb-col-check, .cm-col-check) > input[type="checkbox"]:active:not(:disabled) { + transform: none; + } +} diff --git a/src/common/vm-filter-panel.ts b/src/common/vm-filter-panel.ts new file mode 100644 index 0000000..979b35f --- /dev/null +++ b/src/common/vm-filter-panel.ts @@ -0,0 +1,29 @@ +/** + * 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致) + * + * 规则:筛选项超过 1 行(默认 4 项)时,其余收入折叠区,通过「更多筛选」展开。 + */ +export const VM_FILTER_COLUMNS_PER_ROW = 4; + +/** 默认首行展示的筛选项数量(1 行) */ +export const VM_FILTER_PRIMARY_VISIBLE_COUNT = VM_FILTER_COLUMNS_PER_ROW; + +export function splitFilterFields( + fields: T[], + primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT, +): { primary: T[]; extra: T[] } { + if (fields.length <= primaryCount) { + return { primary: fields, extra: [] }; + } + return { + primary: fields.slice(0, primaryCount), + extra: fields.slice(primaryCount), + }; +} + +export function shouldShowFilterExpand( + fieldCount: number, + primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT, +): boolean { + return fieldCount > primaryCount; +} diff --git a/src/common/vm-pagination.css b/src/common/vm-pagination.css index 244d809..35bca65 100644 --- a/src/common/vm-pagination.css +++ b/src/common/vm-pagination.css @@ -2,6 +2,7 @@ * ONE-OS vm-page 列表分页 — 全局样式 * 规范:src/prototypes/vm-shared/DESIGN.md * 组件:src/common/TablePagination.tsx + * 元素顺序:共 x 条 → 页码 → 每页 N 条 */ .vm-pagination { @@ -10,7 +11,9 @@ align-items: center; flex-wrap: wrap; gap: 12px 16px; - width: 100%; + width: auto; + flex: 0 1 auto; + margin-left: auto; padding: 0; } @@ -23,6 +26,18 @@ flex-shrink: 0; } +.vm-pagination-total { + font-size: 0.875rem; + color: var(--ln-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.vm-pagination-total-num { + color: var(--ln-ink); + font-weight: 600; +} + .vm-pagination-size-label { white-space: nowrap; } @@ -65,31 +80,41 @@ color: var(--ln-ink); font-size: 0.875rem; font-weight: 500; + font-variant-numeric: tabular-nums; cursor: pointer; - transition: background 0.2s, border-color 0.2s, color 0.2s; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease; } .vm-pagination-nav:disabled { - opacity: 0.45; + opacity: 0.4; cursor: not-allowed; + background: var(--ln-canvas-soft); } .vm-pagination-nav:hover:not(:disabled), .vm-pagination-page:hover:not(.active) { background: var(--ln-canvas-soft); - border-color: var(--ln-hairline-strong); + border-color: var(--ln-primary); + color: var(--ln-primary-focus); +} + +.vm-pagination-nav:active:not(:disabled), +.vm-pagination-page:active:not(.active) { + transform: scale(0.96); } .vm-pagination-page.active { background: var(--ln-primary); border-color: var(--ln-primary); color: var(--ln-on-primary); + box-shadow: 0 1px 3px rgba(37, 99, 235, 0.28); } .vm-pagination-nav:focus-visible, .vm-pagination-page:focus-visible { outline: none; border-color: var(--vm-focus-border-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); } .vm-pagination-ellipsis { @@ -100,6 +125,20 @@ height: var(--vm-control-height); color: var(--ln-muted); font-size: 0.875rem; + letter-spacing: 0.08em; + user-select: none; +} + +@media (prefers-reduced-motion: reduce) { + .vm-pagination-nav, + .vm-pagination-page { + transition: none; + } + + .vm-pagination-nav:active:not(:disabled), + .vm-pagination-page:active:not(.active) { + transform: none; + } } /* 详情 Tab 等紧凑场景:vm-table-footer--split vm-table-footer--compact */ @@ -136,3 +175,7 @@ height: 28px; font-size: 0.8125rem; } + +.vm-table-footer--compact .vm-pagination-total { + font-size: 0.8125rem; +} diff --git a/src/prototypes/contract-template-management/.spec/prototype-review.md b/src/prototypes/contract-template-management/.spec/prototype-review.md index e857e72..0717768 100644 --- a/src/prototypes/contract-template-management/.spec/prototype-review.md +++ b/src/prototypes/contract-template-management/.spec/prototype-review.md @@ -2,53 +2,107 @@ - 审查目标:`src/prototypes/contract-template-management` - 用户资料/参考资料: - - 本轮用户任务:处理评审结论中 P1–P3 共 5 条问题 + - 本轮用户任务:对「合同模板管理」原型执行 Prototype Review / 需求评审 - `src/resources/contract-template-management/PRD.md`(v1.2) - - `src/prototypes/contract-template-management/annotation-source.json` - - `contract-template-seed.js`、`contract-template-section-html.js`、`contract-template-catalog.js`、`contract-template-store.js` - - 关联下游:`lease-contract-preview-build.js`、`LeaseContractCreate.jsx` -- 生成时间:2026-06-29(复审) + - `src/prototypes/contract-template-management/index.tsx`、`ContractTemplate.jsx` + - `contract-template-store.js`、`contract-template-catalog.js`、`contract-template-section-html.js`、`contract-template-section-filter.js`、`contract-template-seed.js` + - 下游消费:`lease-contract-management/lease-contract-preview-build.js`、`LeaseContractCreate.jsx` + - `annotation-source.json`、`.spec/prototype-comments.json` +- 生成时间:2026-07-07 ## 总体点评 -上一轮评审中的 **5 条优先级问题均已落地**。合同模板管理原型现已具备: +合同模板管理原型在**主业务链路上已基本成立**:列表筛选与嵌套章节子表、草稿/发布/启用停用、版本日志与快照查看、编辑页 9 章节 Tab、锁定/风控/条件条款工具、离开未保存确认,以及经 `contract-template-store` 向租赁合同创建贯通预览,均能在同浏览器 `localStorage` 会话内端到端演示。这与 PRD v1.2 对「Word 文档级模型 + 启用态控制 + 跨模块消费」的定位一致。 -- 租赁创建预览优先消费 store 中 `sectionContents`(静态 HTML 仅兜底) -- 冷启动种子:直接进入租赁创建即可选用已启用模板 -- PRD / 标注与实现对齐(`contractType`、列表筛选、子表列、章节 Tab 交互) -- `localStorage` 持久化(兼写 `sessionStorage`) -- 移除 `isDefault` /「标准模板」残留逻辑 +当前最影响「按真实法务作业验收」的缺口集中在 **Word 导入仍为文件名驱动的 Mock**,无法证明 PRD §9 对任意 `.doc/.docx` 的拆分策略;其次是 **标注/内嵌 PRD 与 v1.2 字段口径仍有残留不一致**,以及租赁预览侧 **条件条款存在模板 HTML 与硬编码注入双轨**。权限、存储失败、正式 API 与异常分支属原型已知边界,应在研发阶段补齐,但不阻断当前演示型原型价值。 -原型可作为研发与测试的**端到端演示基线**;正式环境仍需 API 持久化、权限与异常分支。 +## P0-P3 优先级问题 -## P0-P3 优先级问题(复审) +### P1 - Word 导入未实现真实解析与章节拆分 -| 级别 | 问题 | 状态 | -|---|---|---| -| P1 | 租赁预览未消费 store 章节正文 | **已修复** — `getContractTemplateBaseHtml` 优先 `mergeMonolithicHtml(doc.sectionContents)` | -| P1 | PRD / 标注与实现字段口径不一致 | **已修复** — PRD v1.2 + `build-annotation-source.mjs` 同步 | -| P1 | 冷启动租赁创建无模板 | **已修复** — `catalog.js` 调用 `ensureContractTemplateStore(buildInitialWordTemplates)` | -| P2 | 仅 sessionStorage 持久化 | **已缓解** — store 读写 `localStorage` 优先 | -| P3 | `isDefault` / 标准模板残留 | **已修复** — 种子、store 默认逻辑、编辑区文案已清理 | +- 证据:`ContractTemplate.jsx` 中 `handleImportWord` 仅调用 `mockWordBlocks(file.name, categoryFilter)`,按文件名关键字返回内置 `v266LeaseBlocks` / 试用合同块或通用占位块,未读取上传文件内容,也未调用 PRD §9 所述 `sanitizeWordExportHtml` + 标记切分流程。 +- 影响:法务无法在本原型中验证「上传任意 Word → 自动拆成 9 章」这一核心维护路径;验收标准 §16.2「导入 Word 后 9 章节正确拆分」仅对少数种子文件名成立,与 PRD 表述的业务成立条件存在明显差距。 +- 修复方向:原型层至少接入 `.docx → HTML` 解析(或明确标注为演示 Mock 并下调 PRD 验收项);研发按 §9.2–§9.4 实现服务端/前端拆分,并在导入失败时给出可恢复提示(重试、保留原稿、查看错误原因)。 + +### P2 - 标注与内嵌 PRD 仍使用「合同名称」,与 v1.2「合同类型」冲突 + +- 证据:`annotation-source.json` 列表筛选节点 `ct-list-filter` 的 `annotationText` 与 `markdownMap.ct-list-filter` 仍写「合同名称 / 选项来自模板名称」;而 `PRD.md` v1.2 §7.2、§7.4 与实现中筛选字段 `keyword`、列表列 `contractType`、编辑区「合同类型」均已统一为 **合同类型**。 +- 影响:评审、测试与业务方按标注操作时会对筛选对象产生误解,造成需求口径分裂;与 PRD §16.4「标注与 PRD 一致」未完全满足。 +- 修复方向:运行或更新 `scripts/build-annotation-source.mjs`,将筛选说明改为「合同类型(可搜索下拉,精确匹配 `contractType`)」;同步目录内嵌 PRD 片段版本号至 v1.2。 + +### P2 - 租赁预览条件条款存在「模板 HTML」与硬编码注入双轨 + +- 证据:`lease-contract-preview-build.js` 在读取 store 合并 HTML 后仍调用 `injectPrototypeVehicleClauses`;该函数在 `lease-contract-vehicle-clause.js` 中按 `PROTOTYPE_VEHICLE_CLAUSE_DEFS` 与固定 `paragraphMarker` 包裹段落,注释写明「正式环境由模板管理产出」。模板管理侧条件条款则通过编辑区「条件」写入 `data-vehicle-clause` / `data-vehicle-bindings`。 +- 影响:法务在模板管理中新设或调整条件条款后,租赁预览对标准 V26.6 类合同可能仍部分依赖硬编码注入点;与 PRD「创建合同时按订单车辆过滤条件条款」的单一数据源目标不完全一致,增加联调与回归风险。 +- 修复方向:租赁预览改为**仅**解析模板 HTML 中的条件条款标记;硬编码注入降级为种子数据初始化脚本或 feature flag,并在跨模块验收用例中覆盖「仅改模板、不改租赁代码」场景。 + +### P2 - 车型主数据未与车辆管理模块打通 + +- 证据:PRD §11 要求品牌·型号与车辆管理一致;实现中 `ContractTemplate.jsx` / `VehicleBrandModelPicker.jsx` 使用 `LEASE_VEHICLE_BRAND_MODEL_CATALOG`(`lease-order-vars.js` 内置枚举),未读取 `vehicle-management` 主数据或接口。 +- 影响:章节「适用车型」绑定与预览模拟车型的选项集可能与正式车辆台账漂移;绑定存的是字符串,正式环境若车型更名会导致历史模板命中失败。 +- 修复方向:原型可保留内置枚举但需在 PRD/标注标明;研发对接车辆主数据 API,并约定品牌型号唯一键与变更策略。 + +### P3 - 权限边界与存储异常未在原型中表达 + +- 证据:PRD §13 建议法务可编辑/发布、客服只读等角色能力;原型无角色切换或操作拦截。`contract-template-store.js` 的 `writeToStorage` 在 `localStorage` 失败时静默忽略,无用户提示或降级策略。 +- 影响:无法演示「启用中不可编辑」之外的组织级权限;极端环境下用户以为已保存但实际未持久化,造成跨页面数据丢失误解。 +- 修复方向:原型可增加「法务 / 客服只读」演示开关;存储失败时 `message.error` 并阻止离开编辑页;正式环境对接权限服务与后端持久化。 ## 完整性与项目对齐 -| 维度 | 评估 | +| 维度 | 结论 | |---|---| -| 跨模块正文 | 模板管理编辑 ↔ 租赁创建预览:**已贯通**(同会话 / localStorage 内) | -| 冷启动 | 无需先打开模板管理:**已支持** | -| 资料一致性 | PRD、标注、列表筛选/列、子表、§8.3 Tab:**已对齐** | -| 正式环境差距 | API 持久化、角色权限、Word 解析失败等异常分支仍待研发 | +| 核心用户 | 法务为主、客服/风控间接用户 — 与 PRD §2 一致 | +| 主流程 | 列表 → 新增/编辑 → 保存/发布 → 启用/停用 → 租赁创建选用 — **可演示** | +| 入口/出口 | `index.tsx` + `ContractTemplate.jsx` 列表/编辑双视图;`navigationRef` 与标注目录可切换 — 对齐 | +| 范围边界 | 仅 `lease` 分类业务闭环;`purchase`/`service` 代码预留未暴露 — 符合非目标 | +| 跨模块 | `contract-template-catalog.js` 冷启动种子、`getContractTemplateBaseHtml` 优先 store — 与 PRD §12、§16.3 对齐 | +| 资料冲突 | **待确认**:`annotation-source.json` 筛选字段仍称「合同名称」,与 PRD v1.2 / 源码「合同类型」冲突(见 P2) | +| 项目 metadata | 路由 `/prototypes/contract-template-management`;与 `lease-contract-management` PRD 关联描述一致 | -## 验收建议(请用户在预览中确认) +**已覆盖且与 PRD 一致的主要能力(摘要)**:嵌套列表 9 章节子表及风控/条件条款计数;启用中锁定编辑删除、停用二次确认;发布递增版本号、已发布再发布写 `versionLogs` 且默认停用;合同主体非空与合同类型校验;未保存离开确认;版本快照只读查看;附件章节车型绑定与租赁侧 `applyAttachmentSectionVehicleFilter` 联动。 -1. **租赁预览贯通**:在模板管理编辑某章节并保存 → 打开租赁创建 → 选用同一模板 → 预览正文应反映修改。 -2. **冷启动**:清空站点 localStorage 后**直接**打开租赁创建 → 应能选择已启用模板(如「正式合同」「试用合同」)。 -3. **列表筛选**:列表页「合同类型」筛选与主表「合同类型」列一致。 -4. **持久化**:刷新浏览器后模板停用/发布状态应保留(localStorage)。 +## 业务逻辑连贯性 -## 修订记录 +- **状态迁移**:草稿(无版本号)→ 发布(有版本号、默认启用)→ 停用(可编辑)→ 再发布(版本递增、写快照、默认停用)— 与 PRD §6.2–§6.3 一致,源码 `handleSaveTemplate` / `toggleTemplateEnabled` 可印证。 +- **启用与下游**:`getPublishedEnabledLeaseOptions` 过滤 `published && enabled`;停用后 `LeaseContractCreate` 订阅 store 更新选项 — 连贯。 +- **版本号生成**:`generateNextVersionNo` 按 `category === lease` 下全部已发布模板取最大 minor 递增 — 符合 PRD §6.1「同分类下递增」字面规则;若业务期望「每份 Word 文件独立版本序列」,需产品另行确认(当前无冲突证据,记为待确认项)。 +- **条件条款命中**:模板编辑预览与租赁预览均遵循「订单车辆任意命中则展示」;模板管理预览未选模拟车型时隐藏条件段落,与 PRD §8.7.3 一致。 +- **数据生命周期**:`creator/updater/createTime/updateTime` 在保存时更新;删除仅草稿或已发布且停用 — 符合 §6.5。 -| 日期 | 说明 | +## 状态、异常、边界与恢复 + +| 类别 | 审查结论 | |---|---| -| 2026-06-29 | 复审:5 条评审问题全部处理完毕 | +| 输入校验 | 保存/发布校验合同主体非空、合同类型必填;条件条款弹窗要求至少一个车型 — 已覆盖 | +| 空状态 | 列表无数据、筛选无结果、版本日志为空、编辑区无内容预览引导 — 已覆盖 | +| 操作确认 | 停用、删除、未保存离开 — 有 Modal 确认 | +| 权限失败 | 未实现角色权限 — 见 P3 | +| 导入失败 | 非真实 Word 解析,无格式错误/拆分失败分支 — 见 P1 | +| 存储失败 | `localStorage` 写入失败静默 — 见 P3 | +| 并发/重复点击 | 保存无 loading 锁,快速连点可能重复写入(原型风险低)— P3 | +| 跨页面恢复 | 刷新后 `localStorage` 恢复模板;清空存储后 `ensureContractTemplateStore` 重新种子 — 可恢复 | +| 下游一致性 | 停用模板后租赁侧选项实时收缩;预览读 store `sectionContents` — 已检查通过 | + +## 证据与评估说明 + +- **用户资料优先级**:本轮无额外用户附件;以 PRD v1.2 为业务基线,与 `annotation-source.json` 冲突处已标「待确认」。 +- **读取范围**: + - 规范:`rules/prototype-review-guide.md` + - 资料:`src/resources/contract-template-management/PRD.md` + - 源码:`index.tsx`、`ContractTemplate.jsx`(列表/编辑/保存发布/导入/版本日志)、`contract-template-store.js`、`contract-template-catalog.js`、`contract-template-section-filter.js` + - 下游:`lease-contract-preview-build.js`、`lease-contract-vehicle-clause.js`、`LeaseContractCreate.jsx` + - 标注:`annotation-source.json`;未展开阅读 `canvas.excalidraw` 全量 +- **独立评估**:`degraded`(未使用子代理分拆「业务基线」与「原型证据」两轮独立评估,在同一审查中顺序完成) + +--- + +## 修复记录(2026-07-07 后续) + +| 优先级 | 问题 | 状态 | 说明 | +|---|---|---|---| +| P1 | Word 导入 Mock | **已修复** | `contract-template-word-import.js` + mammoth 解析 `.docx`;失败回退演示模板并提示 | +| P2 | 标注「合同名称」口径 | **已修复** | `build-annotation-source.mjs` 同步 `annotationText` / `annotationsByElementId` / `markdownMap` | +| P2 | 租赁预览条件条款双轨 | **已修复** | `injectPrototypeVehicleClauses` 在 HTML 已有条件标记时跳过 | +| P2 | 车型主数据未打通 | **已修复(原型)** | `src/common/vehicle-brand-model-catalog.js` 读取车辆管理种子并合并演示车型 | +| P3 | 权限与存储异常 | **已修复(原型)** | `?role=cs` 只读演示;存储失败 `message.error` 且阻止离开编辑页 | diff --git a/src/prototypes/contract-template-management/ContractTemplate.jsx b/src/prototypes/contract-template-management/ContractTemplate.jsx index 03920b7..82d2af6 100644 --- a/src/prototypes/contract-template-management/ContractTemplate.jsx +++ b/src/prototypes/contract-template-management/ContractTemplate.jsx @@ -6,21 +6,48 @@ import { getContractTemplates, setContractTemplates, subscribeContractTemplates, + wasLastContractTemplateWriteOk, } from './contract-template-store.js'; import { buildInitialWordTemplates } from './contract-template-seed.js'; import { - getContractTypeLabel, - normalizeContractType, - resolveContractType, + convertDocxArrayBufferToHtml, + convertLegacyDocFileToHtml, + isDocxArrayBuffer, + isImportedWordDocHtml, + prepareImportedWordHtml, + sanitizeImportedWordHtml, +} from './contract-template-word-import.js'; +import { renderDocxPreview } from './contract-template-docx-fidelity.js'; +import { + getContractNameLabel, + getDocumentContractTypeLabel, + getDocumentContractTypeSelectOptions, + isDocumentContractTypeEnum, + normalizeContractName, + normalizeDocumentContractType, + resolveContractName, + resolveDocumentContractType, } from './contract-template-types.js'; import { LEASE_VEHICLE_BRAND_MODEL_CATALOG } from '../lease-contract-management/lease-order-vars.js'; -import VehicleBrandModelPicker from './VehicleBrandModelPicker.jsx'; +import { + extractSectionTabsFromHtml, + getSectionTabLabel as getDynamicSectionTabLabel, + mergeHtmlBySectionTabs, + resolveSectionTabs, + splitHtmlBySectionTabs, + emptySectionMapForTabs, +} from './contract-template-section-headings.js'; import { vehicleBindingKey, parseVehicleBindingKey, vehicleMatchesBindings, formatVehicleBindings, } from './vehicle-binding-utils.js'; +import { + shouldShowFilterExpand, + splitFilterFields, + VM_FILTER_PRIMARY_VISIBLE_COUNT, +} from '../../common/vm-filter-panel.ts'; var DEFAULT_LESSOR_COMPANIES = [ @@ -528,6 +555,9 @@ const Component = function (props) { function prepareWordHtml(html) { var content = String(html || ''); if (!content) return ''; + if (isImportedWordDocHtml(content)) { + return prepareImportedWordHtml(content); + } if (isWordFormattedDoc(content)) { content = sanitizeWordExportHtml(content); content = ensureV266WordStyles(content); @@ -559,6 +589,9 @@ const Component = function (props) { function prepareEditorHtml(html) { var content = String(html || ''); if (!content) return ''; + if (isImportedWordDocHtml(content)) { + return prepareImportedWordHtml(content); + } if (isWordFormattedDoc(content)) { content = sanitizeWordExportHtml(content); content = ensureV266WordStyles(content); @@ -567,8 +600,19 @@ const Component = function (props) { return content; } + function resolveWordDocShellClass(html) { + if (isImportedWordDocHtml(html)) return 'ct-word-doc ct-word-doc--imported'; + if (/ct-word-doc--v266/.test(String(html || ''))) return 'ct-word-doc ct-word-doc--v266'; + return 'ct-word-doc'; + } + + function resolveWordPageBodyClass(html, extra) { + return 'ct-word-a4-page__body ' + resolveWordDocShellClass(html) + ' ct-rich-editor ct-rich-editor--word' + (extra || ''); + } + function ensureV266WordStyles(html) { var content = String(html || ''); + if (isImportedWordDocHtml(content)) return content; if (!isWordFormattedDoc(content) || typeof document === 'undefined') return content; var cssText = ''; @@ -600,7 +644,7 @@ const Component = function (props) { var source = String(html || ''); if (!isWordFormattedDoc(source)) return false; var sections = splitHtmlIntoSections(source); - return DOCUMENT_SECTION_TABS.slice(1).some(function (tab) { + return getSectionTabs().slice(1).some(function (tab) { return !!(sections[tab.key] && String(sections[tab.key]).trim()); }); } @@ -672,8 +716,9 @@ const Component = function (props) { var LIST_EXPAND_COL_WIDTH = 48; function getActiveSectionTabIndex() { - for (var i = 0; i < DOCUMENT_SECTION_TABS.length; i++) { - if (DOCUMENT_SECTION_TABS[i].key === activeSectionKey) return i; + var tabs = getSectionTabs(); + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].key === activeSectionKey) return i; } return 0; } @@ -716,9 +761,7 @@ const Component = function (props) { } function emptySectionMap() { - var map = {}; - DOCUMENT_SECTION_TABS.forEach(function (tab) { map[tab.key] = ''; }); - return map; + return emptySectionMapForTabs(getSectionTabs()); } function isWordBlockNode(node) { @@ -829,6 +872,9 @@ const Component = function (props) { function splitHtmlIntoSections(html) { var source = String(html || ''); if (!source.trim()) return emptySectionMap(); + if (editorSectionTabs && editorSectionTabs.length > 1) { + return splitHtmlBySectionTabs(source, editorSectionTabs); + } if (typeof document === 'undefined') return splitMonolithicHtml(source); var div = document.createElement('div'); div.innerHTML = source; @@ -847,17 +893,11 @@ const Component = function (props) { } function mergeMonolithicHtml(sectionMap) { - var map = sectionMap || {}; - var out = String(map.main || ''); - DOCUMENT_SECTION_TABS.slice(1).forEach(function (tab) { - var chunk = map[tab.key]; - if (chunk) out += String(chunk); - }); - return out; + return mergeHtmlBySectionTabs(sectionMap, getSectionTabs()); } function mergeSectionsToDocument(sectionMap) { - return DOCUMENT_SECTION_TABS.map(function (tab) { + return getSectionTabs().map(function (tab) { var content = (sectionMap && sectionMap[tab.key]) ? String(sectionMap[tab.key]) : ''; if (!content.trim()) return ''; return '
' + content + '
'; @@ -865,8 +905,7 @@ const Component = function (props) { } function getSectionTabLabel(key) { - var tab = DOCUMENT_SECTION_TABS.find(function (item) { return item.key === key; }); - return tab ? tab.label : key; + return getDynamicSectionTabLabel(getSectionTabs(), key); } var VEHICLE_BRAND_MODEL_CATALOG = LEASE_VEHICLE_BRAND_MODEL_CATALOG; @@ -920,12 +959,31 @@ const Component = function (props) { } function createEmptyListFilter() { - return { keyword: '', status: '', creator: '', enabled: '' }; + return { keyword: '', contractType: '', status: '', creator: '', enabled: '' }; + } + + function hasExtraListFiltersActive(filters) { + return !!(filters && filters.enabled); + } + + function renderFilterChevronIcon(open) { + return React.createElement('svg', { + width: 14, + height: 14, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true, + className: 'vm-filter-toggle-icon' + (open ? ' is-open' : ''), + }, React.createElement('path', { d: 'M6 9l6 6 6-6' })); } function hasActiveListFilters(filters) { if (!filters) return false; - return !!(filters.keyword || filters.status || filters.creator || filters.enabled); + return !!(filters.keyword || filters.contractType || filters.status || filters.creator || filters.enabled); } function filterContractNameOption(input, option) { @@ -1008,7 +1066,8 @@ const Component = function (props) { function createWordDocument(params) { return Object.assign({ category: 'lease', - contractType: '', + contractName: '', + contractType: '合同', fileName: '未命名合同.docx', versionName: '未命名合同', versionNo: null, @@ -1039,7 +1098,8 @@ const Component = function (props) { } function buildDocumentChapterRows(doc) { - return DOCUMENT_SECTION_TABS.map(function (tab) { + var tabs = resolveSectionTabs(doc); + return tabs.map(function (tab) { var html = (doc.sectionContents && doc.sectionContents[tab.key]) || ''; var bindings = getDocumentSectionBindings(doc, tab.key); var hasContent = !!(String(html).trim()); @@ -1058,7 +1118,7 @@ const Component = function (props) { function documentHasVehicleBindingMatch(doc, brand, model) { if (!brand && !model) return true; var hasBindings = false; - var matched = DOCUMENT_SECTION_TABS.some(function (tab) { + var matched = resolveSectionTabs(doc).some(function (tab) { if (!sectionAllowsVehicleBinding(tab.key)) return false; var bindings = getDocumentSectionBindings(doc, tab.key); if (!bindings.length) return false; @@ -1073,8 +1133,12 @@ const Component = function (props) { if (!filters) return true; var keyword = String(filters.keyword || '').trim(); if (keyword) { - var contractType = String(resolveContractType(doc) || ''); - if (contractType !== keyword) return false; + var contractName = String(resolveContractName(doc) || ''); + if (contractName !== keyword) return false; + } + if (filters.contractType) { + var docType = resolveDocumentContractType(doc); + if (docType !== filters.contractType) return false; } if (filters.status && doc.status !== filters.status) return false; if (filters.creator && doc.creator !== filters.creator) return false; @@ -1195,6 +1259,11 @@ const Component = function (props) { }, []); function setTemplates(updater) { setContractTemplates(updater); + var storageOk = wasLastContractTemplateWriteOk(); + storageWriteFailedState[1](!storageOk); + if (!storageOk) { + message.error('本地存储失败,修改可能无法持久化,请清理浏览器缓存后重试'); + } } var categoryFilterState = useState('lease'); @@ -1233,11 +1302,32 @@ const Component = function (props) { var editingRecord = editingRecordState[0]; var setEditingRecord = editingRecordState[1]; - var editorContractTypeState = useState(''); - var editorContractType = editorContractTypeState[0]; - var setEditorContractType = editorContractTypeState[1]; + var editorContractNameState = useState(''); + var editorContractName = editorContractNameState[0]; + var setEditorContractName = editorContractNameState[1]; + + var editorDocumentContractTypeState = useState('合同'); + var editorDocumentContractType = editorDocumentContractTypeState[0]; + var setEditorDocumentContractType = editorDocumentContractTypeState[1]; + + var editorSectionTabsState = useState(null); + var editorSectionTabs = editorSectionTabsState[0]; + var setEditorSectionTabs = editorSectionTabsState[1]; + + function hasEditorWordImported() { + if (editorMode === 'edit' && editingRecord) return true; + return !!importChangedInSessionState[0]; + } + + function getSectionTabs() { + if (editorSectionTabs && editorSectionTabs.length) return editorSectionTabs; + if (!hasEditorWordImported()) return []; + if (!String(formDocumentHtmlState[0] || '').trim()) return []; + return DOCUMENT_SECTION_TABS; + } var lastImportFileNameState = useState(''); + var importedDocxBufferState = useState(null); var importChangedInSessionState = useState(false); var formBlocksState = useState([]); var formDocumentHtmlState = useState(''); @@ -1250,6 +1340,7 @@ const Component = function (props) { var versionLogTargetState = useState(null); var snapshotViewState = useState(null); var editorDirtyState = useState(false); + var storageWriteFailedState = useState(false); var leaveEditorModalState = useState(false); var editorSyncKeyState = useState(0); var editorInsertHandlerRef = useRef(null); @@ -1285,6 +1376,9 @@ const Component = function (props) { var listFilterAppliedState = useState(createEmptyListFilter); var listFilterApplied = listFilterAppliedState[0]; var setListFilterApplied = listFilterAppliedState[1]; + var listFilterExpandedState = useState(function () { return false; }); + var listFilterExpanded = listFilterExpandedState[0]; + var setListFilterExpanded = listFilterExpandedState[1]; var editorScrollElRef = useRef(null); var previewScrollElRef = useRef(null); var scrollSyncLockRef = useRef(false); @@ -1293,6 +1387,12 @@ const Component = function (props) { var workspaceZoom = workspaceZoomState[0]; var setWorkspaceZoom = workspaceZoomState[1]; + useEffect(function () { + if (hasExtraListFiltersActive(listFilterDraft) || hasExtraListFiltersActive(listFilterApplied)) { + setListFilterExpanded(true); + } + }, [listFilterDraft.enabled, listFilterApplied.enabled]); + function attachWorkspaceScrollSync(el) { if (!el || el.dataset.ctScrollSyncBound === '1') return; el.dataset.ctScrollSyncBound = '1'; @@ -1325,6 +1425,14 @@ const Component = function (props) { var CURRENT_USER = '法务-当前用户'; + var IS_READ_ONLY_ROLE = typeof window !== 'undefined' && (function () { + var role = new URLSearchParams(window.location.search).get('role'); + return role === 'cs' || role === 'readonly'; + })(); + + function isPrototypeReadOnly() { + return IS_READ_ONLY_ROLE; + } var pageStyle = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.vm-page.ct-page,.ct-page{color:var(--ln-body)}' + @@ -1413,6 +1521,8 @@ const Component = function (props) { '.ct-rich-editor--word .ct-word-doc--v266 style{display:none!important}' + '.ct-rich-editor:focus{border-color:var(--ln-primary);box-shadow:inset 0 0 0 1px var(--ln-primary)}' + '.ct-rich-editor.is-readonly{background:#f8fafc;color:#475569;cursor:default}' + + '.ct-readonly-banner{padding:10px 16px;margin:0 0 12px;background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;color:#9a3412;font-size:13px;line-height:1.5}' + + '.ct-readonly-hint{font-size:13px;color:#64748b}' + '.ct-rich-editor img{max-width:100%;height:auto;margin:8px 0;border-radius:4px;display:block}' + '.ct-rich-wrap.is-fullscreen{position:fixed;inset:0;z-index:1100;background:#fff;padding:12px;display:flex;flex-direction:column}' + '.ct-rich-wrap.is-fullscreen .ct-rich-editor{max-height:none;min-height:0;flex:1}' + @@ -1423,7 +1533,7 @@ const Component = function (props) { '.ct-preview-doc .ct-page-break{border-top-color:#94A3B8}' + '.ct-preview-doc.ct-word-doc,.ct-preview-doc .ct-word-doc{font-family:FangSong,STFangsong,"仿宋",SimSun,"宋体",serif;font-size:10.5pt;line-height:1.5;color:#000;letter-spacing:0}' + '.ct-preview-doc.ct-word-doc{padding:48px 56px 56px}' + - '.ct-word-doc:not(.ct-word-doc--v266) p{margin:0 0 4px}' + + '.ct-word-doc:not(.ct-word-doc--v266):not(.ct-word-doc--imported) p{margin:0 0 4px}' + '.ct-word-title{text-align:center;font-size:18pt;font-weight:700;margin:0 0 10px!important;line-height:1.4}' + '.ct-word-contract-no{text-align:right;margin:0 0 14px!important;padding-right:1em;font-size:10.5pt}' + '.ct-word-party-line{margin:0!important;line-height:1.8;font-size:10.5pt}' + @@ -1476,10 +1586,11 @@ const Component = function (props) { '.ct-preview-pages{display:flex;flex-direction:column;align-items:center;gap:20px;width:100%;box-sizing:border-box;padding:4px 5px 12px}' + '.ct-preview-page{width:210mm;max-width:calc(100% - 10px);min-height:297mm;padding:25.4mm 31.8mm;box-sizing:border-box;background:#fff;border:1px solid #d9d9d9;box-shadow:0 2px 12px rgba(0,0,0,0.12);position:relative;flex-shrink:0;margin-inline:0}' + '.ct-preview-page__body{width:100%;overflow:hidden;word-break:break-word}' + + '.ct-preview-page__body.ct-word-doc--imported{overflow:visible;word-break:normal}' + '.ct-preview-page__footer{position:absolute;bottom:10mm;left:0;right:0;text-align:center;font-size:9pt;color:#999;font-family:FangSong,STFangsong,"仿宋",SimSun,"宋体",serif}' + '.ct-preview-page.is-loading{min-height:120px;display:flex;align-items:center;justify-content:center;color:#94A3B8;font-size:13px;width:210mm}' + '.ct-preview-panel--word{background:#b8b8b8;padding:0}' + - '.ct-rich-editor .ct-word-doc:not(.ct-word-doc--v266),.ct-rich-editor .ct-word-doc:not(.ct-word-doc--v266){font-family:FangSong,STFangsong,"仿宋",SimSun,"宋体",serif;font-size:10.5pt;line-height:1.5;color:#000}' + + '.ct-rich-editor .ct-word-doc:not(.ct-word-doc--v266):not(.ct-word-doc--imported),.ct-rich-editor .ct-word-doc:not(.ct-word-doc--v266):not(.ct-word-doc--imported){font-family:FangSong,STFangsong,"仿宋",SimSun,"宋体",serif;font-size:10.5pt;line-height:1.5;color:#000}' + '.ct-rich-editor .ct-word-title{text-align:center;font-size:18pt;font-weight:700}' + '.ct-rich-editor .ct-word-contract-no{text-align:right;padding-right:1em}' + '.ct-rich-editor .ct-word-clause-indent,.ct-rich-editor .ct-word-clause-indent2{text-indent:2em;text-align:justify}' + @@ -1539,7 +1650,11 @@ const Component = function (props) { fileName: record.fileName, versionName: record.versionName, versionNo: record.versionNo, - contractType: resolveContractType(record), + contractName: resolveContractName(record), + contractType: resolveDocumentContractType(record), + sectionTabs: resolveSectionTabs(record).map(function (tab) { + return { key: tab.key, label: tab.label }; + }), sectionContents: cloneJson(record.sectionContents), sectionVehicleBindings: cloneJson(record.sectionVehicleBindings), remark: record.remark || '', @@ -1701,10 +1816,13 @@ const Component = function (props) { return '合同 ' + versionNo; } - function buildWordDocumentItem(publish, sectionContents, sectionVehicleBindings, importName, editing, contractType) { + function buildWordDocumentItem(publish, sectionContents, sectionVehicleBindings, importName, editing, contractName, documentContractType) { var now = formatNowTime(); - var resolvedContractType = normalizeContractType( - contractType || (editing && resolveContractType(editing)) || '' + var resolvedContractName = normalizeContractName( + contractName || (editing && resolveContractName(editing)) || '' + ); + var resolvedDocumentContractType = normalizeDocumentContractType( + documentContractType || (editing && resolveDocumentContractType(editing)) || '' ); var fileName = importName ? (/\./.test(importName) ? importName : importName + '.docx') @@ -1712,12 +1830,17 @@ const Component = function (props) { var versionName = stripFileExtension(fileName) || (editing && editing.versionName) || '未命名合同'; var bindingsMap = JSON.parse(JSON.stringify(sectionVehicleBindings || {})); var contentsMap = Object.assign(emptySectionMap(), sectionContents || {}); + var sectionTabsSnapshot = getSectionTabs().map(function (tab) { + return { key: tab.key, label: tab.label }; + }); if (publish) { var versionNo = generateNextVersionNo(); return { id: 'doc-' + Date.now(), category: categoryFilter, - contractType: resolvedContractType, + contractName: resolvedContractName, + contractType: resolvedDocumentContractType, + sectionTabs: sectionTabsSnapshot, fileName: fileName, versionNo: versionNo, versionName: buildAutoVersionName(versionNo, importName, versionName), @@ -1736,7 +1859,9 @@ const Component = function (props) { return { id: 'doc-' + Date.now(), category: categoryFilter, - contractType: resolvedContractType, + contractName: resolvedContractName, + contractType: resolvedDocumentContractType, + sectionTabs: sectionTabsSnapshot, fileName: fileName, versionNo: null, versionName: buildAutoVersionName(null, importName, versionName), @@ -1786,13 +1911,20 @@ const Component = function (props) { } function openCreate() { + if (isPrototypeReadOnly()) { + message.warning('当前为只读演示角色,无法新增模板'); + return; + } var sectionKey = 'main'; setActiveSectionKey(sectionKey); setEditorMode('create'); setEditingRecord(null); - setEditorContractType(''); + setEditorContractName(''); + setEditorDocumentContractType('合同'); + setEditorSectionTabs(null); setEditorSectionKey(sectionKey); lastImportFileNameState[1](''); + importedDocxBufferState[1](null); importChangedInSessionState[1](false); formDocumentHtmlState[1](''); formBlocksState[1]([]); @@ -1811,10 +1943,13 @@ const Component = function (props) { var sectionKey = 'main'; setEditorMode('edit'); setEditingRecord(record); - setEditorContractType(resolveContractType(record)); + setEditorContractName(resolveContractName(record)); + setEditorDocumentContractType(resolveDocumentContractType(record)); + setEditorSectionTabs(record.sectionTabs && record.sectionTabs.length ? record.sectionTabs : null); setEditorSectionKey(sectionKey); setActiveSectionKey(sectionKey); lastImportFileNameState[1](record.fileName || ''); + importedDocxBufferState[1](null); importChangedInSessionState[1](false); var sections = Object.assign(emptySectionMap(), record.sectionContents || {}); var html = mergeMonolithicHtml(sections); @@ -1830,19 +1965,62 @@ const Component = function (props) { setPageView('editor'); } - function handleImportWord(file) { - var name = file && file.name ? file.name : 'Word'; + function applyImportedWordHtml(fullHtml, name) { var sectionKey = getCurrentSectionKey(); lastImportFileNameState[1](name); importChangedInSessionState[1](true); - var blocks = mockWordBlocks(name, categoryFilter); - var fullHtml = mergeBlocksToDocument(blocks); - formDocumentHtmlState[1](fullHtml); - formBlocksState[1](documentToBlocks(fullHtml)); - setSectionContents(splitHtmlIntoSections(fullHtml)); + var prepared = prepareWordHtml(fullHtml); + var tabs = extractSectionTabsFromHtml(prepared); + if (tabs && tabs.length > 1) { + setEditorSectionTabs(tabs); + formDocumentHtmlState[1](prepared); + formBlocksState[1](documentToBlocks(prepared)); + setSectionContents(splitHtmlBySectionTabs(prepared, tabs)); + } else { + setEditorSectionTabs(null); + formDocumentHtmlState[1](prepared); + formBlocksState[1](documentToBlocks(prepared)); + setSectionContents(splitHtmlIntoSections(prepared)); + } setActiveSectionKey(sectionKey); editorSyncKeyState[1](function (v) { return v + 1; }); markEditorDirty(); + } + + function handleImportWord(file) { + var name = file && file.name ? file.name : 'Word'; + if (isPrototypeReadOnly()) { + message.warning('当前为只读演示角色,无法导入 Word'); + return false; + } + if (/\.docx?$/i.test(name)) { + var hideLoading = message.loading('正在解析 Word 文件…', 0); + file.arrayBuffer().then(function (arrayBuffer) { + if (isDocxArrayBuffer(arrayBuffer)) { + importedDocxBufferState[1](arrayBuffer); + return convertDocxArrayBufferToHtml(arrayBuffer); + } + importedDocxBufferState[1](null); + return convertLegacyDocFileToHtml(file); + }).then(function (rawHtml) { + applyImportedWordHtml(rawHtml, name); + message.success('已导入 Word 合同全文,章节 Tab 已按文档标题自动生成'); + }).catch(function () { + importedDocxBufferState[1](null); + var blocks = mockWordBlocks(name, categoryFilter); + if (blocks && blocks.length) { + applyImportedWordHtml(mergeBlocksToDocument(blocks), name); + message.warning('未能完整解析该 Word 文件,已载入与文件名匹配的演示模板'); + } else { + message.error('Word 导入失败,请确认文件为有效的 .doc 或 .docx 格式'); + } + }).finally(function () { + if (typeof hideLoading === 'function') hideLoading(); + }); + return false; + } + importedDocxBufferState[1](null); + applyImportedWordHtml(mergeBlocksToDocument(mockWordBlocks(name, categoryFilter)), name); message.success('已导入 Word 合同全文,编辑区将按原版式分页展示全部章节'); return false; } @@ -1872,23 +2050,37 @@ const Component = function (props) { } function handleSaveTemplate(publish) { + if (isPrototypeReadOnly()) { + message.warning('当前为只读演示角色,无法保存或发布模板'); + return; + } var sections = collectEditorSectionContents(); var mainHtml = sections.main || ''; if (!mainHtml.trim()) { message.warning('请先编辑合同内容,或导入 Word 模板'); return; } - if (!normalizeContractType(editorContractType)) { - message.warning('请输入合同类型'); + if (!normalizeContractName(editorContractName)) { + message.warning('请输入合同名称'); + return; + } + if (!normalizeDocumentContractType(editorDocumentContractType)) { + message.warning('请选择类型'); return; } var now = formatNowTime(); var importName = lastImportFileNameState[0] || ''; var bindingsMap = collectEditorSectionVehicleBindings(); - var savedContractType = normalizeContractType(editorContractType); + var savedContractName = normalizeContractName(editorContractName); + var savedDocumentContractType = normalizeDocumentContractType(editorDocumentContractType); + var savedSectionTabs = getSectionTabs().map(function (tab) { + return { key: tab.key, label: tab.label }; + }); if (editorMode === 'create') { - var created = buildWordDocumentItem(publish, sections, bindingsMap, importName, null, savedContractType); + var created = buildWordDocumentItem( + publish, sections, bindingsMap, importName, null, savedContractName, savedDocumentContractType + ); setTemplates(function (prev) { return prev.concat([created]); }); message.success(publish ? ('已发布,版本号 ' + created.versionNo) : '草稿已保存(未生成版本号)'); } else if (editingRecord) { @@ -1914,7 +2106,9 @@ const Component = function (props) { var logs = (t.versionLogs || []).concat([logEntry]); return Object.assign({}, t, { fileName: fileName, - contractType: savedContractType, + contractName: savedContractName, + contractType: savedDocumentContractType, + sectionTabs: savedSectionTabs, versionNo: nextVersionNo, versionName: buildAutoVersionName(nextVersionNo, importName, stripFileExtension(fileName)), sectionContents: sections, @@ -1937,7 +2131,9 @@ const Component = function (props) { : t.fileName; return Object.assign({}, t, { fileName: fileName, - contractType: savedContractType, + contractName: savedContractName, + contractType: savedDocumentContractType, + sectionTabs: savedSectionTabs, versionNo: publishVersionNo, versionName: buildAutoVersionName(publishVersionNo, importName, stripFileExtension(fileName)), status: 'published', @@ -1962,7 +2158,9 @@ const Component = function (props) { : t.fileName; return Object.assign({}, t, { fileName: fileName, - contractType: savedContractType, + contractName: savedContractName, + contractType: savedDocumentContractType, + sectionTabs: savedSectionTabs, sectionContents: sections, sectionVehicleBindings: bindingsMap, updater: CURRENT_USER, @@ -1980,7 +2178,9 @@ const Component = function (props) { : t.fileName; var patch = { fileName: fileName, - contractType: savedContractType, + contractName: savedContractName, + contractType: savedDocumentContractType, + sectionTabs: savedSectionTabs, sectionContents: sections, sectionVehicleBindings: bindingsMap, updater: CURRENT_USER, @@ -2005,10 +2205,15 @@ const Component = function (props) { setEditorSectionKey(null); placeholderPopoverOpenState[1](false); editorInsertHandlerRef.current = null; + storageWriteFailedState[1](false); clearEditorDirty(); } function requestBackToList() { + if (storageWriteFailedState[0]) { + message.warning('本地存储未成功,请重试保存后再离开,否则修改可能丢失'); + return; + } if (pageView === 'editor' && editorDirtyState[0]) { leaveEditorModalState[1](true); return; @@ -2017,6 +2222,10 @@ const Component = function (props) { } function confirmLeaveEditor() { + if (storageWriteFailedState[0]) { + message.warning('本地存储未成功,请重试保存后再离开,否则修改可能丢失'); + return; + } leaveEditorModalState[1](false); backToList(); } @@ -2120,6 +2329,10 @@ const Component = function (props) { function mergePaginatedEditorHtml(chunks, sourceHtml) { var inner = chunks.filter(function (c) { return c != null; }).join(''); var source = String(sourceHtml || ''); + if (isImportedWordDocHtml(source) || isImportedWordDocHtml(inner)) { + if (inner.indexOf('ct-word-doc--imported') >= 0) return inner; + return '
' + inner + '
'; + } if (source.indexOf('ct-word-doc--v266') >= 0 || inner.indexOf('ct-word-doc--v266') >= 0) { if (inner.indexOf('ct-word-doc--v266') >= 0) return inner; return '
' + inner + '
'; @@ -2164,7 +2377,7 @@ const Component = function (props) { return nodes; } - function createWordMeasureContext() { + function createWordMeasureContext(sourceHtml) { var layout = getWordPageLayout(); var sandbox = document.createElement('div'); sandbox.className = 'vm-page ct-page ct-word-measure-host'; @@ -2173,7 +2386,7 @@ const Component = function (props) { var shell = document.createElement('div'); shell.className = 'ct-word-a4-page'; var body = document.createElement('div'); - body.className = 'ct-word-a4-page__body ct-word-doc ct-word-doc--v266 ct-rich-editor ct-rich-editor--word'; + body.className = resolveWordPageBodyClass(sourceHtml || ''); shell.appendChild(body); sandbox.appendChild(shell); return { sandbox: sandbox, body: body, inner: body, maxHeight: layout.contentHeightPx }; @@ -2320,13 +2533,13 @@ const Component = function (props) { function paginateWordHtml(html) { if (typeof document === 'undefined') return [String(html || '')]; var fixed = prepareEditorHtml(html); - if (isWordFormattedDoc(html)) ensureV266WordStyles(html); + if (isWordFormattedDoc(html) && !isImportedWordDocHtml(html)) ensureV266WordStyles(html); var nodes = collectWordDocNodes(fixed); if (!nodes.length) return [fixed || '']; var sourceLen = getWordNodesPlainLength(nodes); var attempts = [1, 0.94, 0.88]; for (var a = 0; a < attempts.length; a++) { - var ctx = createWordMeasureContext(); + var ctx = createWordMeasureContext(html); try { var scale = attempts[a]; ctx.maxHeight = Math.max(120, Math.floor(ctx.maxHeight * scale)); @@ -2346,6 +2559,7 @@ const Component = function (props) { function WordPaginatedPreview(props) { var html = props.html || ''; + var docxBuffer = props.docxBuffer || null; var onScrollContainerMount = props.onScrollContainerMount; var previewZoom = props.zoom != null ? props.zoom : 100; var setPreviewZoom = props.onZoomChange || function () {}; @@ -2353,6 +2567,8 @@ const Component = function (props) { var viewModeState = useState('scroll'); var pagesState = useState([]); var pageIdxState = useState(0); + var docxRootRef = useRef(null); + var docxRenderTokenRef = useRef(0); var viewMode = viewModeState[0]; var setViewMode = viewModeState[1]; var pages = pagesState[0]; @@ -2410,6 +2626,34 @@ const Component = function (props) { } useEffect(function () { + if (!docxBuffer) return; + var token = docxRenderTokenRef.current + 1; + docxRenderTokenRef.current = token; + var cancelled = false; + + function run() { + if (cancelled) return; + var container = docxRootRef.current; + if (!container) { + window.requestAnimationFrame(run); + return; + } + renderDocxPreview(container, docxBuffer).catch(function () { + if (!cancelled && docxRenderTokenRef.current === token && container) { + container.innerHTML = '
Word 原版式预览加载失败,已回退 HTML 预览
'; + } + }); + } + + run(); + return function () { + cancelled = true; + if (docxRootRef.current) docxRootRef.current.innerHTML = ''; + }; + }, [docxBuffer]); + + useEffect(function () { + if (docxBuffer) return; if (!html) { pagesState[1]([]); return; @@ -2419,13 +2663,13 @@ const Component = function (props) { pageIdxState[1](0); }, 0); return function () { window.clearTimeout(timer); }; - }, [html]); + }, [html, docxBuffer]); if (!html || !String(html).trim()) { return null; } - if (!pages.length) { + if (!pages.length && !docxBuffer) { return React.createElement('div', { className: 'ct-preview-pages' }, React.createElement('div', { className: 'ct-preview-page is-loading' }, 'Word 分页排版计算中…') ); @@ -2507,12 +2751,28 @@ const Component = function (props) { ); } + if (docxBuffer) { + return wrapPreviewShell( + previewToolbar, + React.createElement('div', { className: 'ct-preview-pages ct-preview-pages--docx-fidelity' }, + React.createElement('div', { className: 'ct-docx-fidelity-banner', role: 'note' }, + 'Word 原版式预览(与 WPS 排版一致);左侧编辑区保留可编辑结构' + ), + React.createElement('div', { + ref: docxRootRef, + className: 'ct-docx-fidelity-root', + 'aria-label': 'Word 原版式预览' + }) + ) + ); + } + if (viewMode === 'single') { return wrapPreviewShell( previewToolbar, React.createElement('div', { className: 'ct-preview-page', key: pageIdx }, React.createElement('div', { - className: 'ct-preview-page__body ct-word-doc ct-word-doc--v266 ct-word-measure', + className: 'ct-preview-page__body ' + resolveWordDocShellClass(html) + ' ct-word-measure', dangerouslySetInnerHTML: { __html: pages[pageIdx] } }), React.createElement('div', { className: 'ct-preview-page__footer' }, '— ', pageIdx + 1, ' —') @@ -2525,7 +2785,7 @@ const Component = function (props) { pages.map(function (pageHtml, i) { return React.createElement('div', { className: 'ct-preview-page', key: 'p-' + i }, React.createElement('div', { - className: 'ct-preview-page__body ct-word-doc ct-word-doc--v266 ct-word-measure', + className: 'ct-preview-page__body ' + resolveWordDocShellClass(html) + ' ct-word-measure', dangerouslySetInnerHTML: { __html: pageHtml } }), React.createElement('div', { className: 'ct-preview-page__footer' }, '— ', i + 1, ' —') @@ -2544,6 +2804,7 @@ const Component = function (props) { return React.createElement('div', { className: 'ct-preview-doc ct-word-doc ct-word-paginated' }, React.createElement(WordPaginatedPreview, { html: displayHtml, + docxBuffer: isImportedWordDocHtml(raw) ? importedDocxBufferState[0] : null, zoom: workspaceZoom, onZoomChange: setWorkspaceZoom, onScrollContainerMount: registerPreviewScrollContainer @@ -2568,6 +2829,7 @@ const Component = function (props) { var onScrollContainerMount = props.onScrollContainerMount; var workspaceZoom = props.workspaceZoom != null ? props.workspaceZoom : 100; var enableVehicleClause = !!props.enableVehicleClause; + var readOnly = !!props.readOnly; var editorRef = useRef(null); var sourceTextareaRef = useRef(null); var lockIdRef = useRef(1); @@ -3964,14 +4226,16 @@ const Component = function (props) { ) ); - return React.createElement('div', { className: 'ct-rich-wrap' + (isFullscreen ? ' is-fullscreen' : '') + (isWordEditor ? ' ct-rich-wrap--word-a4' : '') }, - React.createElement('div', { className: 'ct-rich-toolbar' }, row1, row2), + return React.createElement('div', { className: 'ct-rich-wrap' + (isFullscreen ? ' is-fullscreen' : '') + (isWordEditor ? ' ct-rich-wrap--word-a4' : '') + (readOnly ? ' is-readonly' : '') }, + readOnly ? null : React.createElement('div', { className: 'ct-rich-toolbar' }, row1, row2), isSourceMode ? React.createElement('textarea', { ref: sourceTextareaRef, className: 'ct-rich-source', value: sourceDraftState[0], + readOnly: readOnly, onChange: function (e) { + if (readOnly) return; sourceDraftState[1](e.target.value); onChange(e.target.value); } @@ -3997,8 +4261,8 @@ const Component = function (props) { ref: function (el) { pageShellRefs.current[i] = el; } }, React.createElement('div', { - className: 'ct-word-a4-page__body ct-word-doc ct-word-doc--v266 ct-rich-editor ct-rich-editor--word', - contentEditable: true, + className: resolveWordPageBodyClass(html, readOnly ? ' is-readonly' : ''), + contentEditable: !readOnly, suppressContentEditableWarning: true, onInput: syncHtml, onBlur: handlePageBodyBlur, @@ -4020,8 +4284,8 @@ const Component = function (props) { editorRef.current = el; if (onScrollContainerMount) onScrollContainerMount(el); }, - className: 'ct-rich-editor', - contentEditable: true, + className: 'ct-rich-editor' + (readOnly ? ' is-readonly' : ''), + contentEditable: !readOnly, suppressContentEditableWarning: true, onInput: syncHtml, onBlur: handlePageBodyBlur @@ -4071,7 +4335,8 @@ const Component = function (props) { onRegisterInsert: registerEditorInsertRef.current, onScrollContainerMount: registerEditorScrollContainer, workspaceZoom: workspaceZoom, - enableVehicleClause: sectionAllowsMainVehicleClauses(sectionKey) + enableVehicleClause: sectionAllowsMainVehicleClauses(sectionKey), + readOnly: isPrototypeReadOnly() }); } @@ -4183,8 +4448,9 @@ const Component = function (props) { } function renderSectionTabsMorePanel() { + var sectionTabs = getSectionTabs(); var query = String(sectionMoreSearchState[0] || '').trim().toLowerCase(); - var filtered = DOCUMENT_SECTION_TABS.filter(function (tab, index) { + var filtered = sectionTabs.filter(function (tab, index) { if (!query) return true; var label = String(tab.label || '').toLowerCase(); var order = String(index + 1); @@ -4203,7 +4469,7 @@ const Component = function (props) { React.createElement('div', { className: 'ct-section-tabs-more__list', role: 'tablist', 'aria-label': '全部章节' }, filtered.length ? filtered.map(function (tab) { - var tabIndex = DOCUMENT_SECTION_TABS.findIndex(function (item) { return item.key === tab.key; }); + var tabIndex = sectionTabs.findIndex(function (item) { return item.key === tab.key; }); return React.createElement('button', { key: tab.key, type: 'button', @@ -4226,12 +4492,14 @@ const Component = function (props) { } function renderSectionTabs() { + var sectionTabs = getSectionTabs(); + if (!sectionTabs.length) return null; var activeIndex = getActiveSectionTabIndex(); - var activeTab = DOCUMENT_SECTION_TABS[activeIndex] || DOCUMENT_SECTION_TABS[0]; + var activeTab = sectionTabs[activeIndex] || sectionTabs[0]; return React.createElement('div', { className: 'ct-section-nav' }, React.createElement('div', { className: 'ct-section-tabs-scroll', ref: sectionTabsScrollRef }, React.createElement('div', { className: 'ct-section-tabs', role: 'tablist', 'aria-label': '合同章节' }, - DOCUMENT_SECTION_TABS.map(function (tab, index) { + sectionTabs.map(function (tab, index) { var isActive = activeSectionKey === tab.key; return React.createElement('button', { key: tab.key, @@ -4383,37 +4651,61 @@ const Component = function (props) { } function renderContractTypeEditor() { + var sectionTabs = getSectionTabs(); + var hasMainTab = sectionTabs.some(function (tab) { return tab.key === 'main'; }); + if (!hasMainTab) return null; var sectionKey = getCurrentSectionKey(); if (sectionKey !== 'main') return null; return React.createElement('section', { className: 'ct-contract-type-card ct-vehicle-binding-card vm-filter-card', - 'aria-label': '合同类型', + 'aria-label': '合同主体配置', 'data-annotation-id': 'ct-contract-type', }, React.createElement('div', { className: 'ct-vehicle-binding-card__head' }, - React.createElement('h3', { className: 'ct-vehicle-binding-card__title' }, - React.createElement('span', { className: 'ct-form-field__required', 'aria-hidden': 'true' }, '*'), - '合同类型' - ), + React.createElement('h3', { className: 'ct-vehicle-binding-card__title' }, '合同主体配置'), React.createElement('p', { className: 'ct-vehicle-binding-card__desc' }, - '必填。用于列表展示,并供租赁合同创建时按类型筛选模板;请自行填写,如「正式合同」「现代18吨试用合同」。' + '合同名称与类型均为必填,将同步至列表页展示,并供租赁合同创建时按名称筛选模板。' ) ), React.createElement('div', { className: 'ct-contract-type-card__form' }, - React.createElement(Input, { - className: 'ct-editor-contract-type-input', - value: editorContractType, - onChange: function (e) { - setEditorContractType(e.target.value); - markEditorDirty(); - }, - placeholder: '请输入合同类型,如:正式合同', - maxLength: 32, - allowClear: true, - required: true, - 'aria-required': 'true', - 'aria-label': '合同类型', - }) + React.createElement('label', { className: 'ct-contract-type-card__field' }, + React.createElement('span', { className: 'ct-contract-type-card__label' }, + React.createElement('span', { className: 'ct-form-field__required', 'aria-hidden': 'true' }, '*'), + '合同名称' + ), + React.createElement(Input, { + className: 'ct-editor-contract-type-input', + value: editorContractName, + onChange: function (e) { + setEditorContractName(e.target.value); + markEditorDirty(); + }, + placeholder: '请输入合同名称,如:正式合同', + maxLength: 32, + allowClear: true, + required: true, + 'aria-required': 'true', + 'aria-label': '合同名称', + }) + ), + React.createElement('label', { className: 'ct-contract-type-card__field ct-contract-type-card__field--type' }, + React.createElement('span', { className: 'ct-contract-type-card__label' }, + React.createElement('span', { className: 'ct-form-field__required', 'aria-hidden': 'true' }, '*'), + '类型' + ), + React.createElement(Select, { + className: 'ct-editor-contract-type-select', + value: editorDocumentContractType, + options: getDocumentContractTypeSelectOptions(), + onChange: function (v) { + setEditorDocumentContractType(v); + markEditorDirty(); + }, + placeholder: '请选择类型', + 'aria-label': '类型', + 'aria-required': 'true', + }) + ) ) ); } @@ -4454,6 +4746,14 @@ const Component = function (props) { } function renderTemplateRowActions(record) { + if (isPrototypeReadOnly()) { + return React.createElement('div', { className: 'ct-row-actions' }, + React.createElement(Button, { + type: 'link', size: 'small', className: 'ct-action-btn', key: 'view', + onClick: function () { openEdit(record); } + }, '查看') + ); + } var published = isPublishedTemplate(record); var enabled = isTemplateEnabled(record); var actionLocked = published && enabled; @@ -4511,15 +4811,20 @@ const Component = function (props) { function getListColumns() { var cols = [ - { title: '合同类型', dataIndex: 'contractType', key: 'contractType', width: 160, ellipsis: true, + { title: '合同名称', dataIndex: 'contractName', key: 'contractName', width: 160, ellipsis: true, render: function (_, r) { - var typeLabel = getContractTypeLabel(resolveContractType(r)); + var nameLabel = getContractNameLabel(resolveContractName(r)); return React.createElement('div', { className: 'ct-contract-name-cell' }, - React.createElement('span', { className: 'ct-contract-name-cell__name', title: typeLabel }, typeLabel), + React.createElement('span', { className: 'ct-contract-name-cell__name', title: nameLabel }, nameLabel), renderEnabledTag(r) ); } }, + { title: '类型', dataIndex: 'contractType', key: 'contractType', width: 120, ellipsis: true, + render: function (_, r) { + return getDocumentContractTypeLabel(resolveDocumentContractType(r)); + } + }, { title: '版本号', dataIndex: 'versionNo', key: 'versionNo', width: 96, align: 'center', render: function (v) { return v || '—'; } }, @@ -4590,15 +4895,15 @@ const Component = function (props) { }); } - function getContractTypeFilterOptions() { + function getContractNameFilterOptions() { var seen = {}; var options = []; templates.forEach(function (doc) { if (doc.category !== categoryFilter) return; - var type = resolveContractType(doc); - if (!type || seen[type]) return; - seen[type] = true; - options.push({ value: type, label: type }); + var name = resolveContractName(doc); + if (!name || seen[name]) return; + seen[name] = true; + options.push({ value: name, label: name }); }); return options.sort(function (a, b) { return String(a.label).localeCompare(String(b.label), 'zh-CN'); @@ -4608,18 +4913,30 @@ const Component = function (props) { function renderListFilterPanel() { var fields = [ React.createElement('label', { key: 'keyword', className: 'vm-filter-field' }, - React.createElement('span', null, '合同类型'), + React.createElement('span', null, '合同名称'), React.createElement(Select, { className: 'ct-list-filter-select', value: listFilterDraft.keyword || undefined, - placeholder: '请选择或搜索合同类型', + placeholder: '请选择或搜索合同名称', allowClear: true, showSearch: true, optionFilterProp: 'label', filterOption: filterContractNameOption, onChange: function (v) { updateListFilterDraft('keyword', v || ''); }, - options: getContractTypeFilterOptions(), - 'aria-label': '合同类型' + options: getContractNameFilterOptions(), + 'aria-label': '合同名称' + }) + ), + React.createElement('label', { key: 'contractType', className: 'vm-filter-field' }, + React.createElement('span', null, '类型'), + React.createElement(Select, { + className: 'ct-list-filter-select', + value: listFilterDraft.contractType || undefined, + placeholder: '全部', + allowClear: true, + onChange: function (v) { updateListFilterDraft('contractType', v || ''); }, + options: getDocumentContractTypeSelectOptions(), + 'aria-label': '类型' }) ), React.createElement('label', { key: 'status', className: 'vm-filter-field' }, @@ -4662,12 +4979,55 @@ const Component = function (props) { }) ) ]; + var split = splitFilterFields(fields, VM_FILTER_PRIMARY_VISIBLE_COUNT); + var primaryFields = split.primary; + var extraFields = split.extra; + var showExpand = shouldShowFilterExpand(fields.length); + var extraActiveCount = listFilterDraft.enabled ? 1 : 0; + var expandPanelId = 'ct-list-filter-expand-panel'; return React.createElement('section', { className: 'vm-filter-card', 'aria-label': '筛选条件', 'data-annotation-id': 'ct-list-filter' }, React.createElement('header', { className: 'vm-filter-header' }, React.createElement('h2', { className: 'vm-filter-title' }, '筛选条件') ), - React.createElement('div', { className: 'vm-filter-grid' }, fields), + React.createElement('div', { className: 'vm-filter-body' }, + React.createElement('div', { + className: 'vm-filter-grid vm-filter-grid--primary', + 'data-filter-tier': 'primary' + }, primaryFields), + showExpand + ? React.createElement('div', { + className: 'vm-filter-expand' + (listFilterExpanded ? ' is-expanded' : ''), + 'aria-hidden': !listFilterExpanded, + id: expandPanelId + }, + React.createElement('div', { className: 'vm-filter-expand-inner' }, + React.createElement('div', { + className: 'vm-filter-grid vm-filter-expand-grid', + 'data-filter-tier': 'extra' + }, listFilterExpanded ? extraFields : null) + ) + ) + : null + ), React.createElement('div', { className: 'vm-filter-actions' }, + showExpand + ? React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-link vm-filter-toggle', + onClick: function () { setListFilterExpanded(!listFilterExpanded); }, + 'aria-expanded': listFilterExpanded, + 'aria-controls': expandPanelId + }, + listFilterExpanded ? '收起' : '更多筛选', + extraActiveCount > 0 && !listFilterExpanded + ? React.createElement('span', { + className: 'vm-filter-toggle-badge', + 'aria-label': '已设置 ' + extraActiveCount + ' 项扩展筛选' + }, String(extraActiveCount)) + : null, + renderFilterChevronIcon(listFilterExpanded) + ) + : null, React.createElement(Button, { className: 'vm-btn vm-btn-ghost', onClick: resetListFilters }, '重置'), React.createElement(Button, { type: 'primary', className: 'ct-btn-cta', onClick: applyListFilters }, '查询') ) @@ -4698,34 +5058,35 @@ const Component = function (props) { React.createElement('div', { className: 'ct-panel-head__title' }, React.createElement('span', null, '编辑区', React.createElement('span', { className: 'ct-panel-head__badge' }, getSectionTabLabel(sectionKey)) - ), - hasContent && isWordFormattedDoc(activeSectionHtml) - ? React.createElement('span', { className: 'ct-editor-layout-hint' }, 'A4 版式较宽时可横向滑动查看') - : null + ) ), React.createElement('div', { className: 'ct-panel-head__actions' }, - React.createElement(Popover, { - content: renderTemplatePlaceholderCard(), - title: null, - trigger: 'click', - placement: 'bottomRight', - open: placeholderPopoverOpenState[0], - onOpenChange: placeholderPopoverOpenState[1], - overlayInnerStyle: { padding: '12px 14px' } - }, - React.createElement(Button, { - className: 'ct-panel-tool-btn ct-panel-tool-btn--ghost' + (placeholderPopoverOpenState[0] ? ' is-active' : ''), - type: 'default' - }, renderPlaceholderToolIcon(), '模板占位符') - ), - React.createElement(Upload, { - accept: '.doc,.docx', - showUploadList: false, - beforeUpload: handleImportWord - }, React.createElement(Button, { - className: 'ct-panel-tool-btn' + (!hasContent && editorMode === 'create' ? ' ct-panel-tool-btn--accent' : ' ct-panel-tool-btn--ghost'), - type: 'default' - }, renderImportWordToolIcon(), '导入 Word 模板')) + isPrototypeReadOnly() + ? React.createElement('span', { className: 'ct-readonly-hint' }, '只读查看') + : React.createElement(React.Fragment, null, + React.createElement(Popover, { + content: renderTemplatePlaceholderCard(), + title: null, + trigger: 'click', + placement: 'bottomRight', + open: placeholderPopoverOpenState[0], + onOpenChange: placeholderPopoverOpenState[1], + overlayInnerStyle: { padding: '12px 14px' } + }, + React.createElement(Button, { + className: 'ct-panel-tool-btn ct-panel-tool-btn--ghost' + (placeholderPopoverOpenState[0] ? ' is-active' : ''), + type: 'default' + }, renderPlaceholderToolIcon(), '模板占位符') + ), + React.createElement(Upload, { + accept: '.doc,.docx', + showUploadList: false, + beforeUpload: handleImportWord + }, React.createElement(Button, { + className: 'ct-panel-tool-btn' + (!hasContent && editorMode === 'create' ? ' ct-panel-tool-btn--accent' : ' ct-panel-tool-btn--ghost'), + type: 'default' + }, renderImportWordToolIcon(), '导入 Word 模板')) + ) ) ), React.createElement('div', { key: 'editor-panel-' + editorSyncKeyState[0] + '-' + getCurrentSectionKey(), className: 'ct-editor-panel' }, renderUnifiedEditor()) @@ -4770,24 +5131,34 @@ const Component = function (props) { } function renderEditorPage() { + var readOnly = isPrototypeReadOnly(); return React.createElement('div', { className: 'ct-editor-page' }, + readOnly + ? React.createElement('div', { className: 'ct-readonly-banner', role: 'status' }, + '当前为客服只读演示(URL 参数 ?role=cs 或 ?role=readonly),不可保存或修改模板内容。' + ) + : null, React.createElement('div', { className: 'ct-editor-page__topbar vm-detail-topbar', 'data-annotation-id': 'ct-editor-topbar' }, React.createElement(Button, { className: 'ct-editor-page__back-btn vm-btn vm-btn-back', onClick: requestBackToList }, '← 返回列表'), - React.createElement('div', { className: 'ct-editor-page__topbar-actions' }, - React.createElement(Button, { - className: 'vm-btn vm-btn-secondary', - onClick: function () { handleSaveTemplate(false); } - }, '保存'), - React.createElement(Button, { - type: 'primary', - className: 'vm-btn vm-btn-primary ct-btn-cta', - onClick: function () { handleSaveTemplate(true); } - }, '保存并发布') + readOnly + ? null + : React.createElement('div', { className: 'ct-editor-page__topbar-actions' }, + React.createElement(Button, { + className: 'vm-btn vm-btn-secondary', + onClick: function () { handleSaveTemplate(false); } + }, '保存'), + React.createElement(Button, { + type: 'primary', + className: 'vm-btn vm-btn-primary ct-btn-cta', + onClick: function () { handleSaveTemplate(true); } + }, '保存并发布') + ) + ), + getSectionTabs().length + ? React.createElement('nav', { className: 'ct-editor-page__sections', 'aria-label': '合同章节导航', 'data-annotation-id': 'ct-section-tabs' }, + renderSectionTabs() ) - ), - React.createElement('nav', { className: 'ct-editor-page__sections', 'aria-label': '合同章节导航', 'data-annotation-id': 'ct-section-tabs' }, - renderSectionTabs() - ), + : null, renderContractTypeEditor(), renderVehicleBindingEditor(), React.createElement('div', { className: 'ct-editor-page__body' }, renderEditorWorkspace()) @@ -4800,8 +5171,10 @@ const Component = function (props) { React.createElement('section', { className: 'vm-table-section' }, React.createElement('div', { className: 'vm-table-toolbar', 'data-annotation-id': 'ct-list-toolbar' }, React.createElement('div', { className: 'vm-table-actions' }, - React.createElement(Button, { type: 'primary', className: 'ct-btn-cta', onClick: openCreate }, '新增') - ) + isPrototypeReadOnly() + ? React.createElement('span', { className: 'ct-readonly-hint' }, '只读演示:可查看模板,不可新增或修改(?role=cs)') + : React.createElement(Button, { type: 'primary', className: 'ct-btn-cta', onClick: openCreate }, '新增') + ), ), React.createElement('div', { className: 'vm-table-card ct-list-table-card', 'data-annotation-id': 'ct-list-table' }, renderListTable() @@ -4917,7 +5290,13 @@ const Component = function (props) { var previewHtml = mergeMonolithicHtml(Object.assign(emptySectionMap(), snap.sectionContents || {})); return React.createElement('div', null, React.createElement('div', { className: 'ct-snapshot-meta' }, - React.createElement('div', null, '合同名称:', snap.fileName || snap.versionName || '—'), + React.createElement('div', null, 'Word 文件名:', snap.fileName || snap.versionName || '—'), + snap.contractName || snap.contractType + ? React.createElement('div', null, '合同名称:', snap.contractName || snap.contractType) + : null, + snap.contractType && isDocumentContractTypeEnum(snap.contractType) + ? React.createElement('div', null, '类型:', snap.contractType) + : null, React.createElement('div', null, '版本号:', snap.versionNo || '—'), React.createElement('div', null, '归档时间:', snap.archivedAt || snapshotViewState[0].time || '—') ), diff --git a/src/prototypes/contract-template-management/annotation-source.json b/src/prototypes/contract-template-management/annotation-source.json index 5ad344d..d650203 100644 --- a/src/prototypes/contract-template-management/annotation-source.json +++ b/src/prototypes/contract-template-management/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "contract-template-management", "pageId": "list", - "updatedAt": 1783328981482, + "updatedAt": 1783426129808, "nodes": [ { "id": "ct-list-filter", @@ -20,12 +20,12 @@ "path": [] }, "aiPrompt": "列表页筛选区字段与查询逻辑。", - "annotationText": "支持按合同名称(可搜索下拉)、状态、创建人、启用状态筛选。", + "annotationText": "支持按合同名称(可搜索下拉)、类型、状态、创建人、启用状态筛选;超过 4 项时默认折叠扩展筛选项。", "hasMarkdown": true, "color": "#2563eb", "images": [], "createdAt": 1740508800000, - "updatedAt": 1740508800000 + "updatedAt": 1783426129808 }, { "id": "ct-list-toolbar", @@ -40,12 +40,12 @@ "path": [] }, "aiPrompt": "列表工具栏新增入口。", - "annotationText": "进入新增页,默认从「合同主体」章节开始编辑。", + "annotationText": "进入新增页;未导入 Word 前不展示章节 Tab。", "hasMarkdown": false, "color": "#32a06e", "images": [], "createdAt": 1740508800000, - "updatedAt": 1740508800000 + "updatedAt": 1783426129808 }, { "id": "ct-list-table", @@ -99,18 +99,18 @@ "fingerprint": "ct-section-tabs", "path": [] }, - "aiPrompt": "9 个合同章节切换。", - "annotationText": "横向滚动查看全部 9 章;可通过章节列表搜索并跳转。", + "aiPrompt": "由 Word「标题」样式自动生成的章节 Tab。", + "annotationText": "导入 Word 后,按文档中标记为「标题」的段落文本自动生成章节 Tab;新增模式未导入前整段隐藏(含合同主体)。", "hasMarkdown": true, "color": "#2563eb", "images": [], "createdAt": 1740508800000, - "updatedAt": 1782820000000 + "updatedAt": 1783426129808 }, { "id": "ct-contract-type", "index": 6.5, - "title": "合同类型", + "title": "合同主体配置", "pageId": "editor", "locator": { "selectors": [ @@ -119,13 +119,13 @@ "fingerprint": "ct-contract-type", "path": [] }, - "aiPrompt": "合同主体章节下的合同类型配置。", - "annotationText": "仅在「合同主体」章节 Tab 下展示;合同类型为必填,用户自行填写后同步至列表页。", + "aiPrompt": "合同主体章节下的合同名称与类型配置。", + "annotationText": "仅当存在「合同主体」Tab 且选中时展示;合同名称与类型均为必填,同步至列表页。", "hasMarkdown": true, "color": "#7c3aed", "images": [], "createdAt": 1740508800000, - "updatedAt": 1782820000000 + "updatedAt": 1783426129808 }, { "id": "ct-vehicle-binding", @@ -309,19 +309,21 @@ } ], "annotationsByElementId": { - "ct-list-filter": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 可搜索下拉 | 选项来自现有模板名称;支持输入模糊搜索 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅已发布) |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认", - "ct-list-table": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同类型 | `contractType` | 展示用户自定义的合同类型文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名 |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿或已发布且停用 | 启用中禁用 |\n| 删除 | 草稿或已发布且停用 | 启用中禁用 |\n\n### 7.6 子表(展开行)\n\n展示 9 个章节摘要:章节名、适用车型、风控条款数、条件条款数。" + "ct-list-filter": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 可搜索下拉 | 选项来自现有模板 `contractName` 去重;支持输入模糊搜索 |\n| 类型 | 下拉 | 合同 / 授权委托书 / 转三方协议 / 其他 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**布局**:默认首行展示 4 项(合同名称、类型、状态、创建人);第 5 项「启用状态」收入「更多筛选」折叠区(与全局 `vm-filter` 规则一致)。扩展筛选项已设置时,折叠区自动展开并显示角标。\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认", + "ct-list-table": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `contractName` | 展示用户自定义合同名称;同行展示启用状态 Tag |\n| 类型 | `contractType` | 展示类型枚举文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n---\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n---\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---", + "ct-contract-type": "### 8.4 合同主体配置(仅合同主体 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 合同名称输入框 | **必填**;用户自行填写,如「正式合同」 |\n| 类型下拉 | **必填**;选项:合同 / 授权委托书 / 转三方协议 / 其他 |\n| 展示位置 | 章节 Tab 下方配置卡片,与附件章节的「适用车型」卡片交互一致 |\n| 用途 | 同步至列表页「合同名称」「类型」列;租赁合同创建时按合同名称筛选模板 |\n\n**规则**:\n\n- 仅当存在 **合同主体** 章节 Tab 且当前选中该 Tab 时显示本卡片(未导入 Word 时不展示)。\n- 未填写合同名称时无法保存或发布,并提示「请输入合同名称」。\n- 未选择类型时无法保存或发布,并提示「请选择类型」。\n- 切换至租赁订单等附件章节时,显示「适用车型」卡片,本卡片隐藏。\n- 保存/发布时分别写入 `contractName`、`contractType` 字段。", + "ct-section-tabs": "### 8.3 章节 Tab\n\n- **新增模式**:未导入 Word 时 **不展示** 章节 Tab 导航(含「合同主体」)及合同主体配置卡片。\n- **编辑模式**:优先按记录已保存的 `sectionTabs` 展示;无自定义 Tabs 时按 §9 规则生成。\n- **Tab 生成逻辑(非固定列表)**:\n - 导入 Word 后,系统识别文档中被标记为 **「标题」** 样式的段落(Word 样式名如 `标题`、`标题1`~`标题3` 等,导入后映射为 `h1`~`h6`);\n - 每个标题段落的纯文本生成一个章节 Tab,Tab 文案与标题一致;\n - 第一个标题之前的内容归入 **「合同主体」** Tab(`main`);该 Tab 下编辑/预览 **整份 Word 合并全文**;\n - 其余 Tab 仅展示对应标题至下一标题之间的切片内容;\n - 若文档中 **未识别到任何标题**,回退为按 §9.2 预置章节标记切分的默认章节 Tab(与演示模板一致)。\n- **展示**:横向滚动 Tab 条展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。" } }, "markdownMap": { - "ct-list-filter": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同类型 | 可搜索下拉 | 选项来自现有模板 `contractType` 去重;支持输入模糊搜索 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认", - "ct-list-table": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同类型 | `contractType` | 展示用户自定义的合同类型文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n---\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n---\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---", - "ct-editor-topbar": "### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n---\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布(须先停用) |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**保持已发布与版本号**,仍为停用态 |\n| **保存并发布** | 新建已发布记录 + 版本号,默认启用 | 原记录变已发布 + 版本号,默认启用 | **同记录升级版本号**;旧内容写入 `versionLogs` 快照;发布后默认停用,需手动启用 |\n\n**校验**:保存/发布前,合同主体(`sectionContents.main`)不能为空。\n\n**离开编辑**:有未保存修改时返回列表需二次确认。", - "ct-contract-type": "### 8.4 合同类型(仅合同主体 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 合同类型输入框 | **必填**;用户自行填写,如「正式合同」「现代18吨试用合同」 |\n| 展示位置 | 章节 Tab 下方配置卡片,与附件章节的「适用车型」卡片交互一致 |\n| 用途 | 同步至列表页「合同类型」列;租赁合同创建时按类型匹配标准模板 |\n\n**规则**:\n\n- 仅当选中 **合同主体** 章节 Tab 时显示本卡片。\n- 未填写时无法保存或发布,并提示「请输入合同类型」。\n- 切换至租赁订单等附件章节时,显示「适用车型」卡片,本卡片隐藏。\n- 保存/发布时写入模板 `contractType` 字段。", - "ct-section-tabs": "### 8.3 章节 Tab\n\n- 共 9 个章节;**横向滚动 Tab 条**展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**。\n- **其他 Tab**:仅展示该章节切片内容。\n\n章节顺序:\n\n1. 合同主体 \n2. 租赁订单 \n3. 车辆产生的费用 \n4. 安全责任告知书 \n5. 重型普通货运车辆租赁动态监管责任告知与确认书 \n6. 安全责任承诺书 \n7. 车辆使用规范和驾驶员要求 \n8. 授权委托书 \n9. 安全责任协议", - "ct-vehicle-binding": "### 8.5 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。", - "ct-editor-panel": "### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.6.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。", - "ct-preview-panel": "### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充" + "ct-list-filter": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 可搜索下拉 | 选项来自现有模板 `contractName` 去重;支持输入模糊搜索 |\n| 类型 | 下拉 | 合同 / 授权委托书 / 转三方协议 / 其他 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**布局**:默认首行展示 4 项(合同名称、类型、状态、创建人);第 5 项「启用状态」收入「更多筛选」折叠区(与全局 `vm-filter` 规则一致)。扩展筛选项已设置时,折叠区自动展开并显示角标。\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认", + "ct-list-table": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `contractName` | 展示用户自定义合同名称;同行展示启用状态 Tag |\n| 类型 | `contractType` | 展示类型枚举文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n---\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n---\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---", + "ct-editor-topbar": "### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n---\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布(须先停用) |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**保持已发布与版本号**,仍为停用态 |\n| **保存并发布** | 新建已发布记录 + 版本号,默认启用 | 原记录变已发布 + 版本号,默认启用 | **同记录升级版本号**;旧内容写入 `versionLogs` 快照;发布后默认停用,需手动启用 |\n\n**校验**:保存/发布前须满足:\n\n- 合同主体(`sectionContents.main`)不能为空;\n- **合同名称**(`contractName`)已填写;\n- **类型**(`contractType`)已选择。\n\n**离开编辑**:有未保存修改时返回列表需二次确认。", + "ct-contract-type": "### 8.4 合同主体配置(仅合同主体 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 合同名称输入框 | **必填**;用户自行填写,如「正式合同」 |\n| 类型下拉 | **必填**;选项:合同 / 授权委托书 / 转三方协议 / 其他 |\n| 展示位置 | 章节 Tab 下方配置卡片,与附件章节的「适用车型」卡片交互一致 |\n| 用途 | 同步至列表页「合同名称」「类型」列;租赁合同创建时按合同名称筛选模板 |\n\n**规则**:\n\n- 仅当存在 **合同主体** 章节 Tab 且当前选中该 Tab 时显示本卡片(未导入 Word 时不展示)。\n- 未填写合同名称时无法保存或发布,并提示「请输入合同名称」。\n- 未选择类型时无法保存或发布,并提示「请选择类型」。\n- 切换至租赁订单等附件章节时,显示「适用车型」卡片,本卡片隐藏。\n- 保存/发布时分别写入 `contractName`、`contractType` 字段。", + "ct-section-tabs": "### 8.3 章节 Tab\n\n- **新增模式**:未导入 Word 时 **不展示** 章节 Tab 导航(含「合同主体」)及合同主体配置卡片。\n- **编辑模式**:优先按记录已保存的 `sectionTabs` 展示;无自定义 Tabs 时按 §9 规则生成。\n- **Tab 生成逻辑(非固定列表)**:\n - 导入 Word 后,系统识别文档中被标记为 **「标题」** 样式的段落(Word 样式名如 `标题`、`标题1`~`标题3` 等,导入后映射为 `h1`~`h6`);\n - 每个标题段落的纯文本生成一个章节 Tab,Tab 文案与标题一致;\n - 第一个标题之前的内容归入 **「合同主体」** Tab(`main`);该 Tab 下编辑/预览 **整份 Word 合并全文**;\n - 其余 Tab 仅展示对应标题至下一标题之间的切片内容;\n - 若文档中 **未识别到任何标题**,回退为按 §9.2 预置章节标记切分的默认章节 Tab(与演示模板一致)。\n- **展示**:横向滚动 Tab 条展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。", + "ct-vehicle-binding": "### 8.6 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。", + "ct-editor-panel": "### 8.7 编辑区(左侧)\n\n#### 8.7.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.7.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.7.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。", + "ct-preview-panel": "### 8.8 实时预览(右侧)\n\n#### 8.8.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.8.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.8.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.8.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充" }, "assetMap": {}, "directory": { @@ -465,7 +467,7 @@ "type": "markdown", "id": "ct-doc-overview", "title": "模块总览", - "markdown": "## 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 合同模板管理 |\n| 目标用户 | 法务人员 |\n| 核心任务 | 维护 Word 版商用车租赁合同模板,按章节编辑,发布版本并控制启用态 |\n| 数据模型 | 一份 Word 文件 = 一条记录,内含 9 个章节内容 |\n| PRD 文档 | 见目录「PRD 全文 → PRD 完整文档」 |\n| 资源文件 | `src/resources/contract-template-management/PRD.md` |\n\n---\n\n### 产品目标(摘要)\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;发布变更写入版本日志 |\n| 多套模板并存 | 租赁创建时由用户选择已启用模板 |\n\n**同步命令**:`node src/prototypes/contract-template-management/scripts/build-annotation-source.mjs`" + "markdown": "## 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 合同模板管理 |\n| 目标用户 | 法务人员 |\n| 核心任务 | 维护 Word 版商用车租赁合同模板,按章节编辑,发布版本并控制启用态 |\n| 数据模型 | 一份 Word 文件 = 一条记录,内含 9 个章节内容 |\n| 车型主数据 | 品牌·型号选项来自 [车辆管理](/prototypes/vehicle-management) 种子数据,并与租赁演示车型合并 |\n| 角色演示 | 法务默认可编辑;URL 加 ?role=cs 或 ?role=readonly 进入客服只读模式 |\n| PRD 文档 | 见目录「PRD 全文 → PRD 完整文档」 |\n| 资源文件 | `src/resources/contract-template-management/PRD.md` |\n\n---\n\n### 产品目标(摘要)\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;发布变更写入版本日志 |\n| 多套模板并存 | 租赁创建时由用户选择已启用模板 |\n\n**同步命令**:`node src/prototypes/contract-template-management/scripts/build-annotation-source.mjs`" }, { "type": "folder", @@ -477,32 +479,32 @@ "type": "markdown", "id": "ct-doc-prd-full", "title": "PRD 完整文档", - "markdown": "# 合同模板管理 · 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.0 |\n| 模块名称 | 合同模板管理 |\n| 所属系统 | ONE-OS 业务管理 |\n| 目标读者 | 前端 / 后端 / 测试 / 法务产品对接人 |\n| 交互原型 | `/prototypes/contract-template-management` |\n| 关联原型 | [车辆管理](/prototypes/vehicle-management)(车型主数据口径参考) |\n| 文档状态 | 已对齐原型(启用态 / 版本日志 / 全章节条件条款) |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 背景\n\n商用车租赁合同以 Word 文档形式维护,内容长、结构复杂,包含合同主体与 8 个附件章节。不同车型可能需要不同的附件条款组合。法务人员需要:\n\n- 以 **Word 版式保真** 的方式维护整份合同;\n- 按 **章节** 管理内容,并配置 **适用车型**;\n- 发布 **版本**,通过 **启用/停用** 控制模板是否可供租赁合同选用;\n- 在合同主体及租赁订单~安全责任协议各章节中配置 **条件条款**(按订单车辆命中显隐)。\n\n### 1.2 产品目标\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;已发布修改产生新版本并写入版本日志 |\n| 多套模板并存 | 正式/试用、不同车型等多套已发布模板可同时存在;租赁创建时由用户选择 |\n\n### 1.3 非目标(本期不做)\n\n- 采购合同、服务合同等其他分类的完整业务闭环(数据结构预留 `category`,当前仅启用 `lease`)\n- 在线多人协同编辑、审批流配置界面\n- Word 导出回写(本期以 HTML 结构化存储 + 预览为主)\n- 列表页顶部章节 Tab(**仅编辑页**保留章节 Tab)\n\n---\n\n## 2. 用户与场景\n\n### 2.1 目标用户\n\n| 角色 | 诉求 |\n|---|---|\n| 法务人员(主用户) | 导入/编辑 Word 模板、发布版本、启用/停用模板 |\n| 客服人员(间接用户) | 草拟新合同时从已启用模板中选择(下游「租赁合同管理」消费) |\n| 运营 / 风控(间接用户) | 查看风控红线条款配置情况 |\n\n### 2.2 核心用户故事\n\n1. **作为法务**,我希望导入一份完整 Word 合同,系统自动拆成 9 个章节,以便分章节维护。\n2. **作为法务**,我希望为「租赁订单」等附件章节绑定适用车型,以便创建合同时只纳入匹配车辆的条款。\n3. **作为法务**,我希望在合同主体及租赁订单~安全责任协议各章节为某段落设置「条件条款」并绑定车型,以便不同车型看到不同正文。\n4. **作为法务**,我希望保存草稿不产生版本号,发布后才生成版本号,以便区分未上线内容。\n5. **作为法务**,我希望对已发布模板执行启用/停用,停用后方可编辑,并在发布变更时自动归档旧版快照到版本日志。\n6. **作为客服**,我希望在创建租赁合同时第一步选择要使用的已启用合同模板。\n7. **作为法务**,我希望在列表展开行查看各章节的风控条款数、条件条款数,以便快速评估模板完整度。\n\n---\n\n## 3. 名词解释\n\n| 名词 | 定义 |\n|---|---|\n| Word 文档 / 模板记录 | 列表主表一行,对应一份完整合同文件 |\n| 章节 | 合同内的逻辑切片,共 9 个(1 个主体 + 8 个附件) |\n| 草稿 | `status = draft`,无版本号 |\n| 已发布 | `status = published`,有版本号 `vX.XX` |\n| 启用 | `enabled = true` 的已发布模板,可供租赁合同创建选用 |\n| 停用 | `enabled = false` 的已发布模板,不可编辑/删除,不可被租赁创建选用 |\n| 版本日志 | `versionLogs[]`,记录发布变更前自动归档的快照 |\n| 锁定区 | 编辑区内不可随意修改的段落,HTML 标记 `data-inline-lock=\"1\"` |\n| 风控红线条款 | 需重点合规关注的条款,HTML 标记 `data-risk-redline=\"1\"` |\n| 条件条款 | 各章节可用,按订单车辆命中显隐,HTML 标记 `data-vehicle-clause=\"1\"` |\n| 适用车型绑定 | 附件章节级别配置,决定该章节是否纳入某份合同 |\n| 模板占位符 | 形如 `{{contractCode}}` 的变量,创建合同时由系统替换 |\n\n---\n\n## 4. 信息架构\n\n```text\n合同模板管理\n├── 列表页\n│ ├── 筛选区\n│ ├── 工具栏(新增)\n│ └── 嵌套表格\n│ ├── 主表:Word 文件\n│ └── 子表:9 个章节(展开行)\n└── 编辑 / 新增页\n ├── 顶栏(返回、保存、保存并发布)\n ├── 章节 Tab(仅本页展示)\n ├── 条件条款说明(各章节均可使用工具栏「条件」)\n ├── 适用车型绑定(附件章节 Tab 时显示)\n └── 工作区\n ├── 左侧:Word 版式编辑区\n └── 右侧:A4 实时预览\n```\n\n---\n\n## 5. 数据模型\n\n### 5.1 模板记录(Word 文档级)\n\n一条记录代表 **一份 Word 合同文件**,不按章节拆成多行。\n\n```typescript\ninterface ContractTemplateDocument {\n id: string; // 唯一 ID\n category: 'lease'; // 合同分类,当前固定商用车租赁\n contractType: string; // 用户自定义合同类型,如「正式合同」「试用合同」\n fileName: string; // Word 文件名,如「现代18吨车型-数智中心版.docx」\n versionName: string; // 展示用版本名称\n versionNo: string | null; // 已发布:v1.00;草稿:null\n status: 'draft' | 'published';\n enabled: boolean; // 已发布模板是否启用(草稿恒为 false)\n versionLogs: VersionLogEntry[]; // 版本变更日志与快照\n sectionContents: Record; // 各章节 HTML\n sectionVehicleBindings: Record; // 各章节车型绑定\n creator: string;\n createTime: string; // YYYY-MM-DD HH:mm\n updater: string;\n updateTime: string;\n remark?: string; // 内部备注(列表不展示)\n}\n\ninterface VersionLogEntry {\n id: string;\n versionNo: string; // 归档时的版本号\n action: string; // 如「发布变更」\n operator: string;\n time: string;\n remark?: string;\n snapshot: {\n fileName: string;\n versionName: string;\n versionNo: string;\n sectionContents: Record;\n sectionVehicleBindings: Record;\n remark?: string;\n archivedAt: string;\n };\n}\n\ninterface VehicleBinding {\n brand: string; // 品牌,如「现代」\n model: string; // 型号,如「18吨氢燃料电池车」\n}\n```\n\n### 5.2 章节枚举(SectionKey)\n\n| key | 章节名称 | 车型绑定 | 条件条款 |\n|---|---|:---:|:---:|\n| `main` | 合同主体 | 否 | 是 |\n| `lease_order` | 租赁订单 | 是 | 是 |\n| `vehicle_cost` | 车辆产生的费用 | 是 | 是 |\n| `safety_notice` | 安全责任告知书 | 是 | 是 |\n| `dynamic_supervision` | 重型普通货运车辆租赁动态监管责任告知与确认书 | 是 | 是 |\n| `safety_commitment` | 安全责任承诺书 | 是 | 是 |\n| `vehicle_spec` | 车辆使用规范和驾驶员要求 | 是 | 是 |\n| `authorization` | 授权委托书 | 是 | 是 |\n| `safety_agreement` | 安全责任协议 | 是 | 是 |\n\n### 5.3 章节内容 HTML 标记规范\n\n研发需保证存储、渲染、统计口径一致:\n\n| 类型 | 选择器 / 属性 | 统计函数 |\n|---|---|---|\n| 锁定区 | `.ct-inline-lock[data-inline-lock=\"1\"]` | `countLockRegions` |\n| 风控红线 | `.ct-risk-redline[data-risk-redline=\"1\"]` | `countRiskRedlineRegions` |\n| 条件条款 | `.ct-vehicle-clause[data-vehicle-clause=\"1\"]` | `countVehicleClauseRegions` |\n| 条件条款车型 | `data-vehicle-bindings`(JSON 数组) | 预览命中逻辑 |\n\n---\n\n## 6. 业务规则(全局)\n\n### 6.1 版本号规则\n\n- 格式:`v{major}.{minor}`,如 `v1.00`、`v1.01`\n- **仅发布时生成**;保存草稿时 `versionNo = null`\n- 同分类下递增:取当前已发布版本最大 minor +1;minor ≥ 99 时 major +1、minor 归 0\n- 首版默认 `v1.00`\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布(须先停用) |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**保持已发布与版本号**,仍为停用态 |\n| **保存并发布** | 新建已发布记录 + 版本号,默认启用 | 原记录变已发布 + 版本号,默认启用 | **同记录升级版本号**;旧内容写入 `versionLogs` 快照;发布后默认停用,需手动启用 |\n\n**校验**:保存/发布前,合同主体(`sectionContents.main`)不能为空。\n\n**离开编辑**:有未保存修改时返回列表需二次确认。\n\n### 6.3 启用 / 停用规则\n\n1. 仅 **已发布** 模板具有启用态;草稿显示「—」。\n2. **启用中**:不可编辑、不可删除;可供租赁合同创建选用。\n3. **停用**:需二次确认(提示可能导致标准合同无法正常使用);停用后可编辑、可删除;租赁创建不可选用。\n4. **启用**:直接生效,无需二次确认。\n5. 编辑已发布模板并发布后,模板自动变为 **停用**,需重新启用后下游才可选。\n\n### 6.4 版本日志规则\n\n1. 对已发布模板执行「保存并发布」前,系统将 **当前内容自动归档** 为快照写入 `versionLogs`。\n2. 列表操作列提供「版本日志」,可查看历史版本号、操作人、时间与快照正文(只读)。\n3. 列表主表 **一行对应一份 Word 文件**,历史版本通过日志追溯,不另增列表行。\n\n### 6.5 删除规则\n\n- **启用中** 的已发布模板不可删除。\n- 其余记录可删除;删除前二次确认;删除后不可恢复。\n\n### 6.6 列表排序\n\n按 `updateTime` 降序。\n\n### 6.7 筛选匹配规则\n\n| 字段 | 匹配逻辑 |\n|---|---|\n| 合同类型 | 下拉选择(支持输入模糊搜索候选项);精确匹配 `contractType` |\n| 状态 | 精确匹配 `draft` / `published` |\n| 创建人 | 精确匹配 `creator` |\n| 启用状态 | `enabled`:仅已发布且启用;`disabled`:仅已发布且停用 |\n\n> **已移除**:筛选项「适用品牌/适用车型」、主表列「备注」、「设为标准模板」能力。\n\n---\n\n## 7. 列表页需求\n\n### 7.1 页面结构\n\n筛选区 + 工具栏 + 嵌套表格。**列表页不展示章节 Tab。**\n\n### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同类型 | 可搜索下拉 | 选项来自现有模板 `contractType` 去重;支持输入模糊搜索 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认\n\n### 7.3 工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增 | 进入新增页,默认章节为「合同主体」,模式为 create |\n\n### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同类型 | `contractType` | 展示用户自定义的合同类型文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---\n\n## 8. 编辑 / 新增页需求\n\n### 8.1 页面结构\n\n顶栏 → 章节 Tab → 条件说明 / 车型绑定(按章节显隐)→ 左右分栏工作区。\n\n### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n### 8.3 章节 Tab\n\n- 共 9 个章节;**横向滚动 Tab 条**展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**。\n- **其他 Tab**:仅展示该章节切片内容。\n\n章节顺序:\n\n1. 合同主体 \n2. 租赁订单 \n3. 车辆产生的费用 \n4. 安全责任告知书 \n5. 重型普通货运车辆租赁动态监管责任告知与确认书 \n6. 安全责任承诺书 \n7. 车辆使用规范和驾驶员要求 \n8. 授权委托书 \n9. 安全责任协议 \n\n### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。适用于合同主体及全部附件章节。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n### 8.5 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。\n\n### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.6.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充\n\n### 8.8 空状态\n\n编辑区无内容时,预览区展示空状态引导;新增模式下突出「导入 Word 模板」按钮。\n\n---\n\n## 9. Word 导入与章节拆分\n\n### 9.1 导入范围\n\n- 一次导入 **整份合同**;\n- 导入后写入各 `sectionContents[key]`;\n- 默认进入「合同主体」章节编辑。\n\n### 9.2 拆分策略\n\n1. 对导入 HTML 做 Word 导出清洗(`sanitizeWordExportHtml`);\n2. 从附件章节起,按 **章节标记字符串** 定位切分点;\n3. 切分点优先落在 `

`、``、`` 之后,避免打断标签;\n4. `main` = 第一个切分点之前的全文;\n5. 各附件章节 = 相邻切分点之间的片段。\n\n### 9.3 章节标记(节选)\n\n研发需维护可扩展的 `markers` 列表,示例:\n\n| 章节 | 标记示例 |\n|---|---|\n| 租赁订单 | `附件 1:租赁订单`、`附件1:租赁订单` |\n| 车辆产生的费用 | `附件2:车辆产生的费用` |\n| 安全责任告知书 | `附件3:安全责任告知书` |\n| … | 完整列表见原型 `DOCUMENT_SECTION_TABS` |\n\n### 9.4 误匹配过滤\n\n以下情况 **不应** 作为切分点:\n\n- 标记出现在「详见附件…」「以附件…」等引用语境;\n- 标记过短且不在 `` 标题语境内;\n- 标记前为「(」「、」等不完整标题上下文。\n\n---\n\n## 10. 模板占位符\n\n### 10.1 变量分组\n\n**甲方签约主体**(创建合同时按所选签约公司自动替换):\n\n| key | 标签 |\n|---|---|\n| `lessorName` | 甲方全称 |\n| `lessorAddress` | 甲方地址 |\n| `lessorContact` | 甲方联系人 |\n| `lessorPhone` | 甲方电话 |\n| `lessorEmail` | 甲方邮箱 |\n| `lessorAccountName` | 收款户名 |\n| `lessorBankName` | 开户行 |\n| `lessorBankAccount` | 银行账号 |\n\n**合同通用**(创建合同时由系统或客服填写):\n\n| key | 标签 |\n|---|---|\n| `contractCode` | 合同编号 |\n| `customerName` | 乙方公司全称 |\n| `creditCode` | 乙方统一社会信用代码 |\n| `customerAddress` | 乙方地址 |\n| `customerContact` | 乙方联系人 |\n| `customerPhone` | 乙方联系电话 |\n| `signDateYear` | 签订年份 |\n| `signDateMonth` | 签订月份 |\n| `signDateDay` | 签订日期 |\n\n### 10.2 插入规则\n\n- 编辑区光标处插入 `{{key}}`;\n- 存储为 HTML 文本,不做运行时强校验;\n- 缺失变量创建合同时按空字符串或业务默认值处理(由下游定义)。\n\n---\n\n## 11. 车型主数据\n\n品牌 · 型号下拉数据与车辆管理模块保持一致,当前原型内置示例:\n\n| 品牌 | 型号示例 |\n|---|---|\n| 现代 | 18吨氢燃料电池车、帕力安牌4.5吨冷链车 |\n| 苏龙 | 9.6米氢燃料电池车 |\n| 柳汽乘龙 | 9.6米氢燃料电池车 |\n| 飞驰 | 集卡头、半挂车 |\n| 跃进 | 4.2米冷链车 |\n| 宇通 | 氢燃料电池客车 |\n\n> 正式环境应由车辆主数据服务提供;绑定存储仅保存品牌 + 型号字符串。\n\n---\n\n## 12. 与其他模块的关系\n\n```mermaid\nflowchart LR\n A[合同模板管理] -->|已启用模板 + 章节内容| B[租赁合同创建]\n A -->|车型绑定口径| C[车辆管理]\n A -->|占位符变量定义| B\n B -->|订单车辆列表| A\n```\n\n| 模块 | 关系 |\n|---|---|\n| 租赁合同创建 | 创建流程第一步选择 **已启用** 的已发布模板;按订单车辆过滤条件条款与附件章节 |\n| 车辆管理 | 车型品牌/型号枚举口径一致 |\n| 非标准合同审批 | 合同正文出现 `data-nonstd-trigger` 时触发(标记规范已预留) |\n\n---\n\n## 13. 权限与审计(建议)\n\n| 能力 | 建议权限 |\n|---|---|\n| 查看列表 | 法务、客服(只读) |\n| 新增 / 编辑 / 发布 | 法务 |\n| 启用 / 停用 | 法务 |\n| 删除 | 法务负责人 |\n\n**审计字段**:`creator`、`createTime`、`updater`、`updateTime` 必须在每次保存时更新;发布变更写入 `versionLogs`。\n\n---\n\n## 14. 接口需求(建议)\n\n### 14.1 列表查询\n\n```\nGET /api/contract-templates?category=lease&fileName=&status=&creator=&enabled=&page=1&pageSize=10\n```\n\n响应需包含主表字段;章节摘要可列表内嵌或展开时按需加载。\n\n### 14.2 详情\n\n```\nGET /api/contract-templates/{id}\n```\n\n返回完整 `sectionContents`、`sectionVehicleBindings`、`versionLogs`。\n\n### 14.3 创建 / 更新\n\n```\nPOST /api/contract-templates\nPUT /api/contract-templates/{id}\n```\n\n请求体含章节 HTML 与绑定;`publish: boolean` 控制是否发布。\n\n### 14.4 启用 / 停用\n\n```\nPOST /api/contract-templates/{id}/enable\nPOST /api/contract-templates/{id}/disable\n```\n\n停用前需二次确认;启用中记录拒绝编辑/删除类写操作(由业务层校验)。\n\n### 14.5 删除\n\n```\nDELETE /api/contract-templates/{id}\n```\n\n拒绝删除 `enabled=true` 的记录。\n\n### 14.6 Word 导入\n\n```\nPOST /api/contract-templates/import-word\nContent-Type: multipart/form-data\n```\n\n返回拆分后的 `sectionContents` 供前端预览确认(或后端直接落库,由研发评估)。\n\n---\n\n## 15. 非功能需求\n\n| 类别 | 要求 |\n|---|---|\n| 性能 | 单份合同 HTML ≤ 2MB 时,章节切换 < 500ms(前端缓存) |\n| 兼容性 | Chrome / Edge 最新两个大版本;编辑区最低宽度 1280px 推荐 |\n| 可访问性 | 表单控件有 `aria-label`;操作按钮具备可读文案 |\n| 安全 | 导入 Word 需做 XSS 清洗;仅白名单标签/属性入库 |\n| 可追溯 | 已发布版本不可原地覆盖内容,只能新发版 |\n\n---\n\n## 16. 验收标准\n\n### 16.1 列表页\n\n- [ ] 筛选项包含:合同类型(可搜索下拉)、状态、创建人、启用状态\n- [ ] 主表含合同类型、启用状态列;操作含启用/停用、版本日志、编辑、删除\n- [ ] 启用中模板编辑/删除按钮禁用;停用需二次确认\n- [ ] 主表每行对应一份 Word 文件,可展开 9 个章节子表\n- [ ] 子表展示:章节名、适用车型、风控条款数、条件条款数\n\n### 16.2 编辑页\n\n- [ ] 章节 Tab 仅在编辑页展示;横向滚动 + 章节列表面板\n- [ ] 导入 Word 后 9 章节正确拆分\n- [ ] 附件章节可绑定车型;各章节均可设条件条款\n- [ ] 预览支持连续/单页切换;各章节可模拟车型验证条件条款\n- [ ] 保存草稿无版本号;发布生成版本号\n- [ ] 编辑已发布(停用后)并发布时版本号递增,旧版写入版本日志\n- [ ] 编辑已发布(停用后)点「保存」不改变发布态与版本号\n- [ ] 有未保存修改时返回列表需确认\n\n### 16.3 跨模块与数据一致性\n\n- [ ] 模板管理停用后,租赁合同创建页不可再选用该模板\n- [ ] 租赁创建预览读取 store 中模板 `sectionContents`(与模板管理编辑一致)\n- [ ] 列表子表「风控条款数」与编辑区统计一致\n- [ ] 列表子表「条件条款数」与各章节实际标记数一致\n\n### 16.4 标注与文档(原型)\n\n- [ ] 原型已接入 `@axhub/annotation`,关键区域有 `data-annotation-id`\n- [ ] 目录路由可切换列表页 / 新增页\n- [ ] `PRD.md` 与 `annotation-source.json` 与当前实现一致\n\n---\n\n## 17. 原型实现映射(供研发对照)\n\n| 能力 | 原型文件 / 符号 |\n|---|---|\n| 主逻辑 | `src/prototypes/contract-template-management/ContractTemplate.jsx` |\n| 跨模块模板状态 | `src/prototypes/contract-template-management/contract-template-store.js` |\n| 章节 HTML 合并 | `src/prototypes/contract-template-management/contract-template-section-html.js` |\n| 冷启动种子 | `src/prototypes/contract-template-management/contract-template-seed.js` |\n| 租赁侧消费目录 | `src/prototypes/contract-template-management/contract-template-catalog.js` |\n| 入口与标注 | `src/prototypes/contract-template-management/index.tsx` |\n| 标注数据 | `src/prototypes/contract-template-management/annotation-source.json` |\n| 下游选用 | `src/prototypes/lease-contract-management/LeaseContractCreate.jsx` |\n| 占位符与出租方 | `src/prototypes/contract-template-management/contract-template-vars.js` |\n| 样式 | `src/prototypes/contract-template-management/styles/` |\n\n> **原型限制**:模板数据经 `localStorage`(兼写 `sessionStorage`)持久化;冷启动由 `contract-template-seed.js` 注入种子,无需先打开模板管理页即可在租赁创建选用已启用模板。\n\n---\n\n## 18. 附录:停用确认弹窗(原文案)\n\n**标题**:确认停用\n\n**正文**:\n\n> 停用后,该模板将不可在租赁合同创建时选用,是否确定?\n\n**按钮**:取消 / 确定停用\n\n---\n\n## 19. 修订记录\n\n| 版本 | 日期 | 作者 | 说明 |\n|---|---|---|---|\n| v1.2 | 2026-06-29 | 产品 | 租赁预览贯通 store;冷启动种子;列表按合同类型筛选;移除 isDefault;localStorage 持久化;PRD 与实现对齐 |\n| v1.1 | 2026-06-29 | 产品 | 移除标准模板模型,改为启用/停用 + 版本日志;列表筛选与操作同步;各章节支持条件条款 |\n| v1.0 | 2026-06-25 | 产品 | 首版:Word 文档级模型、嵌套列表、章节拆分与条件条款 |\n", + "markdown": "# 合同模板管理 · 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.4 |\n| 模块名称 | 合同模板管理 |\n| 所属系统 | ONE-OS 业务管理 |\n| 目标读者 | 前端 / 后端 / 测试 / 法务产品对接人 |\n| 交互原型 | `/prototypes/contract-template-management` |\n| 关联原型 | [车辆管理](/prototypes/vehicle-management)(车型主数据口径参考) |\n| 文档状态 | 已对齐原型(v1.4:章节 Tab 由 Word「标题」自动生成;列表筛选类型字段;未导入 Word 不展示章节导航) |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 背景\n\n商用车租赁合同以 Word 文档形式维护,内容长、结构复杂,包含合同主体与 8 个附件章节。不同车型可能需要不同的附件条款组合。法务人员需要:\n\n- 以 **Word 版式保真** 的方式维护整份合同;\n- 按 **章节** 管理内容,并配置 **适用车型**;\n- 发布 **版本**,通过 **启用/停用** 控制模板是否可供租赁合同选用;\n- 在合同主体及租赁订单~安全责任协议各章节中配置 **条件条款**(按订单车辆命中显隐)。\n\n### 1.2 产品目标\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;已发布修改产生新版本并写入版本日志 |\n| 多套模板并存 | 正式/试用、不同车型等多套已发布模板可同时存在;租赁创建时由用户选择 |\n\n### 1.3 非目标(本期不做)\n\n- 采购合同、服务合同等其他分类的完整业务闭环(数据结构预留 `category`,当前仅启用 `lease`)\n- 在线多人协同编辑、审批流配置界面\n- Word 导出回写(本期以 HTML 结构化存储 + 预览为主)\n- 列表页顶部章节 Tab(**仅编辑页**保留章节 Tab)\n\n---\n\n## 2. 用户与场景\n\n### 2.1 目标用户\n\n| 角色 | 诉求 |\n|---|---|\n| 法务人员(主用户) | 导入/编辑 Word 模板、发布版本、启用/停用模板 |\n| 客服人员(间接用户) | 草拟新合同时从已启用模板中选择(下游「租赁合同管理」消费) |\n| 运营 / 风控(间接用户) | 查看风控红线条款配置情况 |\n\n### 2.2 核心用户故事\n\n1. **作为法务**,我希望导入一份完整 Word 合同,系统自动拆成 9 个章节,以便分章节维护。\n2. **作为法务**,我希望为「租赁订单」等附件章节绑定适用车型,以便创建合同时只纳入匹配车辆的条款。\n3. **作为法务**,我希望在合同主体及租赁订单~安全责任协议各章节为某段落设置「条件条款」并绑定车型,以便不同车型看到不同正文。\n4. **作为法务**,我希望保存草稿不产生版本号,发布后才生成版本号,以便区分未上线内容。\n5. **作为法务**,我希望对已发布模板执行启用/停用,停用后方可编辑,并在发布变更时自动归档旧版快照到版本日志。\n6. **作为客服**,我希望在创建租赁合同时第一步选择要使用的已启用合同模板。\n7. **作为法务**,我希望在列表展开行查看各章节的风控条款数、条件条款数,以便快速评估模板完整度。\n\n---\n\n## 3. 名词解释\n\n| 名词 | 定义 |\n|---|---|\n| Word 文档 / 模板记录 | 列表主表一行,对应一份完整合同文件 |\n| 章节 | 合同内的逻辑切片,共 9 个(1 个主体 + 8 个附件) |\n| 草稿 | `status = draft`,无版本号 |\n| 已发布 | `status = published`,有版本号 `vX.XX` |\n| 启用 | `enabled = true` 的已发布模板,可供租赁合同创建选用 |\n| 停用 | `enabled = false` 的已发布模板,不可编辑/删除,不可被租赁创建选用 |\n| 版本日志 | `versionLogs[]`,记录发布变更前自动归档的快照 |\n| 锁定区 | 编辑区内不可随意修改的段落,HTML 标记 `data-inline-lock=\"1\"` |\n| 风控红线条款 | 需重点合规关注的条款,HTML 标记 `data-risk-redline=\"1\"` |\n| 条件条款 | 各章节可用,按订单车辆命中显隐,HTML 标记 `data-vehicle-clause=\"1\"` |\n| 适用车型绑定 | 附件章节级别配置,决定该章节是否纳入某份合同 |\n| 合同名称 | `contractName`,用户自定义展示名,如「正式合同」「试用合同」 |\n| 类型 | `contractType` 枚举:合同 / 授权委托书 / 转三方协议 / 其他 |\n| 模板占位符 | 形如 `{{contractCode}}` 的变量,创建合同时由系统替换 |\n\n---\n\n## 4. 信息架构\n\n```text\n合同模板管理\n├── 列表页\n│ ├── 筛选区\n│ ├── 工具栏(新增)\n│ └── 嵌套表格\n│ ├── 主表:Word 文件\n│ └── 子表:9 个章节(展开行)\n└── 编辑 / 新增页\n ├── 顶栏(返回、保存、保存并发布)\n ├── 章节 Tab(仅本页展示)\n ├── 条件条款说明(各章节均可使用工具栏「条件」)\n ├── 适用车型绑定(附件章节 Tab 时显示)\n └── 工作区\n ├── 左侧:Word 版式编辑区\n └── 右侧:A4 实时预览\n```\n\n---\n\n## 5. 数据模型\n\n### 5.1 模板记录(Word 文档级)\n\n一条记录代表 **一份 Word 合同文件**,不按章节拆成多行。\n\n```typescript\ninterface ContractTemplateDocument {\n id: string; // 唯一 ID\n category: 'lease'; // 合同分类,当前固定商用车租赁\n contractName: string; // 合同名称,用户自定义,如「正式合同」\n contractType: string; // 类型枚举:合同 | 授权委托书 | 转三方协议 | 其他\n fileName: string; // Word 文件名,如「现代18吨车型-数智中心版.docx」\n versionName: string; // 展示用版本名称\n versionNo: string | null; // 已发布:v1.00;草稿:null\n status: 'draft' | 'published';\n enabled: boolean; // 已发布模板是否启用(草稿恒为 false)\n versionLogs: VersionLogEntry[]; // 版本变更日志与快照\n sectionContents: Record; // 各章节 HTML\n sectionVehicleBindings: Record; // 各章节车型绑定\n creator: string;\n createTime: string; // YYYY-MM-DD HH:mm\n updater: string;\n updateTime: string;\n remark?: string; // 内部备注(列表不展示)\n}\n\ninterface VersionLogEntry {\n id: string;\n versionNo: string; // 归档时的版本号\n action: string; // 如「发布变更」\n operator: string;\n time: string;\n remark?: string;\n snapshot: {\n fileName: string;\n versionName: string;\n versionNo: string;\n sectionContents: Record;\n sectionVehicleBindings: Record;\n remark?: string;\n archivedAt: string;\n };\n}\n\ninterface VehicleBinding {\n brand: string; // 品牌,如「现代」\n model: string; // 型号,如「18吨氢燃料电池车」\n}\n```\n\n### 5.2 章节枚举(SectionKey)\n\n| key | 章节名称 | 车型绑定 | 条件条款 |\n|---|---|:---:|:---:|\n| `main` | 合同主体 | 否 | 是 |\n| `lease_order` | 租赁订单 | 是 | 是 |\n| `vehicle_cost` | 车辆产生的费用 | 是 | 是 |\n| `safety_notice` | 安全责任告知书 | 是 | 是 |\n| `dynamic_supervision` | 重型普通货运车辆租赁动态监管责任告知与确认书 | 是 | 是 |\n| `safety_commitment` | 安全责任承诺书 | 是 | 是 |\n| `vehicle_spec` | 车辆使用规范和驾驶员要求 | 是 | 是 |\n| `authorization` | 授权委托书 | 是 | 是 |\n| `safety_agreement` | 安全责任协议 | 是 | 是 |\n\n### 5.3 章节内容 HTML 标记规范\n\n研发需保证存储、渲染、统计口径一致:\n\n| 类型 | 选择器 / 属性 | 统计函数 |\n|---|---|---|\n| 锁定区 | `.ct-inline-lock[data-inline-lock=\"1\"]` | `countLockRegions` |\n| 风控红线 | `.ct-risk-redline[data-risk-redline=\"1\"]` | `countRiskRedlineRegions` |\n| 条件条款 | `.ct-vehicle-clause[data-vehicle-clause=\"1\"]` | `countVehicleClauseRegions` |\n| 条件条款车型 | `data-vehicle-bindings`(JSON 数组) | 预览命中逻辑 |\n\n---\n\n## 6. 业务规则(全局)\n\n### 6.1 版本号规则\n\n- 格式:`v{major}.{minor}`,如 `v1.00`、`v1.01`\n- **仅发布时生成**;保存草稿时 `versionNo = null`\n- 同分类下递增:取当前已发布版本最大 minor +1;minor ≥ 99 时 major +1、minor 归 0\n- 首版默认 `v1.00`\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布(须先停用) |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**保持已发布与版本号**,仍为停用态 |\n| **保存并发布** | 新建已发布记录 + 版本号,默认启用 | 原记录变已发布 + 版本号,默认启用 | **同记录升级版本号**;旧内容写入 `versionLogs` 快照;发布后默认停用,需手动启用 |\n\n**校验**:保存/发布前须满足:\n\n- 合同主体(`sectionContents.main`)不能为空;\n- **合同名称**(`contractName`)已填写;\n- **类型**(`contractType`)已选择。\n\n**离开编辑**:有未保存修改时返回列表需二次确认。\n\n### 6.3 启用 / 停用规则\n\n1. 仅 **已发布** 模板具有启用态;草稿显示「—」。\n2. **启用中**:不可编辑、不可删除;可供租赁合同创建选用。\n3. **停用**:需二次确认(提示可能导致标准合同无法正常使用);停用后可编辑、可删除;租赁创建不可选用。\n4. **启用**:直接生效,无需二次确认。\n5. 编辑已发布模板并发布后,模板自动变为 **停用**,需重新启用后下游才可选。\n\n### 6.4 版本日志规则\n\n1. 对已发布模板执行「保存并发布」前,系统将 **当前内容自动归档** 为快照写入 `versionLogs`。\n2. 列表操作列提供「版本日志」,可查看历史版本号、操作人、时间与快照正文(只读)。\n3. 列表主表 **一行对应一份 Word 文件**,历史版本通过日志追溯,不另增列表行。\n\n### 6.5 删除规则\n\n- **启用中** 的已发布模板不可删除。\n- 其余记录可删除;删除前二次确认;删除后不可恢复。\n\n### 6.6 列表排序\n\n按 `updateTime` 降序。\n\n### 6.7 筛选匹配规则\n\n| 字段 | 匹配逻辑 |\n|---|---|\n| 合同名称 | 可搜索下拉;精确匹配 `contractName`(选项来自现有模板去重) |\n| 类型 | 下拉;精确匹配 `contractType` 枚举 |\n| 状态 | 精确匹配 `draft` / `published` |\n| 创建人 | 精确匹配 `creator` |\n| 启用状态 | `enabled`:仅已发布且启用;`disabled`:仅已发布且停用 |\n\n> **已移除**:筛选项「适用品牌/适用车型」、主表列「备注」、「设为标准模板」能力。\n\n---\n\n## 7. 列表页需求\n\n### 7.1 页面结构\n\n筛选区 + 工具栏 + 嵌套表格。**列表页不展示章节 Tab。**\n\n### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 可搜索下拉 | 选项来自现有模板 `contractName` 去重;支持输入模糊搜索 |\n| 类型 | 下拉 | 合同 / 授权委托书 / 转三方协议 / 其他 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**布局**:默认首行展示 4 项(合同名称、类型、状态、创建人);第 5 项「启用状态」收入「更多筛选」折叠区(与全局 `vm-filter` 规则一致)。扩展筛选项已设置时,折叠区自动展开并显示角标。\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认\n\n### 7.3 工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增 | 进入新增页,模式为 create;**未导入 Word 前不展示章节 Tab** |\n\n### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `contractName` | 展示用户自定义合同名称;同行展示启用状态 Tag |\n| 类型 | `contractType` | 展示类型枚举文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---\n\n## 8. 编辑 / 新增页需求\n\n### 8.1 页面结构\n\n顶栏 → 章节 Tab(导入 Word 后展示)→ 合同主体配置 / 车型绑定(按章节显隐)→ 左右分栏工作区。\n\n### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n### 8.3 章节 Tab\n\n- **新增模式**:未导入 Word 时 **不展示** 章节 Tab 导航(含「合同主体」)及合同主体配置卡片。\n- **编辑模式**:优先按记录已保存的 `sectionTabs` 展示;无自定义 Tabs 时按 §9 规则生成。\n- **Tab 生成逻辑(非固定列表)**:\n - 导入 Word 后,系统识别文档中被标记为 **「标题」** 样式的段落(Word 样式名如 `标题`、`标题1`~`标题3` 等,导入后映射为 `h1`~`h6`);\n - 每个标题段落的纯文本生成一个章节 Tab,Tab 文案与标题一致;\n - 第一个标题之前的内容归入 **「合同主体」** Tab(`main`);该 Tab 下编辑/预览 **整份 Word 合并全文**;\n - 其余 Tab 仅展示对应标题至下一标题之间的切片内容;\n - 若文档中 **未识别到任何标题**,回退为按 §9.2 预置章节标记切分的默认章节 Tab(与演示模板一致)。\n- **展示**:横向滚动 Tab 条展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n\n### 8.4 合同主体配置(仅合同主体 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 合同名称输入框 | **必填**;用户自行填写,如「正式合同」 |\n| 类型下拉 | **必填**;选项:合同 / 授权委托书 / 转三方协议 / 其他 |\n| 展示位置 | 章节 Tab 下方配置卡片,与附件章节的「适用车型」卡片交互一致 |\n| 用途 | 同步至列表页「合同名称」「类型」列;租赁合同创建时按合同名称筛选模板 |\n\n**规则**:\n\n- 仅当存在 **合同主体** 章节 Tab 且当前选中该 Tab 时显示本卡片(未导入 Word 时不展示)。\n- 未填写合同名称时无法保存或发布,并提示「请输入合同名称」。\n- 未选择类型时无法保存或发布,并提示「请选择类型」。\n- 切换至租赁订单等附件章节时,显示「适用车型」卡片,本卡片隐藏。\n- 保存/发布时分别写入 `contractName`、`contractType` 字段。\n\n### 8.5 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。适用于合同主体及全部附件章节。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n### 8.6 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。\n\n### 8.7 编辑区(左侧)\n\n#### 8.7.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.7.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.7.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n### 8.8 实时预览(右侧)\n\n#### 8.8.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.8.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.8.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.8.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充\n\n### 8.9 空状态\n\n编辑区无内容时,预览区展示空状态引导;新增模式下突出「导入 Word 模板」按钮。\n\n---\n\n## 9. Word 导入与章节拆分\n\n### 9.1 导入范围\n\n- 一次导入 **整份合同**;\n- 导入后写入各 `sectionContents[key]`,并展示章节 Tab;\n- 导入后默认进入「合同主体」章节编辑。\n\n### 9.2 拆分策略\n\n**优先:按 Word「标题」样式生成章节 Tab**\n\n1. 对导入 HTML 做 Word 导出清洗(`sanitizeWordExportHtml`);\n2. 扫描 `h1`~`h6` 标题节点(由 Word「标题」类样式映射而来),每个标题文本生成一个章节 Tab;\n3. 第一个标题之前的内容写入 `main`(合同主体);各标题至下一标题之间的片段写入对应 Tab;\n4. 将结果写入 `sectionTabs` 与各 `sectionContents[key]`。\n\n**回退:按预置章节标记切分(无标题时)**\n\n1. 从附件章节起,按 **章节标记字符串** 定位切分点;\n2. 切分点优先落在 `

`、``、`` 之后,避免打断标签;\n3. `main` = 第一个切分点之前的全文;\n4. 各附件章节 = 相邻切分点之间的片段;\n5. 使用默认 `DOCUMENT_SECTION_TABS` 作为 Tab 列表。\n\n### 9.3 章节标记(节选)\n\n研发需维护可扩展的 `markers` 列表,示例:\n\n| 章节 | 标记示例 |\n|---|---|\n| 租赁订单 | `附件 1:租赁订单`、`附件1:租赁订单` |\n| 车辆产生的费用 | `附件2:车辆产生的费用` |\n| 安全责任告知书 | `附件3:安全责任告知书` |\n| … | 完整列表见原型 `DOCUMENT_SECTION_TABS` |\n\n### 9.4 误匹配过滤\n\n以下情况 **不应** 作为切分点:\n\n- 标记出现在「详见附件…」「以附件…」等引用语境;\n- 标记过短且不在 `` 标题语境内;\n- 标记前为「(」「、」等不完整标题上下文。\n\n---\n\n## 10. 模板占位符\n\n### 10.1 变量分组\n\n**甲方签约主体**(创建合同时按所选签约公司自动替换):\n\n| key | 标签 |\n|---|---|\n| `lessorName` | 甲方全称 |\n| `lessorAddress` | 甲方地址 |\n| `lessorContact` | 甲方联系人 |\n| `lessorPhone` | 甲方电话 |\n| `lessorEmail` | 甲方邮箱 |\n| `lessorAccountName` | 收款户名 |\n| `lessorBankName` | 开户行 |\n| `lessorBankAccount` | 银行账号 |\n\n**合同通用**(创建合同时由系统或客服填写):\n\n| key | 标签 |\n|---|---|\n| `contractCode` | 合同编号 |\n| `customerName` | 乙方公司全称 |\n| `creditCode` | 乙方统一社会信用代码 |\n| `customerAddress` | 乙方地址 |\n| `customerContact` | 乙方联系人 |\n| `customerPhone` | 乙方联系电话 |\n| `signDateYear` | 签订年份 |\n| `signDateMonth` | 签订月份 |\n| `signDateDay` | 签订日期 |\n\n### 10.2 插入规则\n\n- 编辑区光标处插入 `{{key}}`;\n- 存储为 HTML 文本,不做运行时强校验;\n- 缺失变量创建合同时按空字符串或业务默认值处理(由下游定义)。\n\n---\n\n## 11. 车型主数据\n\n品牌 · 型号下拉数据与车辆管理模块保持一致,当前原型内置示例:\n\n| 品牌 | 型号示例 |\n|---|---|\n| 现代 | 18吨氢燃料电池车、帕力安牌4.5吨冷链车 |\n| 苏龙 | 9.6米氢燃料电池车 |\n| 柳汽乘龙 | 9.6米氢燃料电池车 |\n| 飞驰 | 集卡头、半挂车 |\n| 跃进 | 4.2米冷链车 |\n| 宇通 | 氢燃料电池客车 |\n\n> 正式环境应由车辆主数据服务提供;绑定存储仅保存品牌 + 型号字符串。\n\n---\n\n## 12. 与其他模块的关系\n\n```mermaid\nflowchart LR\n A[合同模板管理] -->|已启用模板 + 章节内容| B[租赁合同创建]\n A -->|车型绑定口径| C[车辆管理]\n A -->|占位符变量定义| B\n B -->|订单车辆列表| A\n```\n\n| 模块 | 关系 |\n|---|---|\n| 租赁合同创建 | 创建流程第一步选择 **已启用** 的已发布模板;按订单车辆过滤条件条款与附件章节 |\n| 车辆管理 | 车型品牌/型号枚举口径一致 |\n| 非标准合同审批 | 合同正文出现 `data-nonstd-trigger` 时触发(标记规范已预留) |\n\n---\n\n## 13. 权限与审计(建议)\n\n| 能力 | 建议权限 |\n|---|---|\n| 查看列表 | 法务、客服(只读) |\n| 新增 / 编辑 / 发布 | 法务 |\n| 启用 / 停用 | 法务 |\n| 删除 | 法务负责人 |\n\n**审计字段**:`creator`、`createTime`、`updater`、`updateTime` 必须在每次保存时更新;发布变更写入 `versionLogs`。\n\n---\n\n## 14. 接口需求(建议)\n\n### 14.1 列表查询\n\n```\nGET /api/contract-templates?category=lease&fileName=&status=&creator=&enabled=&page=1&pageSize=10\n```\n\n响应需包含主表字段;章节摘要可列表内嵌或展开时按需加载。\n\n### 14.2 详情\n\n```\nGET /api/contract-templates/{id}\n```\n\n返回完整 `sectionContents`、`sectionVehicleBindings`、`versionLogs`。\n\n### 14.3 创建 / 更新\n\n```\nPOST /api/contract-templates\nPUT /api/contract-templates/{id}\n```\n\n请求体含章节 HTML 与绑定;`publish: boolean` 控制是否发布。\n\n### 14.4 启用 / 停用\n\n```\nPOST /api/contract-templates/{id}/enable\nPOST /api/contract-templates/{id}/disable\n```\n\n停用前需二次确认;启用中记录拒绝编辑/删除类写操作(由业务层校验)。\n\n### 14.5 删除\n\n```\nDELETE /api/contract-templates/{id}\n```\n\n拒绝删除 `enabled=true` 的记录。\n\n### 14.6 Word 导入\n\n```\nPOST /api/contract-templates/import-word\nContent-Type: multipart/form-data\n```\n\n返回拆分后的 `sectionContents` 供前端预览确认(或后端直接落库,由研发评估)。\n\n---\n\n## 15. 非功能需求\n\n| 类别 | 要求 |\n|---|---|\n| 性能 | 单份合同 HTML ≤ 2MB 时,章节切换 < 500ms(前端缓存) |\n| 兼容性 | Chrome / Edge 最新两个大版本;编辑区最低宽度 1280px 推荐 |\n| 可访问性 | 表单控件有 `aria-label`;操作按钮具备可读文案 |\n| 安全 | 导入 Word 需做 XSS 清洗;仅白名单标签/属性入库 |\n| 可追溯 | 已发布版本不可原地覆盖内容,只能新发版 |\n\n---\n\n## 16. 验收标准\n\n### 16.1 列表页\n\n- [ ] 筛选项包含:合同名称(可搜索下拉)、类型、状态、创建人、启用状态;超过 4 项时默认折叠扩展筛选项\n- [ ] 主表含合同名称、类型列;操作含启用/停用、版本日志、编辑、删除\n- [ ] 启用中模板编辑/删除按钮禁用;停用需二次确认\n- [ ] 主表每行对应一份 Word 文件,可展开 9 个章节子表\n- [ ] 子表展示:章节名、适用车型、风控条款数、条件条款数\n\n### 16.2 编辑页\n\n- [ ] 章节 Tab 仅在编辑页展示;横向滚动 + 章节列表面板\n- [ ] 新增页未导入 Word 时不展示章节 Tab 与合同主体配置卡片\n- [ ] 导入 Word 后章节 Tab 按「标题」样式自动生成(无标题时回退默认标记拆分)\n- [ ] 附件章节可绑定车型;各章节均可设条件条款\n- [ ] 预览支持连续/单页切换;各章节可模拟车型验证条件条款\n- [ ] 保存草稿无版本号;发布生成版本号\n- [ ] 编辑已发布(停用后)并发布时版本号递增,旧版写入版本日志\n- [ ] 编辑已发布(停用后)点「保存」不改变发布态与版本号\n- [ ] 有未保存修改时返回列表需确认\n\n### 16.3 跨模块与数据一致性\n\n- [ ] 模板管理停用后,租赁合同创建页不可再选用该模板\n- [ ] 租赁创建预览读取 store 中模板 `sectionContents`(与模板管理编辑一致)\n- [ ] 列表子表「风控条款数」与编辑区统计一致\n- [ ] 列表子表「条件条款数」与各章节实际标记数一致\n\n### 16.4 标注与文档(原型)\n\n- [ ] 原型已接入 `@axhub/annotation`,关键区域有 `data-annotation-id`\n- [ ] 目录路由可切换列表页 / 新增页\n- [ ] `PRD.md` 与 `annotation-source.json` 与当前实现一致\n\n---\n\n## 17. 原型实现映射(供研发对照)\n\n| 能力 | 原型文件 / 符号 |\n|---|---|\n| 主逻辑 | `src/prototypes/contract-template-management/ContractTemplate.jsx` |\n| 跨模块模板状态 | `src/prototypes/contract-template-management/contract-template-store.js` |\n| 章节 HTML 合并 | `src/prototypes/contract-template-management/contract-template-section-html.js` |\n| 冷启动种子 | `src/prototypes/contract-template-management/contract-template-seed.js` |\n| 租赁侧消费目录 | `src/prototypes/contract-template-management/contract-template-catalog.js` |\n| 入口与标注 | `src/prototypes/contract-template-management/index.tsx` |\n| 标注数据 | `src/prototypes/contract-template-management/annotation-source.json` |\n| 下游选用 | `src/prototypes/lease-contract-management/LeaseContractCreate.jsx` |\n| 占位符与出租方 | `src/prototypes/contract-template-management/contract-template-vars.js` |\n| 样式 | `src/prototypes/contract-template-management/styles/` |\n\n> **原型限制**:模板数据经 `localStorage`(兼写 `sessionStorage`)持久化;冷启动由 `contract-template-seed.js` 注入种子,无需先打开模板管理页即可在租赁创建选用已启用模板。\n\n---\n\n## 18. 附录:停用确认弹窗(原文案)\n\n**标题**:确认停用\n\n**正文**:\n\n> 停用后,该模板将不可在租赁合同创建时选用,是否确定?\n\n**按钮**:取消 / 确定停用\n\n---\n\n## 19. 修订记录\n\n| 版本 | 日期 | 作者 | 说明 |\n|---|---|---|---|\n| v1.4 | 2026-07-07 | 产品 | 章节 Tab 改为由 Word「标题」样式自动生成;明确未导入 Word 不展示章节导航;列表筛选与列名统一为「类型」 |\n| v1.3 | 2026-07-07 | 产品 | 合同名称与类型字段拆分;列表筛选增加类型与折叠;新增页未导入 Word 不展示章节 Tab;PRD 与原型对齐 |\n| v1.2 | 2026-06-29 | 产品 | 租赁预览贯通 store;冷启动种子;列表按合同类型筛选;移除 isDefault;localStorage 持久化;PRD 与实现对齐 |\n| v1.1 | 2026-06-29 | 产品 | 移除标准模板模型,改为启用/停用 + 版本日志;列表筛选与操作同步;各章节支持条件条款 |\n| v1.0 | 2026-06-25 | 产品 | 首版:Word 文档级模型、嵌套列表、章节拆分与条件条款 |\n", "markdownPath": "src/resources/contract-template-management/PRD.md" }, { "type": "markdown", "id": "ct-doc-prd-overview", "title": "背景 · 数据模型 · 业务规则", - "markdown": "## 1. 背景与目标\n\n### 1.1 背景\n\n商用车租赁合同以 Word 文档形式维护,内容长、结构复杂,包含合同主体与 8 个附件章节。不同车型可能需要不同的附件条款组合。法务人员需要:\n\n- 以 **Word 版式保真** 的方式维护整份合同;\n- 按 **章节** 管理内容,并配置 **适用车型**;\n- 发布 **版本**,通过 **启用/停用** 控制模板是否可供租赁合同选用;\n- 在合同主体及租赁订单~安全责任协议各章节中配置 **条件条款**(按订单车辆命中显隐)。\n\n### 1.2 产品目标\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;已发布修改产生新版本并写入版本日志 |\n| 多套模板并存 | 正式/试用、不同车型等多套已发布模板可同时存在;租赁创建时由用户选择 |\n\n### 1.3 非目标(本期不做)\n\n- 采购合同、服务合同等其他分类的完整业务闭环(数据结构预留 `category`,当前仅启用 `lease`)\n- 在线多人协同编辑、审批流配置界面\n- Word 导出回写(本期以 HTML 结构化存储 + 预览为主)\n- 列表页顶部章节 Tab(**仅编辑页**保留章节 Tab)\n\n---\n\n---\n\n## 2. 用户与场景\n\n### 2.1 目标用户\n\n| 角色 | 诉求 |\n|---|---|\n| 法务人员(主用户) | 导入/编辑 Word 模板、发布版本、启用/停用模板 |\n| 客服人员(间接用户) | 草拟新合同时从已启用模板中选择(下游「租赁合同管理」消费) |\n| 运营 / 风控(间接用户) | 查看风控红线条款配置情况 |\n\n### 2.2 核心用户故事\n\n1. **作为法务**,我希望导入一份完整 Word 合同,系统自动拆成 9 个章节,以便分章节维护。\n2. **作为法务**,我希望为「租赁订单」等附件章节绑定适用车型,以便创建合同时只纳入匹配车辆的条款。\n3. **作为法务**,我希望在合同主体及租赁订单~安全责任协议各章节为某段落设置「条件条款」并绑定车型,以便不同车型看到不同正文。\n4. **作为法务**,我希望保存草稿不产生版本号,发布后才生成版本号,以便区分未上线内容。\n5. **作为法务**,我希望对已发布模板执行启用/停用,停用后方可编辑,并在发布变更时自动归档旧版快照到版本日志。\n6. **作为客服**,我希望在创建租赁合同时第一步选择要使用的已启用合同模板。\n7. **作为法务**,我希望在列表展开行查看各章节的风控条款数、条件条款数,以便快速评估模板完整度。\n\n---\n\n---\n\n## 3. 名词解释\n\n| 名词 | 定义 |\n|---|---|\n| Word 文档 / 模板记录 | 列表主表一行,对应一份完整合同文件 |\n| 章节 | 合同内的逻辑切片,共 9 个(1 个主体 + 8 个附件) |\n| 草稿 | `status = draft`,无版本号 |\n| 已发布 | `status = published`,有版本号 `vX.XX` |\n| 启用 | `enabled = true` 的已发布模板,可供租赁合同创建选用 |\n| 停用 | `enabled = false` 的已发布模板,不可编辑/删除,不可被租赁创建选用 |\n| 版本日志 | `versionLogs[]`,记录发布变更前自动归档的快照 |\n| 锁定区 | 编辑区内不可随意修改的段落,HTML 标记 `data-inline-lock=\"1\"` |\n| 风控红线条款 | 需重点合规关注的条款,HTML 标记 `data-risk-redline=\"1\"` |\n| 条件条款 | 各章节可用,按订单车辆命中显隐,HTML 标记 `data-vehicle-clause=\"1\"` |\n| 适用车型绑定 | 附件章节级别配置,决定该章节是否纳入某份合同 |\n| 模板占位符 | 形如 `{{contractCode}}` 的变量,创建合同时由系统替换 |\n\n---\n\n---\n\n## 4. 信息架构\n\n```text\n合同模板管理\n├── 列表页\n│ ├── 筛选区\n│ ├── 工具栏(新增)\n│ └── 嵌套表格\n│ ├── 主表:Word 文件\n│ └── 子表:9 个章节(展开行)\n└── 编辑 / 新增页\n ├── 顶栏(返回、保存、保存并发布)\n ├── 章节 Tab(仅本页展示)\n ├── 条件条款说明(各章节均可使用工具栏「条件」)\n ├── 适用车型绑定(附件章节 Tab 时显示)\n └── 工作区\n ├── 左侧:Word 版式编辑区\n └── 右侧:A4 实时预览\n```\n\n---\n\n---\n\n## 5. 数据模型\n\n### 5.1 模板记录(Word 文档级)\n\n一条记录代表 **一份 Word 合同文件**,不按章节拆成多行。\n\n```typescript\ninterface ContractTemplateDocument {\n id: string; // 唯一 ID\n category: 'lease'; // 合同分类,当前固定商用车租赁\n contractType: string; // 用户自定义合同类型,如「正式合同」「试用合同」\n fileName: string; // Word 文件名,如「现代18吨车型-数智中心版.docx」\n versionName: string; // 展示用版本名称\n versionNo: string | null; // 已发布:v1.00;草稿:null\n status: 'draft' | 'published';\n enabled: boolean; // 已发布模板是否启用(草稿恒为 false)\n versionLogs: VersionLogEntry[]; // 版本变更日志与快照\n sectionContents: Record; // 各章节 HTML\n sectionVehicleBindings: Record; // 各章节车型绑定\n creator: string;\n createTime: string; // YYYY-MM-DD HH:mm\n updater: string;\n updateTime: string;\n remark?: string; // 内部备注(列表不展示)\n}\n\ninterface VersionLogEntry {\n id: string;\n versionNo: string; // 归档时的版本号\n action: string; // 如「发布变更」\n operator: string;\n time: string;\n remark?: string;\n snapshot: {\n fileName: string;\n versionName: string;\n versionNo: string;\n sectionContents: Record;\n sectionVehicleBindings: Record;\n remark?: string;\n archivedAt: string;\n };\n}\n\ninterface VehicleBinding {\n brand: string; // 品牌,如「现代」\n model: string; // 型号,如「18吨氢燃料电池车」\n}\n```\n\n### 5.2 章节枚举(SectionKey)\n\n| key | 章节名称 | 车型绑定 | 条件条款 |\n|---|---|:---:|:---:|\n| `main` | 合同主体 | 否 | 是 |\n| `lease_order` | 租赁订单 | 是 | 是 |\n| `vehicle_cost` | 车辆产生的费用 | 是 | 是 |\n| `safety_notice` | 安全责任告知书 | 是 | 是 |\n| `dynamic_supervision` | 重型普通货运车辆租赁动态监管责任告知与确认书 | 是 | 是 |\n| `safety_commitment` | 安全责任承诺书 | 是 | 是 |\n| `vehicle_spec` | 车辆使用规范和驾驶员要求 | 是 | 是 |\n| `authorization` | 授权委托书 | 是 | 是 |\n| `safety_agreement` | 安全责任协议 | 是 | 是 |\n\n### 5.3 章节内容 HTML 标记规范\n\n研发需保证存储、渲染、统计口径一致:\n\n| 类型 | 选择器 / 属性 | 统计函数 |\n|---|---|---|\n| 锁定区 | `.ct-inline-lock[data-inline-lock=\"1\"]` | `countLockRegions` |\n| 风控红线 | `.ct-risk-redline[data-risk-redline=\"1\"]` | `countRiskRedlineRegions` |\n| 条件条款 | `.ct-vehicle-clause[data-vehicle-clause=\"1\"]` | `countVehicleClauseRegions` |\n| 条件条款车型 | `data-vehicle-bindings`(JSON 数组) | 预览命中逻辑 |\n\n---\n\n---\n\n## 6. 业务规则(全局)\n\n### 6.1 版本号规则\n\n- 格式:`v{major}.{minor}`,如 `v1.00`、`v1.01`\n- **仅发布时生成**;保存草稿时 `versionNo = null`\n- 同分类下递增:取当前已发布版本最大 minor +1;minor ≥ 99 时 major +1、minor 归 0\n- 首版默认 `v1.00`\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布(须先停用) |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**保持已发布与版本号**,仍为停用态 |\n| **保存并发布** | 新建已发布记录 + 版本号,默认启用 | 原记录变已发布 + 版本号,默认启用 | **同记录升级版本号**;旧内容写入 `versionLogs` 快照;发布后默认停用,需手动启用 |\n\n**校验**:保存/发布前,合同主体(`sectionContents.main`)不能为空。\n\n**离开编辑**:有未保存修改时返回列表需二次确认。\n\n### 6.3 启用 / 停用规则\n\n1. 仅 **已发布** 模板具有启用态;草稿显示「—」。\n2. **启用中**:不可编辑、不可删除;可供租赁合同创建选用。\n3. **停用**:需二次确认(提示可能导致标准合同无法正常使用);停用后可编辑、可删除;租赁创建不可选用。\n4. **启用**:直接生效,无需二次确认。\n5. 编辑已发布模板并发布后,模板自动变为 **停用**,需重新启用后下游才可选。\n\n### 6.4 版本日志规则\n\n1. 对已发布模板执行「保存并发布」前,系统将 **当前内容自动归档** 为快照写入 `versionLogs`。\n2. 列表操作列提供「版本日志」,可查看历史版本号、操作人、时间与快照正文(只读)。\n3. 列表主表 **一行对应一份 Word 文件**,历史版本通过日志追溯,不另增列表行。\n\n### 6.5 删除规则\n\n- **启用中** 的已发布模板不可删除。\n- 其余记录可删除;删除前二次确认;删除后不可恢复。\n\n### 6.6 列表排序\n\n按 `updateTime` 降序。\n\n### 6.7 筛选匹配规则\n\n| 字段 | 匹配逻辑 |\n|---|---|\n| 合同类型 | 下拉选择(支持输入模糊搜索候选项);精确匹配 `contractType` |\n| 状态 | 精确匹配 `draft` / `published` |\n| 创建人 | 精确匹配 `creator` |\n| 启用状态 | `enabled`:仅已发布且启用;`disabled`:仅已发布且停用 |\n\n> **已移除**:筛选项「适用品牌/适用车型」、主表列「备注」、「设为标准模板」能力。\n\n---" + "markdown": "## 1. 背景与目标\n\n### 1.1 背景\n\n商用车租赁合同以 Word 文档形式维护,内容长、结构复杂,包含合同主体与 8 个附件章节。不同车型可能需要不同的附件条款组合。法务人员需要:\n\n- 以 **Word 版式保真** 的方式维护整份合同;\n- 按 **章节** 管理内容,并配置 **适用车型**;\n- 发布 **版本**,通过 **启用/停用** 控制模板是否可供租赁合同选用;\n- 在合同主体及租赁订单~安全责任协议各章节中配置 **条件条款**(按订单车辆命中显隐)。\n\n### 1.2 产品目标\n\n| 目标 | 说明 |\n|---|---|\n| 降低维护成本 | 一份 Word 文件 = 一条记录,导入后自动拆分章节 |\n| 保证合规可控 | 支持锁定区、风控红线条款标记与统计 |\n| 支撑业务差异化 | 附件章节按车型绑定;各章节均支持条件条款 |\n| 版本可追溯 | 草稿 / 已发布分离;已发布修改产生新版本并写入版本日志 |\n| 多套模板并存 | 正式/试用、不同车型等多套已发布模板可同时存在;租赁创建时由用户选择 |\n\n### 1.3 非目标(本期不做)\n\n- 采购合同、服务合同等其他分类的完整业务闭环(数据结构预留 `category`,当前仅启用 `lease`)\n- 在线多人协同编辑、审批流配置界面\n- Word 导出回写(本期以 HTML 结构化存储 + 预览为主)\n- 列表页顶部章节 Tab(**仅编辑页**保留章节 Tab)\n\n---\n\n---\n\n## 2. 用户与场景\n\n### 2.1 目标用户\n\n| 角色 | 诉求 |\n|---|---|\n| 法务人员(主用户) | 导入/编辑 Word 模板、发布版本、启用/停用模板 |\n| 客服人员(间接用户) | 草拟新合同时从已启用模板中选择(下游「租赁合同管理」消费) |\n| 运营 / 风控(间接用户) | 查看风控红线条款配置情况 |\n\n### 2.2 核心用户故事\n\n1. **作为法务**,我希望导入一份完整 Word 合同,系统自动拆成 9 个章节,以便分章节维护。\n2. **作为法务**,我希望为「租赁订单」等附件章节绑定适用车型,以便创建合同时只纳入匹配车辆的条款。\n3. **作为法务**,我希望在合同主体及租赁订单~安全责任协议各章节为某段落设置「条件条款」并绑定车型,以便不同车型看到不同正文。\n4. **作为法务**,我希望保存草稿不产生版本号,发布后才生成版本号,以便区分未上线内容。\n5. **作为法务**,我希望对已发布模板执行启用/停用,停用后方可编辑,并在发布变更时自动归档旧版快照到版本日志。\n6. **作为客服**,我希望在创建租赁合同时第一步选择要使用的已启用合同模板。\n7. **作为法务**,我希望在列表展开行查看各章节的风控条款数、条件条款数,以便快速评估模板完整度。\n\n---\n\n---\n\n## 3. 名词解释\n\n| 名词 | 定义 |\n|---|---|\n| Word 文档 / 模板记录 | 列表主表一行,对应一份完整合同文件 |\n| 章节 | 合同内的逻辑切片,共 9 个(1 个主体 + 8 个附件) |\n| 草稿 | `status = draft`,无版本号 |\n| 已发布 | `status = published`,有版本号 `vX.XX` |\n| 启用 | `enabled = true` 的已发布模板,可供租赁合同创建选用 |\n| 停用 | `enabled = false` 的已发布模板,不可编辑/删除,不可被租赁创建选用 |\n| 版本日志 | `versionLogs[]`,记录发布变更前自动归档的快照 |\n| 锁定区 | 编辑区内不可随意修改的段落,HTML 标记 `data-inline-lock=\"1\"` |\n| 风控红线条款 | 需重点合规关注的条款,HTML 标记 `data-risk-redline=\"1\"` |\n| 条件条款 | 各章节可用,按订单车辆命中显隐,HTML 标记 `data-vehicle-clause=\"1\"` |\n| 适用车型绑定 | 附件章节级别配置,决定该章节是否纳入某份合同 |\n| 合同名称 | `contractName`,用户自定义展示名,如「正式合同」「试用合同」 |\n| 类型 | `contractType` 枚举:合同 / 授权委托书 / 转三方协议 / 其他 |\n| 模板占位符 | 形如 `{{contractCode}}` 的变量,创建合同时由系统替换 |\n\n---\n\n---\n\n## 4. 信息架构\n\n```text\n合同模板管理\n├── 列表页\n│ ├── 筛选区\n│ ├── 工具栏(新增)\n│ └── 嵌套表格\n│ ├── 主表:Word 文件\n│ └── 子表:9 个章节(展开行)\n└── 编辑 / 新增页\n ├── 顶栏(返回、保存、保存并发布)\n ├── 章节 Tab(仅本页展示)\n ├── 条件条款说明(各章节均可使用工具栏「条件」)\n ├── 适用车型绑定(附件章节 Tab 时显示)\n └── 工作区\n ├── 左侧:Word 版式编辑区\n └── 右侧:A4 实时预览\n```\n\n---\n\n---\n\n## 5. 数据模型\n\n### 5.1 模板记录(Word 文档级)\n\n一条记录代表 **一份 Word 合同文件**,不按章节拆成多行。\n\n```typescript\ninterface ContractTemplateDocument {\n id: string; // 唯一 ID\n category: 'lease'; // 合同分类,当前固定商用车租赁\n contractName: string; // 合同名称,用户自定义,如「正式合同」\n contractType: string; // 类型枚举:合同 | 授权委托书 | 转三方协议 | 其他\n fileName: string; // Word 文件名,如「现代18吨车型-数智中心版.docx」\n versionName: string; // 展示用版本名称\n versionNo: string | null; // 已发布:v1.00;草稿:null\n status: 'draft' | 'published';\n enabled: boolean; // 已发布模板是否启用(草稿恒为 false)\n versionLogs: VersionLogEntry[]; // 版本变更日志与快照\n sectionContents: Record; // 各章节 HTML\n sectionVehicleBindings: Record; // 各章节车型绑定\n creator: string;\n createTime: string; // YYYY-MM-DD HH:mm\n updater: string;\n updateTime: string;\n remark?: string; // 内部备注(列表不展示)\n}\n\ninterface VersionLogEntry {\n id: string;\n versionNo: string; // 归档时的版本号\n action: string; // 如「发布变更」\n operator: string;\n time: string;\n remark?: string;\n snapshot: {\n fileName: string;\n versionName: string;\n versionNo: string;\n sectionContents: Record;\n sectionVehicleBindings: Record;\n remark?: string;\n archivedAt: string;\n };\n}\n\ninterface VehicleBinding {\n brand: string; // 品牌,如「现代」\n model: string; // 型号,如「18吨氢燃料电池车」\n}\n```\n\n### 5.2 章节枚举(SectionKey)\n\n| key | 章节名称 | 车型绑定 | 条件条款 |\n|---|---|:---:|:---:|\n| `main` | 合同主体 | 否 | 是 |\n| `lease_order` | 租赁订单 | 是 | 是 |\n| `vehicle_cost` | 车辆产生的费用 | 是 | 是 |\n| `safety_notice` | 安全责任告知书 | 是 | 是 |\n| `dynamic_supervision` | 重型普通货运车辆租赁动态监管责任告知与确认书 | 是 | 是 |\n| `safety_commitment` | 安全责任承诺书 | 是 | 是 |\n| `vehicle_spec` | 车辆使用规范和驾驶员要求 | 是 | 是 |\n| `authorization` | 授权委托书 | 是 | 是 |\n| `safety_agreement` | 安全责任协议 | 是 | 是 |\n\n### 5.3 章节内容 HTML 标记规范\n\n研发需保证存储、渲染、统计口径一致:\n\n| 类型 | 选择器 / 属性 | 统计函数 |\n|---|---|---|\n| 锁定区 | `.ct-inline-lock[data-inline-lock=\"1\"]` | `countLockRegions` |\n| 风控红线 | `.ct-risk-redline[data-risk-redline=\"1\"]` | `countRiskRedlineRegions` |\n| 条件条款 | `.ct-vehicle-clause[data-vehicle-clause=\"1\"]` | `countVehicleClauseRegions` |\n| 条件条款车型 | `data-vehicle-bindings`(JSON 数组) | 预览命中逻辑 |\n\n---\n\n---\n\n## 6. 业务规则(全局)\n\n### 6.1 版本号规则\n\n- 格式:`v{major}.{minor}`,如 `v1.00`、`v1.01`\n- **仅发布时生成**;保存草稿时 `versionNo = null`\n- 同分类下递增:取当前已发布版本最大 minor +1;minor ≥ 99 时 major +1、minor 归 0\n- 首版默认 `v1.00`\n\n### 6.2 保存与发布\n\n| 操作 | 新增模式 | 编辑草稿 | 编辑已发布(须先停用) |\n|---|---|---|---|\n| **保存** | 新建草稿记录 | 更新原记录,保持草稿 | 更新原记录,**保持已发布与版本号**,仍为停用态 |\n| **保存并发布** | 新建已发布记录 + 版本号,默认启用 | 原记录变已发布 + 版本号,默认启用 | **同记录升级版本号**;旧内容写入 `versionLogs` 快照;发布后默认停用,需手动启用 |\n\n**校验**:保存/发布前须满足:\n\n- 合同主体(`sectionContents.main`)不能为空;\n- **合同名称**(`contractName`)已填写;\n- **类型**(`contractType`)已选择。\n\n**离开编辑**:有未保存修改时返回列表需二次确认。\n\n### 6.3 启用 / 停用规则\n\n1. 仅 **已发布** 模板具有启用态;草稿显示「—」。\n2. **启用中**:不可编辑、不可删除;可供租赁合同创建选用。\n3. **停用**:需二次确认(提示可能导致标准合同无法正常使用);停用后可编辑、可删除;租赁创建不可选用。\n4. **启用**:直接生效,无需二次确认。\n5. 编辑已发布模板并发布后,模板自动变为 **停用**,需重新启用后下游才可选。\n\n### 6.4 版本日志规则\n\n1. 对已发布模板执行「保存并发布」前,系统将 **当前内容自动归档** 为快照写入 `versionLogs`。\n2. 列表操作列提供「版本日志」,可查看历史版本号、操作人、时间与快照正文(只读)。\n3. 列表主表 **一行对应一份 Word 文件**,历史版本通过日志追溯,不另增列表行。\n\n### 6.5 删除规则\n\n- **启用中** 的已发布模板不可删除。\n- 其余记录可删除;删除前二次确认;删除后不可恢复。\n\n### 6.6 列表排序\n\n按 `updateTime` 降序。\n\n### 6.7 筛选匹配规则\n\n| 字段 | 匹配逻辑 |\n|---|---|\n| 合同名称 | 可搜索下拉;精确匹配 `contractName`(选项来自现有模板去重) |\n| 类型 | 下拉;精确匹配 `contractType` 枚举 |\n| 状态 | 精确匹配 `draft` / `published` |\n| 创建人 | 精确匹配 `creator` |\n| 启用状态 | `enabled`:仅已发布且启用;`disabled`:仅已发布且停用 |\n\n> **已移除**:筛选项「适用品牌/适用车型」、主表列「备注」、「设为标准模板」能力。\n\n---" }, { "type": "markdown", "id": "ct-doc-prd-list", "title": "列表页 PRD", - "markdown": "# 合同模板管理 · 列表页 PRD\n\n## 7. 列表页需求\n\n### 7.1 页面结构\n\n筛选区 + 工具栏 + 嵌套表格。**列表页不展示章节 Tab。**\n\n### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同类型 | 可搜索下拉 | 选项来自现有模板 `contractType` 去重;支持输入模糊搜索 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认\n\n### 7.3 工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增 | 进入新增页,默认章节为「合同主体」,模式为 create |\n\n### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同类型 | `contractType` | 展示用户自定义的合同类型文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---" + "markdown": "# 合同模板管理 · 列表页 PRD\n\n## 7. 列表页需求\n\n### 7.1 页面结构\n\n筛选区 + 工具栏 + 嵌套表格。**列表页不展示章节 Tab。**\n\n### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 可搜索下拉 | 选项来自现有模板 `contractName` 去重;支持输入模糊搜索 |\n| 类型 | 下拉 | 合同 / 授权委托书 / 转三方协议 / 其他 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**布局**:默认首行展示 4 项(合同名称、类型、状态、创建人);第 5 项「启用状态」收入「更多筛选」折叠区(与全局 `vm-filter` 规则一致)。扩展筛选项已设置时,折叠区自动展开并显示角标。\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认\n\n### 7.3 工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增 | 进入新增页,模式为 create;**未导入 Word 前不展示章节 Tab** |\n\n### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `contractName` | 展示用户自定义合同名称;同行展示启用状态 Tag |\n| 类型 | `contractType` | 展示类型枚举文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---" }, { "type": "markdown", "id": "ct-doc-prd-editor", "title": "编辑页 PRD", - "markdown": "# 合同模板管理 · 编辑/新增页 PRD\n\n## 8. 编辑 / 新增页需求\n\n### 8.1 页面结构\n\n顶栏 → 章节 Tab → 条件说明 / 车型绑定(按章节显隐)→ 左右分栏工作区。\n\n### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n### 8.3 章节 Tab\n\n- 共 9 个章节;**横向滚动 Tab 条**展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**。\n- **其他 Tab**:仅展示该章节切片内容。\n\n章节顺序:\n\n1. 合同主体 \n2. 租赁订单 \n3. 车辆产生的费用 \n4. 安全责任告知书 \n5. 重型普通货运车辆租赁动态监管责任告知与确认书 \n6. 安全责任承诺书 \n7. 车辆使用规范和驾驶员要求 \n8. 授权委托书 \n9. 安全责任协议 \n\n### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。适用于合同主体及全部附件章节。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n### 8.5 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。\n\n### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.6.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充\n\n### 8.8 空状态\n\n编辑区无内容时,预览区展示空状态引导;新增模式下突出「导入 Word 模板」按钮。\n\n---" + "markdown": "# 合同模板管理 · 编辑/新增页 PRD\n\n## 8. 编辑 / 新增页需求\n\n### 8.1 页面结构\n\n顶栏 → 章节 Tab(导入 Word 后展示)→ 合同主体配置 / 车型绑定(按章节显隐)→ 左右分栏工作区。\n\n### 8.2 顶栏\n\n| 按钮 | 行为 |\n|---|---|\n| ← 返回列表 | 回到列表页,不自动保存 |\n| 保存 | 见 §6.2 |\n| 保存并发布 | 见 §6.2 |\n\n### 8.3 章节 Tab\n\n- **新增模式**:未导入 Word 时 **不展示** 章节 Tab 导航(含「合同主体」)及合同主体配置卡片。\n- **编辑模式**:优先按记录已保存的 `sectionTabs` 展示;无自定义 Tabs 时按 §9 规则生成。\n- **Tab 生成逻辑(非固定列表)**:\n - 导入 Word 后,系统识别文档中被标记为 **「标题」** 样式的段落(Word 样式名如 `标题`、`标题1`~`标题3` 等,导入后映射为 `h1`~`h6`);\n - 每个标题段落的纯文本生成一个章节 Tab,Tab 文案与标题一致;\n - 第一个标题之前的内容归入 **「合同主体」** Tab(`main`);该 Tab 下编辑/预览 **整份 Word 合并全文**;\n - 其余 Tab 仅展示对应标题至下一标题之间的切片内容;\n - 若文档中 **未识别到任何标题**,回退为按 §9.2 预置章节标记切分的默认章节 Tab(与演示模板一致)。\n- **展示**:横向滚动 Tab 条展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n\n### 8.4 合同主体配置(仅合同主体 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 合同名称输入框 | **必填**;用户自行填写,如「正式合同」 |\n| 类型下拉 | **必填**;选项:合同 / 授权委托书 / 转三方协议 / 其他 |\n| 展示位置 | 章节 Tab 下方配置卡片,与附件章节的「适用车型」卡片交互一致 |\n| 用途 | 同步至列表页「合同名称」「类型」列;租赁合同创建时按合同名称筛选模板 |\n\n**规则**:\n\n- 仅当存在 **合同主体** 章节 Tab 且当前选中该 Tab 时显示本卡片(未导入 Word 时不展示)。\n- 未填写合同名称时无法保存或发布,并提示「请输入合同名称」。\n- 未选择类型时无法保存或发布,并提示「请选择类型」。\n- 切换至租赁订单等附件章节时,显示「适用车型」卡片,本卡片隐藏。\n- 保存/发布时分别写入 `contractName`、`contractType` 字段。\n\n### 8.5 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。适用于合同主体及全部附件章节。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n### 8.6 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。\n\n### 8.7 编辑区(左侧)\n\n#### 8.7.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.7.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.7.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n### 8.8 实时预览(右侧)\n\n#### 8.8.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.8.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.8.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.8.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充\n\n### 8.9 空状态\n\n编辑区无内容时,预览区展示空状态引导;新增模式下突出「导入 Word 模板」按钮。\n\n---" }, { "type": "markdown", "id": "ct-doc-prd-acceptance", "title": "验收标准", - "markdown": "## 16. 验收标准\n\n### 16.1 列表页\n\n- [ ] 筛选项包含:合同类型(可搜索下拉)、状态、创建人、启用状态\n- [ ] 主表含合同类型、启用状态列;操作含启用/停用、版本日志、编辑、删除\n- [ ] 启用中模板编辑/删除按钮禁用;停用需二次确认\n- [ ] 主表每行对应一份 Word 文件,可展开 9 个章节子表\n- [ ] 子表展示:章节名、适用车型、风控条款数、条件条款数\n\n### 16.2 编辑页\n\n- [ ] 章节 Tab 仅在编辑页展示;横向滚动 + 章节列表面板\n- [ ] 导入 Word 后 9 章节正确拆分\n- [ ] 附件章节可绑定车型;各章节均可设条件条款\n- [ ] 预览支持连续/单页切换;各章节可模拟车型验证条件条款\n- [ ] 保存草稿无版本号;发布生成版本号\n- [ ] 编辑已发布(停用后)并发布时版本号递增,旧版写入版本日志\n- [ ] 编辑已发布(停用后)点「保存」不改变发布态与版本号\n- [ ] 有未保存修改时返回列表需确认\n\n### 16.3 跨模块与数据一致性\n\n- [ ] 模板管理停用后,租赁合同创建页不可再选用该模板\n- [ ] 租赁创建预览读取 store 中模板 `sectionContents`(与模板管理编辑一致)\n- [ ] 列表子表「风控条款数」与编辑区统计一致\n- [ ] 列表子表「条件条款数」与各章节实际标记数一致\n\n### 16.4 标注与文档(原型)\n\n- [ ] 原型已接入 `@axhub/annotation`,关键区域有 `data-annotation-id`\n- [ ] 目录路由可切换列表页 / 新增页\n- [ ] `PRD.md` 与 `annotation-source.json` 与当前实现一致\n\n---" + "markdown": "## 16. 验收标准\n\n### 16.1 列表页\n\n- [ ] 筛选项包含:合同名称(可搜索下拉)、类型、状态、创建人、启用状态;超过 4 项时默认折叠扩展筛选项\n- [ ] 主表含合同名称、类型列;操作含启用/停用、版本日志、编辑、删除\n- [ ] 启用中模板编辑/删除按钮禁用;停用需二次确认\n- [ ] 主表每行对应一份 Word 文件,可展开 9 个章节子表\n- [ ] 子表展示:章节名、适用车型、风控条款数、条件条款数\n\n### 16.2 编辑页\n\n- [ ] 章节 Tab 仅在编辑页展示;横向滚动 + 章节列表面板\n- [ ] 新增页未导入 Word 时不展示章节 Tab 与合同主体配置卡片\n- [ ] 导入 Word 后章节 Tab 按「标题」样式自动生成(无标题时回退默认标记拆分)\n- [ ] 附件章节可绑定车型;各章节均可设条件条款\n- [ ] 预览支持连续/单页切换;各章节可模拟车型验证条件条款\n- [ ] 保存草稿无版本号;发布生成版本号\n- [ ] 编辑已发布(停用后)并发布时版本号递增,旧版写入版本日志\n- [ ] 编辑已发布(停用后)点「保存」不改变发布态与版本号\n- [ ] 有未保存修改时返回列表需确认\n\n### 16.3 跨模块与数据一致性\n\n- [ ] 模板管理停用后,租赁合同创建页不可再选用该模板\n- [ ] 租赁创建预览读取 store 中模板 `sectionContents`(与模板管理编辑一致)\n- [ ] 列表子表「风控条款数」与编辑区统计一致\n- [ ] 列表子表「条件条款数」与各章节实际标记数一致\n\n### 16.4 标注与文档(原型)\n\n- [ ] 原型已接入 `@axhub/annotation`,关键区域有 `data-annotation-id`\n- [ ] 目录路由可切换列表页 / 新增页\n- [ ] `PRD.md` 与 `annotation-source.json` 与当前实现一致\n\n---" } ] }, @@ -525,13 +527,13 @@ "type": "markdown", "id": "ct-doc-node-filter", "title": "筛选条件", - "markdown": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同类型 | 可搜索下拉 | 选项来自现有模板 `contractType` 去重;支持输入模糊搜索 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认" + "markdown": "### 7.2 筛选区\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 合同名称 | 可搜索下拉 | 选项来自现有模板 `contractName` 去重;支持输入模糊搜索 |\n| 类型 | 下拉 | 合同 / 授权委托书 / 转三方协议 / 其他 |\n| 状态 | 下拉 | 全部 / 已发布 / 草稿 |\n| 创建人 | 下拉 | 选项来自现有模板 `creator` 去重 |\n| 启用状态 | 下拉 | 全部 / 启用 / 停用(仅作用于已发布记录) |\n\n**布局**:默认首行展示 4 项(合同名称、类型、状态、创建人);第 5 项「启用状态」收入「更多筛选」折叠区(与全局 `vm-filter` 规则一致)。扩展筛选项已设置时,折叠区自动展开并显示角标。\n\n**按钮**:\n\n- **查询**:应用筛选条件\n- **重置**:清空筛选恢复默认" }, { "type": "markdown", "id": "ct-doc-node-table", "title": "嵌套列表", - "markdown": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同类型 | `contractType` | 展示用户自定义的合同类型文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n---\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n---\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---" + "markdown": "### 7.4 主表字段\n\n| 列名 | 字段 | 展示规则 |\n|---|---|---|\n| (展开列) | — | 宽 48px |\n| 合同名称 | `contractName` | 展示用户自定义合同名称;同行展示启用状态 Tag |\n| 类型 | `contractType` | 展示类型枚举文案 |\n| 版本号 | `versionNo` | 草稿显示「—」 |\n| 状态 | `status` | 已发布 / 草稿 Tag |\n| 启用状态 | `enabled` | 已发布:启用/停用;草稿:「—」 |\n| 创建时间 | `createTime` | — |\n| 创建人 | `creator` | 展示短名(如 `法务-王静` → `王静`) |\n| 最后更新时间 | `updateTime` | — |\n| 更新人 | `updater` | 同创建人展示规则 |\n| 操作 | — | 见 7.5 |\n\n**分页**:每页 10 条,展示总条数。\n\n---\n\n### 7.5 主表操作\n\n| 操作 | 显示条件 | 行为 |\n|---|---|---|\n| 启用 / 停用 | 已发布 | 停用需确认;启用直接生效 |\n| 版本日志 | 已发布 | 查看历史快照 |\n| 编辑 | 草稿,或已发布且停用 | 启用中按钮禁用 |\n| 删除 | 草稿,或已发布且停用 | 启用中按钮禁用;删除前确认 |\n\n---\n\n### 7.6 子表(展开行)\n\n主表每行可展开,展示该 Word 文档下 **9 个章节** 的摘要(原生 HTML 表,非嵌套 Ant Design Table)。\n\n| 列名 | 说明 |\n|---|---|\n| 章节 | 章节名称 |\n| 适用车型 | 附件章节展示绑定列表,格式「品牌 · 型号」;多个用「;」连接;未绑定显示「全部车型」;合同主体显示「—」 |\n| 风控条款数 | 该章节 HTML 内风控红线标记数量 |\n| 条件条款数 | 按各章节 HTML 统计条件条款标记数量 |\n\n---" } ] }, @@ -554,13 +556,13 @@ "type": "markdown", "id": "ct-doc-node-tabs", "title": "章节 Tab", - "markdown": "### 8.3 章节 Tab\n\n- 共 9 个章节;**横向滚动 Tab 条**展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n- **合同主体 Tab**:编辑/预览 **整份 Word 合并全文**。\n- **其他 Tab**:仅展示该章节切片内容。\n\n章节顺序:\n\n1. 合同主体 \n2. 租赁订单 \n3. 车辆产生的费用 \n4. 安全责任告知书 \n5. 重型普通货运车辆租赁动态监管责任告知与确认书 \n6. 安全责任承诺书 \n7. 车辆使用规范和驾驶员要求 \n8. 授权委托书 \n9. 安全责任协议\n\n---\n\n### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。适用于合同主体及全部附件章节。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n---\n\n### 8.5 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。" + "markdown": "### 8.3 章节 Tab\n\n- **新增模式**:未导入 Word 时 **不展示** 章节 Tab 导航(含「合同主体」)及合同主体配置卡片。\n- **编辑模式**:优先按记录已保存的 `sectionTabs` 展示;无自定义 Tabs 时按 §9 规则生成。\n- **Tab 生成逻辑(非固定列表)**:\n - 导入 Word 后,系统识别文档中被标记为 **「标题」** 样式的段落(Word 样式名如 `标题`、`标题1`~`标题3` 等,导入后映射为 `h1`~`h6`);\n - 每个标题段落的纯文本生成一个章节 Tab,Tab 文案与标题一致;\n - 第一个标题之前的内容归入 **「合同主体」** Tab(`main`);该 Tab 下编辑/预览 **整份 Word 合并全文**;\n - 其余 Tab 仅展示对应标题至下一标题之间的切片内容;\n - 若文档中 **未识别到任何标题**,回退为按 §9.2 预置章节标记切分的默认章节 Tab(与演示模板一致)。\n- **展示**:横向滚动 Tab 条展示全部章节,右侧提供「章节列表」按钮打开可搜索的章节面板(便于长标题章节快速定位)。\n- 切换 Tab 后,编辑区与预览区 **同步切换** 至对应章节内容。\n\n---\n\n### 8.5 条件条款说明(合同主体及租赁订单~安全责任协议)\n\n展示说明卡片,文案要点:\n\n> 在编辑区选中段落后,点击工具栏「条件」并指定适用车型。适用于合同主体及全部附件章节。创建合同时,订单车辆任意命中即纳入正文;右侧预览可选择模拟车型验证显隐。\n\n---\n\n### 8.6 适用车型绑定(仅附件章节 Tab)\n\n| 元素 | 说明 |\n|---|---|\n| 品牌下拉 | 来自车型主数据 |\n| 型号下拉 | 随品牌联动 |\n| 添加车型 | 追加到当前章节绑定列表 |\n| 已绑定 Tag | 可删除单项 |\n| 空状态文案 | 「当前未绑定车型,表示全部车型适用」 |\n\n**规则**:\n\n- 绑定保存在 `sectionVehicleBindings[当前章节 key]`。\n- 切换章节 Tab 时加载对应章节的绑定到编辑表单。\n- 保存时将各章节绑定一并写入文档记录。" }, { "type": "markdown", "id": "ct-doc-node-workspace", "title": "编辑与预览", - "markdown": "### 8.6 编辑区(左侧)\n\n#### 8.6.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.6.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.6.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n---\n\n### 8.7 实时预览(右侧)\n\n#### 8.7.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.7.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.7.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.7.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充" + "markdown": "### 8.7 编辑区(左侧)\n\n#### 8.7.1 面板头操作\n\n| 按钮 | 说明 |\n|---|---|\n| { } 模板占位符 | 弹层展示变量分组,点击插入光标处 |\n| 导入 Word 模板 | 接受 `.doc` / `.docx`;导入后按 §9 拆分章节 |\n\n#### 8.7.2 编辑能力\n\n- **Word 版式保真**:A4 分页编辑,保留 Word 导出样式类名。\n- **富文本工具栏**(节选):\n - 字号、加粗、对齐等基础格式\n - **锁定**:选中内容设为锁定区\n - **解锁**:解除锁定\n - **风控**:标记风控红线条款\n - **条件**:在各章节选中段落绑定车型条件\n\n#### 8.7.3 条件条款交互\n\n**适用范围**:合同主体、租赁订单、车辆产生的费用、安全责任告知书、重型普通货运车辆租赁动态监管责任告知与确认书、安全责任承诺书、车辆使用规范和驾驶员要求、授权委托书、安全责任协议。\n\n1. 用户选中段落或文字;\n2. 点击工具栏「条件」;\n3. 为选中内容绑定一个或多个「品牌 · 型号」;\n4. 生成 `.ct-vehicle-clause` 包裹结构,并写入 `data-vehicle-bindings`;\n5. 创建合同时:订单车辆 **任意一辆** 命中绑定车型 → 段落纳入正文;否则隐藏。\n\n---\n\n### 8.8 实时预览(右侧)\n\n#### 8.8.1 预览模式\n\n| 模式 | 说明 |\n|---|---|\n| 连续预览 | 多页连续滚动 |\n| 单页预览 | 按 A4 单页翻页 |\n\n#### 8.8.2 预览顶栏统计\n\n展示当前章节:\n\n`锁定 N 处 · 风控红线 M 处 · 条件条款 K 处`\n\n当前章节支持条件且已选模拟车型时追加:`(预览命中 X)`\n\n#### 8.8.3 模拟车型\n\n- 在合同主体及全部附件章节可用;多选下拉,选项来自车型主数据(品牌 · 型号)。\n- 未选车型时:**隐藏**所有条件条款段落(与创建合同时无车辆命中行为一致)。\n- 选中后按命中规则过滤并展开条件条款正文。\n\n#### 8.8.4 占位符预览\n\n预览时替换部分占位符为样例值,如:\n\n- `{{customerName}}` → `【承租方公司名称】`\n- `{{contractCode}}` → `20260001`\n- 甲方相关变量按预览所选出租方公司填充" } ] }, diff --git a/src/prototypes/contract-template-management/contract-template-docx-fidelity.js b/src/prototypes/contract-template-management/contract-template-docx-fidelity.js new file mode 100644 index 0000000..f046c50 --- /dev/null +++ b/src/prototypes/contract-template-management/contract-template-docx-fidelity.js @@ -0,0 +1,27 @@ +/** + * docx-preview 高保真 Word 渲染(预览区) + */ +import { renderAsync } from 'docx-preview'; + +var DEFAULT_RENDER_OPTIONS = { + className: 'ct-docx-preview', + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + ignoreFonts: false, + breakPages: true, + ignoreLastRenderedPageBreak: true, + trimXmlDeclaration: true, + useBase64URL: true, +}; + +export async function renderDocxPreview(container, arrayBuffer, options) { + if (!container || !arrayBuffer) return; + container.innerHTML = ''; + await renderAsync( + arrayBuffer, + container, + null, + Object.assign({}, DEFAULT_RENDER_OPTIONS, options || {}) + ); +} diff --git a/src/prototypes/contract-template-management/contract-template-section-headings.js b/src/prototypes/contract-template-management/contract-template-section-headings.js new file mode 100644 index 0000000..408b524 --- /dev/null +++ b/src/prototypes/contract-template-management/contract-template-section-headings.js @@ -0,0 +1,173 @@ +/** + * 从 Word HTML 中按「标题」段落提取章节 Tab,并拆分/合并章节内容。 + */ +import { DOCUMENT_SECTION_TABS } from './contract-template-section-html.js'; + +var DEFAULT_MAIN_TAB = { key: 'main', label: '合同主体' }; +var HEADING_TAGS = { H1: true, H2: true, H3: true, H4: true, H5: true, H6: true }; + +export function getDefaultSectionTabs() { + return DOCUMENT_SECTION_TABS.slice(); +} + +export function emptySectionMapForTabs(tabs) { + var map = {}; + (tabs || []).forEach(function (tab) { + map[tab.key] = ''; + }); + return map; +} + +function normalizeHeadingText(text) { + return String(text || '').replace(/\s+/g, ' ').trim(); +} + +function slugifySectionKey(label, index) { + var slug = String(label || '') + .toLowerCase() + .replace(/[^\u4e00-\u9fa5a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 24); + return slug ? 'section_' + slug + '_' + index : 'section_' + index; +} + +function isSectionTitleElement(node) { + if (!node || node.nodeType !== 1) return false; + if (!node.classList || !node.classList.contains('ct-word-section-title')) return false; + if (node.closest && node.closest('table')) return false; + var text = normalizeHeadingText(node.textContent); + return text.length > 0 && text.length <= 80; +} + +function isHeadingElement(node) { + if (!node || node.nodeType !== 1) return false; + if (isSectionTitleElement(node)) return true; + if (!HEADING_TAGS[node.nodeName]) return false; + if (node.closest && node.closest('table')) return false; + var text = normalizeHeadingText(node.textContent); + return text.length > 0 && text.length <= 80; +} + +function collectHeadingElements(root) { + var headings = []; + if (!root) return headings; + var seen = new Set(); + function push(node) { + if (!node || seen.has(node)) return; + if (!isHeadingElement(node)) return; + seen.add(node); + headings.push(node); + } + root.querySelectorAll('.ct-word-section-title,h1,h2,h3,h4,h5,h6').forEach(push); + return headings; +} + +/** 从 Word HTML 提取章节 Tab;无标题时返回 null(调用方回退默认 Tab) */ +export function extractSectionTabsFromHtml(html) { + if (typeof document === 'undefined') return null; + var source = String(html || '').trim(); + if (!source) return null; + + var div = document.createElement('div'); + div.innerHTML = source; + var headings = collectHeadingElements(div); + if (!headings.length) return null; + + var tabs = [Object.assign({}, DEFAULT_MAIN_TAB)]; + var usedKeys = { main: true }; + + headings.forEach(function (heading, index) { + var label = normalizeHeadingText(heading.textContent); + if (!label) return; + var key = slugifySectionKey(label, index + 1); + while (usedKeys[key]) { + key = key + '_' + index; + } + usedKeys[key] = true; + tabs.push({ key: key, label: label, headingText: label }); + }); + + return tabs.length > 1 ? tabs : null; +} + +function isWordBlockNode(node) { + if (!node) return false; + if (node.nodeType === 3) return !!String(node.textContent || '').trim(); + return node.nodeType === 1 && node.nodeName !== 'STYLE'; +} + +function detectDynamicSectionStartKey(node, tabs) { + if (!node || node.nodeType !== 1 || !tabs || !tabs.length) return null; + if (!isHeadingElement(node)) return null; + var text = normalizeHeadingText(node.textContent); + for (var i = 1; i < tabs.length; i++) { + if (tabs[i].label === text || tabs[i].headingText === text) { + return tabs[i].key; + } + } + return null; +} + +/** 按章节 Tab 将整份 HTML 拆为 section map */ +export function splitHtmlBySectionTabs(html, tabs) { + var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs(); + var source = String(html || ''); + var result = emptySectionMapForTabs(sectionTabs); + if (!source.trim()) return result; + + if (typeof document === 'undefined') { + return result; + } + + var div = document.createElement('div'); + div.innerHTML = source.replace(/]*>[\s\S]*?<\/style>/gi, ''); + var root = div.querySelector('.ct-word-doc') || div; + var children = []; + root.childNodes.forEach(function (node) { + if (isWordBlockNode(node)) children.push(node); + }); + + if (!children.length) { + result.main = source; + return result; + } + + var holders = {}; + var currentKey = 'main'; + children.forEach(function (child) { + var startKey = detectDynamicSectionStartKey(child, sectionTabs); + if (startKey) currentKey = startKey; + if (!holders[currentKey]) holders[currentKey] = document.createElement('div'); + holders[currentKey].appendChild(child.cloneNode(true)); + }); + + sectionTabs.forEach(function (tab) { + result[tab.key] = holders[tab.key] ? holders[tab.key].innerHTML : ''; + }); + return result; +} + +/** 将章节 map 合并为整份 HTML */ +export function mergeHtmlBySectionTabs(sectionMap, tabs) { + var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs(); + var map = sectionMap || {}; + var out = String(map.main || ''); + sectionTabs.slice(1).forEach(function (tab) { + var chunk = map[tab.key]; + if (chunk) out += String(chunk); + }); + return out; +} + +export function resolveSectionTabs(record) { + if (record && Array.isArray(record.sectionTabs) && record.sectionTabs.length) { + return record.sectionTabs; + } + return getDefaultSectionTabs(); +} + +export function getSectionTabLabel(tabs, key) { + var list = tabs || getDefaultSectionTabs(); + var tab = list.find(function (item) { return item.key === key; }); + return tab ? tab.label : key; +} diff --git a/src/prototypes/contract-template-management/contract-template-seed.js b/src/prototypes/contract-template-management/contract-template-seed.js index 9793244..e3998cd 100644 --- a/src/prototypes/contract-template-management/contract-template-seed.js +++ b/src/prototypes/contract-template-management/contract-template-seed.js @@ -37,7 +37,8 @@ function getTrialAgreementSectionMap(fileName) { function createWordDocument(params) { return Object.assign({ category: 'lease', - contractType: '', + contractName: '', + contractType: '合同', fileName: '未命名合同.docx', versionName: '未命名合同', versionNo: null, @@ -62,7 +63,8 @@ export function buildInitialWordTemplates() { return [ createWordDocument({ id: 'doc-1', - contractType: '现代18吨正式合同', + contractName: '现代18吨正式合同', + contractType: '合同', fileName: '现代18吨车型-数智中心版.docx', versionName: '现代18吨车型-数智中心版', versionNo: 'v1.00', @@ -99,7 +101,8 @@ export function buildInitialWordTemplates() { }), createWordDocument({ id: 'doc-2', - contractType: '正式合同', + contractName: '正式合同', + contractType: '合同', fileName: '2026年标准商用车租赁合同.docx', versionName: '2026年标准商用车租赁合同', versionNo: 'v1.01', @@ -115,7 +118,8 @@ export function buildInitialWordTemplates() { }), createWordDocument({ id: 'doc-3', - contractType: '正式合同', + contractName: '正式合同', + contractType: '合同', fileName: '2026年二季度草案.docx', versionName: '2026年二季度草案', versionNo: null, @@ -130,7 +134,8 @@ export function buildInitialWordTemplates() { }), createWordDocument({ id: 'doc-4', - contractType: '试用合同', + contractName: '试用合同', + contractType: '合同', fileName: '商用车试用协议.docx', versionName: '商用车试用协议', versionNo: 'v1.00', @@ -146,7 +151,8 @@ export function buildInitialWordTemplates() { }), createWordDocument({ id: 'doc-5', - contractType: '现代18吨试用合同', + contractName: '现代18吨试用合同', + contractType: '合同', fileName: '商用车试用协议-现代18吨.docx', versionName: '商用车试用协议-现代18吨', versionNo: 'v1.00', diff --git a/src/prototypes/contract-template-management/contract-template-store.js b/src/prototypes/contract-template-management/contract-template-store.js index ffbdfd3..c76a2f6 100644 --- a/src/prototypes/contract-template-management/contract-template-store.js +++ b/src/prototypes/contract-template-management/contract-template-store.js @@ -4,7 +4,7 @@ */ import { - getContractTypeLabel, + getContractNameLabel, normalizeContractType, resolveContractType, } from './contract-template-types.js'; @@ -12,6 +12,7 @@ import { var STORAGE_KEY = 'oneos_contract_templates_v1'; var templates = null; var listeners = new Set(); +var lastWriteSucceeded = true; function readFromStorage() { if (typeof localStorage !== 'undefined') { @@ -37,22 +38,33 @@ function readFromStorage() { } function writeToStorage() { - if (!templates) return; + if (!templates) { + lastWriteSucceeded = true; + return true; + } var payload = JSON.stringify(templates); + var localOk = true; + var sessionOk = true; if (typeof localStorage !== 'undefined') { try { localStorage.setItem(STORAGE_KEY, payload); } catch (err) { - /* 原型忽略存储失败 */ + localOk = false; } } if (typeof sessionStorage !== 'undefined') { try { sessionStorage.setItem(STORAGE_KEY, payload); } catch (err) { - /* 原型忽略存储失败 */ + sessionOk = false; } } + lastWriteSucceeded = localOk || sessionOk; + return lastWriteSucceeded; +} + +export function wasLastContractTemplateWriteOk() { + return lastWriteSucceeded; } function notify() { @@ -117,7 +129,7 @@ var VEHICLE_HINT_BY_ID = { }; function inferKind(doc) { - return resolveContractType(doc); + return resolveContractName(doc); } function inferVehicleHint(doc) { @@ -126,14 +138,14 @@ function inferVehicleHint(doc) { } export function mapTemplateToLeaseOption(doc) { - var contractType = resolveContractType(doc); + var contractName = resolveContractName(doc); return { id: doc.id, title: doc.versionName || stripExtension(doc.fileName) || doc.id, fileName: doc.fileName || doc.versionName || '', versionNo: doc.versionNo || '—', - contractType: contractType, - contractTypeLabel: getContractTypeLabel(contractType), + contractType: contractName, + contractTypeLabel: getContractNameLabel(contractName), kind: inferKind(doc), vehicleHint: inferVehicleHint(doc), }; diff --git a/src/prototypes/contract-template-management/contract-template-types.js b/src/prototypes/contract-template-management/contract-template-types.js index b041dc0..345995e 100644 --- a/src/prototypes/contract-template-management/contract-template-types.js +++ b/src/prototypes/contract-template-management/contract-template-types.js @@ -1,23 +1,78 @@ /** - * 合同类型 — 用户自定义文本,合同模板管理与租赁合同创建共用 + * 合同模板字段口径: + * - contractName:合同名称(用户自定义文本) + * - contractType:类型枚举(合同 / 授权委托书 / 转三方协议 / 其他) */ -export function normalizeContractType(value) { +export var CONTRACT_TYPE_ENUM_OPTIONS = ['合同', '授权委托书', '转三方协议', '其他']; + +export var DEFAULT_DOCUMENT_CONTRACT_TYPE = '合同'; + +export function isDocumentContractTypeEnum(value) { + return CONTRACT_TYPE_ENUM_OPTIONS.indexOf(String(value || '').trim()) >= 0; +} + +export function normalizeContractName(value) { return String(value || '').trim(); } -export function getContractTypeLabel(value) { - var text = normalizeContractType(value); +export function normalizeDocumentContractType(value) { + var text = String(value || '').trim(); + return isDocumentContractTypeEnum(text) ? text : DEFAULT_DOCUMENT_CONTRACT_TYPE; +} + +export function getContractNameLabel(value) { + var text = normalizeContractName(value); return text || '—'; } -/** 无 contractType 字段时,按文件名推断(兼容旧数据) */ -export function inferContractTypeFromDoc(doc) { - if (doc && doc.contractType) return normalizeContractType(doc.contractType); +export function getDocumentContractTypeLabel(value) { + var text = normalizeDocumentContractType(value); + return text || '—'; +} + +/** 无 contractName 时,按文件名推断(兼容旧数据) */ +export function inferContractNameFromDoc(doc) { + if (doc && doc.contractName) return normalizeContractName(doc.contractName); + var legacy = doc && doc.contractType; + if (legacy && !isDocumentContractTypeEnum(legacy)) { + return normalizeContractName(legacy); + } var name = String((doc && (doc.fileName || doc.versionName)) || ''); return /试用/.test(name) ? '试用合同' : '正式合同'; } -export function resolveContractType(doc) { - return inferContractTypeFromDoc(doc); +export function resolveContractName(doc) { + return inferContractNameFromDoc(doc); +} + +export function resolveDocumentContractType(doc) { + if (doc && doc.contractName != null && isDocumentContractTypeEnum(doc.contractType)) { + return normalizeDocumentContractType(doc.contractType); + } + if (doc && isDocumentContractTypeEnum(doc.contractType)) { + return normalizeDocumentContractType(doc.contractType); + } + return DEFAULT_DOCUMENT_CONTRACT_TYPE; +} + +/** @deprecated 租赁合同等下游仍按「合同名称」消费,保留别名 */ +export function normalizeContractType(value) { + return normalizeContractName(value); +} + +/** @deprecated 保留别名,语义为合同名称 */ +export function getContractTypeLabel(value) { + return getContractNameLabel(value); +} + +/** @deprecated 保留别名,语义为合同名称 */ +export function resolveContractType(doc) { + return resolveContractName(doc); +} + +export function getDocumentContractTypeSelectOptions() { + return CONTRACT_TYPE_ENUM_OPTIONS.map(function (value) { + return { value: value, label: value }; + }); } diff --git a/src/prototypes/contract-template-management/contract-template-word-import.js b/src/prototypes/contract-template-management/contract-template-word-import.js new file mode 100644 index 0000000..d07d509 --- /dev/null +++ b/src/prototypes/contract-template-management/contract-template-word-import.js @@ -0,0 +1,341 @@ +/** + * Word (.doc / .docx) → HTML 导入 + */ +import mammoth from 'mammoth'; + +var MAMMOTH_STYLE_MAP = [ + "p[style-name='Title'] => h1.ct-word-doc-title:fresh", + "p[style-name='Heading 1'] => p.ct-word-section-title:fresh", + "p[style-name='Heading 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh", + "p[style-name='Heading 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh", + "p[style-name='标题'] => p.ct-word-section-title:fresh", + "p[style-name='标题 1'] => p.ct-word-section-title:fresh", + "p[style-name='标题1'] => p.ct-word-section-title:fresh", + "p[style-name='标题 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh", + "p[style-name='标题2'] => p.ct-word-section-title.ct-word-section-title--2:fresh", + "p[style-name='标题 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh", + "p[style-name='标题3'] => p.ct-word-section-title.ct-word-section-title--3:fresh", + "highlight => mark.ct-word-highlight", + "r[style-name='Highlight'] => mark.ct-word-highlight", + "r[style-name='高亮强调'] => mark.ct-word-highlight", +]; + +var OLE_METADATA_PATTERNS = [ + /Root Entry/i, + /SummaryInformation/i, + /DocumentSummaryInformation/i, + /\bWordDocument\b/, + /WpsCustomData/i, + /MsoDataStore/i, + /PKSOProductBuildVer/i, + /KSOProductBuildVer/i, + /\bICV\b/, + /\bFormText\b/, + /MERGEFORMAT/i, + /OLE_LINK/i, +]; + +var LEGACY_DOC_JUNK_EXACT = { + 'Root Entry': true, + 'SummaryInformation': true, + 'DocumentSummaryInformation': true, + 'WordDocument': true, + 'Word.Document': true, + 'Data': true, + '1Table': true, + 'WpsCustomData': true, + 'MsoDataStore': true, + 'Normal': true, + 'Default Paragraph Font': true, + 'Table Normal': true, +}; + +export var IMPORTED_WORD_STYLE_ELEMENT_ID = 'ct-imported-word-styles'; + +export function buildImportedWordBaselineCss() { + return [ + '.ct-word-doc--imported{font-family:SimSun,"宋体","Songti SC",STSong,serif;font-size:11pt;line-height:1.15;color:#000;letter-spacing:normal;word-break:normal;overflow-wrap:break-word}', + '.ct-word-doc--imported p,.ct-word-doc--imported li{margin:0;text-align:inherit;text-indent:inherit}', + '.ct-word-doc--imported h1,.ct-word-doc--imported h2,.ct-word-doc--imported h3,.ct-word-doc--imported h4,.ct-word-doc--imported h5,.ct-word-doc--imported h6{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit}', + '.ct-word-doc--imported .ct-word-section-title{font-weight:700;margin:0;text-align:justify}', + '.ct-word-doc--imported .ct-word-doc-title{text-align:center;font-weight:700;font-size:18pt;margin:0 0 6pt}', + '.ct-word-doc--imported mark.ct-word-highlight,.ct-word-doc--imported .ct-word-highlight{background-color:#ffff00;color:inherit;padding:0}', + '.ct-word-doc--imported table{border-collapse:collapse;width:auto;max-width:100%;table-layout:auto}', + '.ct-word-doc--imported td,.ct-word-doc--imported th{vertical-align:top}', + '.ct-word-doc--imported img{max-width:100%;height:auto}', + '.ct-word-doc--imported strong,.ct-word-doc--imported b{font-weight:700}', + ].join('\n'); +} + +function scopeCssToImportedRoot(cssText) { + var scoped = String(cssText || '').trim(); + if (!scoped) return ''; + if (scoped.indexOf('.ct-word-doc--imported') >= 0) return scoped; + return scoped.replace(/(^|\})([^{@]+)\{/g, function (match, prefix, selector) { + var parts = selector.split(',').map(function (part) { + var trimmed = part.trim(); + if (!trimmed || trimmed.indexOf('@') === 0) return trimmed; + return '.ct-word-doc--imported ' + trimmed; + }); + return prefix + parts.join(',') + '{'; + }); +} + +export function extractInlineStylesFromImportedHtml(html) { + var content = String(html || ''); + var cssParts = []; + content = content.replace(/]*>([\s\S]*?)<\/style>/gi, function (_, css) { + if (css && css.trim()) cssParts.push(css.trim()); + return ''; + }); + var cssText = cssParts.map(scopeCssToImportedRoot).filter(Boolean).join('\n'); + return { html: content.trim(), cssText: cssText }; +} + +export async function extractDocxThemeStyles(arrayBuffer) { + try { + var JSZip = (await import('jszip')).default; + var zip = await JSZip.loadAsync(arrayBuffer); + var stylesFile = zip.file('word/styles.xml'); + if (!stylesFile) return ''; + var stylesXml = await stylesFile.async('text'); + var fontFamily = '宋体'; + var fontSize = '11pt'; + var docDefaultsMatch = stylesXml.match(//); + if (docDefaultsMatch) { + var block = docDefaultsMatch[0]; + var eastAsia = block.match(/w:eastAsia="([^"]+)"/); + if (eastAsia && eastAsia[1]) fontFamily = eastAsia[1]; + var ascii = block.match(/w:ascii="([^"]+)"/); + if (!eastAsia && ascii && ascii[1]) fontFamily = ascii[1]; + var sz = block.match(/]*w:val="(\d+)"/); + if (sz && sz[1]) fontSize = (parseInt(sz[1], 10) / 2) + 'pt'; + } + return '.ct-word-doc--imported{font-family:"' + fontFamily + '",SimSun,"宋体","Songti SC",serif;font-size:' + fontSize + ';line-height:1.15;color:#000}'; + } catch (e) { + return ''; + } +} + +export function injectImportedWordStyles(cssText) { + if (typeof document === 'undefined') return; + var merged = [buildImportedWordBaselineCss(), String(cssText || '').trim()].filter(Boolean).join('\n'); + var styleEl = document.getElementById(IMPORTED_WORD_STYLE_ELEMENT_ID); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = IMPORTED_WORD_STYLE_ELEMENT_ID; + document.head.appendChild(styleEl); + } + if (styleEl.textContent !== merged) styleEl.textContent = merged; +} + +export function prepareImportedWordHtml(html, extraCss) { + var source = sanitizeImportedWordHtml(String(html || '')); + if (!isImportedWordDocHtml(source)) return source; + var extracted = extractInlineStylesFromImportedHtml(source); + injectImportedWordStyles([extracted.cssText, extraCss].filter(Boolean).join('\n')); + var body = extracted.html.trim(); + if (!body) return ''; + if (body.indexOf('ct-word-doc--imported') >= 0) return body; + return wrapWordHtml(body); +} + +function wrapWordHtml(html) { + var body = String(html || '').trim(); + if (!body) return ''; + if (body.indexOf('ct-word-doc') >= 0) return body; + return '
' + body + '
'; +} + +function escapeHtml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function detectWordFileFormat(arrayBuffer) { + var view = new Uint8Array(arrayBuffer || []); + if (view.length >= 4 && view[0] === 0x50 && view[1] === 0x4b) return 'docx'; + if (view.length >= 8 && view[0] === 0xd0 && view[1] === 0xcf && view[2] === 0x11 && view[3] === 0xe0) return 'doc'; + return 'unknown'; +} + +export function isImportedWordDocHtml(html) { + return /ct-word-doc--imported/.test(String(html || '')); +} + +export function isCorruptWordImportHtml(html) { + var source = String(html || ''); + if (!source.trim()) return true; + var plain = source.replace(/]*>[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, ''); + if (!plain.replace(/\s/g, '')) return true; + var hits = 0; + OLE_METADATA_PATTERNS.forEach(function (pattern) { + if (pattern.test(plain)) hits += 1; + }); + if (hits >= 2) return true; + if (/[\u4e00-\u9fff]/.test(plain)) return false; + if (/合同|甲方|乙方|租赁|附件/.test(plain)) return false; + return hits >= 1 && plain.length < 500; +} + +export function sanitizeImportedWordHtml(html) { + var content = String(html || '').trim(); + if (!content) return ''; + content = content + .replace(/<\/p>\s*n\s*

\s*n\s*

\s*n\s*= 0) { + content = content.replace('ct-word-doc', 'ct-word-doc ct-word-doc--imported'); + } + return content; +} + +function looksLikeHeadingLine(line) { + var text = String(line || '').trim(); + if (!text || text.length > 80) return false; + if (/^附件\s*\d+/i.test(text)) return true; + if (/^第[一二三四五六七八九十\d]+[章节部分]/i.test(text)) return true; + if (/^[一二三四五六七八九十\d]+[、.::]/.test(text) && text.length <= 40) return true; + return text.length <= 24 && !/[。;,,;]/.test(text); +} + +function isLegacyDocJunkLine(line) { + var text = String(line || '').trim(); + if (!text) return true; + if (LEGACY_DOC_JUNK_EXACT[text]) return true; + if (/^Text\d+$/i.test(text)) return true; + if (/^P\s*\d+$/i.test(text)) return true; + if (/^[\d\s*桰漀]+$/.test(text)) return true; + if (text.length <= 28 && !/[\u4e00-\u9fff]/.test(text) && /^[\x00-\x7F\s]+$/.test(text)) return true; + if (OLE_METADATA_PATTERNS.some(function (pattern) { return pattern.test(text); })) return true; + return false; +} + +function plainTextToHtml(text) { + var lines = String(text || '') + .replace(/\r/g, '\n') + .split('\n') + .map(function (line) { return line.trim(); }) + .filter(function (line) { return line && !isLegacyDocJunkLine(line); }); + if (!lines.length) return ''; + return lines.map(function (line) { + if (looksLikeHeadingLine(line)) { + return '

' + escapeHtml(line) + '

'; + } + return '

' + escapeHtml(line) + '

'; + }).join(''); +} + +/** 从旧版 .doc 二进制中粗提取可读文本(原型层兜底) */ +function extractTextFromLegacyDoc(arrayBuffer) { + var view = new Uint8Array(arrayBuffer); + var chunks = []; + var current = ''; + + function flush() { + var text = current.replace(/\s+/g, ' ').trim(); + if (text.length >= 4 && !isLegacyDocJunkLine(text)) chunks.push(text); + current = ''; + } + + for (var i = 0; i < view.length - 1; i += 2) { + var code = view[i] | (view[i + 1] << 8); + if ( + (code >= 0x20 && code <= 0x7e) + || (code >= 0x4e00 && code <= 0x9fff) + || code === 0x0a + || code === 0x0d + || code === 0x3001 + || code === 0x3002 + || code === 0xff1a + || code === 0xff1b + ) { + current += String.fromCharCode(code); + } else { + flush(); + } + } + flush(); + + var merged = chunks.join('\n'); + if (!merged.trim()) { + var latin = new TextDecoder('latin1').decode(view); + merged = latin.replace(/[^\x20-\x7E\u4e00-\u9fff\r\n]+/g, '\n'); + } + return merged.replace(/\n{3,}/g, '\n\n').trim(); +} + +export async function convertDocxArrayBufferToHtml(arrayBuffer) { + if (detectWordFileFormat(arrayBuffer) !== 'docx') { + throw new Error('不是有效的 .docx 文件'); + } + var themeCss = await extractDocxThemeStyles(arrayBuffer); + var result = await mammoth.convertToHtml( + { arrayBuffer: arrayBuffer }, + { + styleMap: MAMMOTH_STYLE_MAP, + includeDefaultStyleMap: true, + includeEmbeddedStyleMap: true, + ignoreEmptyParagraphs: false, + } + ); + var html = String(result.value || '').trim(); + if (!html || isCorruptWordImportHtml(html)) { + var messages = (result.messages || []).map(function (m) { return m.message; }).filter(Boolean); + throw new Error(messages[0] || 'Word 文件解析结果无效'); + } + return prepareImportedWordHtml(wrapWordHtml(html), themeCss); +} + +export async function convertDocxFileToHtml(file) { + if (!file) throw new Error('未选择文件'); + var arrayBuffer = await file.arrayBuffer(); + return convertDocxArrayBufferToHtml(arrayBuffer); +} + +export async function convertLegacyDocFileToHtml(file) { + if (!file) throw new Error('未选择文件'); + var arrayBuffer = await file.arrayBuffer(); + if (detectWordFileFormat(arrayBuffer) !== 'doc') { + throw new Error('不是有效的 .doc 文件'); + } + var text = extractTextFromLegacyDoc(arrayBuffer); + if (!text || !/[\u4e00-\u9fff]/.test(text)) { + throw new Error('未能从 .doc 文件中提取正文,请另存为 .docx 后重试'); + } + var html = plainTextToHtml(text); + if (!html || isCorruptWordImportHtml(html)) { + throw new Error('未能从 .doc 文件中生成可用内容,请另存为 .docx 后重试'); + } + return prepareImportedWordHtml(wrapWordHtml(html), ''); +} + +export async function convertWordFileToHtml(file) { + if (!file) throw new Error('未选择文件'); + var arrayBuffer = await file.arrayBuffer(); + var format = detectWordFileFormat(arrayBuffer); + var name = String(file.name || '').toLowerCase(); + + if (format === 'docx' || (/\.docx$/i.test(name) && format !== 'doc')) { + return convertDocxArrayBufferToHtml(arrayBuffer); + } + if (format === 'doc' || /\.doc$/i.test(name)) { + return convertLegacyDocFileToHtml(file); + } + throw new Error('无法识别 Word 文件格式,请使用 .doc 或 .docx'); +} + +export function detectWordFileFormatFromName(name, arrayBuffer) { + if (arrayBuffer) return detectWordFileFormat(arrayBuffer); + var lower = String(name || '').toLowerCase(); + if (/\.docx$/i.test(lower)) return 'docx'; + if (/\.doc$/i.test(lower)) return 'doc'; + return 'unknown'; +} + +export function isDocxArrayBuffer(arrayBuffer) { + return detectWordFileFormat(arrayBuffer) === 'docx'; +} diff --git a/src/prototypes/contract-template-management/scripts/build-annotation-source.mjs b/src/prototypes/contract-template-management/scripts/build-annotation-source.mjs index 1df0f02..64fdf76 100644 --- a/src/prototypes/contract-template-management/scripts/build-annotation-source.mjs +++ b/src/prototypes/contract-template-management/scripts/build-annotation-source.mjs @@ -40,6 +40,8 @@ const overviewMd = `## 模块定位 | 目标用户 | 法务人员 | | 核心任务 | 维护 Word 版商用车租赁合同模板,按章节编辑,发布版本并控制启用态 | | 数据模型 | 一份 Word 文件 = 一条记录,内含 9 个章节内容 | +| 车型主数据 | 品牌·型号选项来自 [车辆管理](/prototypes/vehicle-management) 种子数据,并与租赁演示车型合并 | +| 角色演示 | 法务默认可编辑;URL 加 ?role=cs 或 ?role=readonly 进入客服只读模式 | | PRD 文档 | 见目录「PRD 全文 → PRD 完整文档」 | | 资源文件 | \`src/resources/contract-template-management/PRD.md\` | @@ -66,8 +68,8 @@ const DOC_MARKDOWN = { 'ct-doc-prd-acceptance': extractSection(fullPrd, '## 16. 验收标准') ?? '## 16. 验收标准', 'ct-doc-node-filter': extractSection(fullPrd, '### 7.2 筛选区') ?? '', 'ct-doc-node-table': combineSections(['### 7.4 主表字段', '### 7.5 主表操作', '### 7.6 子表(展开行)']), - 'ct-doc-node-tabs': combineSections(['### 8.3 章节 Tab', '### 8.4 条件条款说明(合同主体及租赁订单~安全责任协议)', '### 8.5 适用车型绑定(仅附件章节 Tab)']), - 'ct-doc-node-workspace': combineSections(['### 8.6 编辑区(左侧)', '### 8.7 实时预览(右侧)']), + 'ct-doc-node-tabs': combineSections(['### 8.3 章节 Tab', '### 8.5 条件条款说明(合同主体及租赁订单~安全责任协议)', '### 8.6 适用车型绑定(仅附件章节 Tab)']), + 'ct-doc-node-workspace': combineSections(['### 8.7 编辑区(左侧)', '### 8.8 实时预览(右侧)']), 'ct-doc-annotation-hint': '## 标注查看提示\n\n右侧标注工具栏可浏览目录、切换主题与颜色筛选。列表页与编辑页标注按当前页面自动显隐。\n\n**PRD 同步**:目录「PRD 全文」已与 `src/resources/contract-template-management/PRD.md` 对齐;修改 PRD 后运行 `node src/prototypes/contract-template-management/scripts/build-annotation-source.mjs` 重新生成 `annotation-source.json`。', }; @@ -76,10 +78,10 @@ const MARKDOWN_MAP = { 'ct-list-table': combineSections(['### 7.4 主表字段', '### 7.5 主表操作', '### 7.6 子表(展开行)']), 'ct-editor-topbar': combineSections(['### 8.2 顶栏', '### 6.2 保存与发布']), 'ct-section-tabs': extractSection(fullPrd, '### 8.3 章节 Tab') ?? '', - 'ct-contract-type': source.markdownMap?.['ct-contract-type'] ?? '', - 'ct-vehicle-binding': extractSection(fullPrd, '### 8.5 适用车型绑定(仅附件章节 Tab)') ?? source.markdownMap?.['ct-vehicle-binding'] ?? '', - 'ct-editor-panel': combineSections(['### 8.6 编辑区(左侧)']), - 'ct-preview-panel': extractSection(fullPrd, '### 8.7 实时预览(右侧)') ?? '', + 'ct-contract-type': extractSection(fullPrd, '### 8.4 合同主体配置(仅合同主体 Tab)') ?? '', + 'ct-vehicle-binding': extractSection(fullPrd, '### 8.6 适用车型绑定(仅附件章节 Tab)') ?? source.markdownMap?.['ct-vehicle-binding'] ?? '', + 'ct-editor-panel': combineSections(['### 8.7 编辑区(左侧)']), + 'ct-preview-panel': extractSection(fullPrd, '### 8.8 实时预览(右侧)') ?? '', }; function patchDirectoryMarkdown(nodes) { @@ -97,6 +99,44 @@ function patchDirectoryMarkdown(nodes) { const ctDocRoot = source.directory?.nodes?.find((n) => n.id === 'ct-doc-root'); if (ctDocRoot) patchDirectoryMarkdown(ctDocRoot.children); +function patchListNodes(nodes) { + for (const node of nodes ?? []) { + if (node.id === 'ct-list-filter') { + node.annotationText = '支持按合同名称(可搜索下拉)、类型、状态、创建人、启用状态筛选;超过 4 项时默认折叠扩展筛选项。'; + node.updatedAt = Date.now(); + } + if (node.id === 'ct-list-toolbar') { + node.annotationText = '进入新增页;未导入 Word 前不展示章节 Tab。'; + node.updatedAt = Date.now(); + } + if (node.id === 'ct-section-tabs') { + node.aiPrompt = '由 Word「标题」样式自动生成的章节 Tab。'; + node.annotationText = '导入 Word 后,按文档中标记为「标题」的段落文本自动生成章节 Tab;新增模式未导入前整段隐藏(含合同主体)。'; + node.updatedAt = Date.now(); + } + if (node.id === 'ct-contract-type') { + node.title = '合同主体配置'; + node.aiPrompt = '合同主体章节下的合同名称与类型配置。'; + node.annotationText = '仅当存在「合同主体」Tab 且选中时展示;合同名称与类型均为必填,同步至列表页。'; + node.updatedAt = Date.now(); + } + } +} +patchListNodes(source.data?.nodes); + +if (source.data?.annotationsByElementId && MARKDOWN_MAP['ct-list-filter']) { + source.data.annotationsByElementId['ct-list-filter'] = MARKDOWN_MAP['ct-list-filter']; + if (MARKDOWN_MAP['ct-list-table']) { + source.data.annotationsByElementId['ct-list-table'] = MARKDOWN_MAP['ct-list-table']; + } + if (MARKDOWN_MAP['ct-contract-type']) { + source.data.annotationsByElementId['ct-contract-type'] = MARKDOWN_MAP['ct-contract-type']; + } + if (MARKDOWN_MAP['ct-section-tabs']) { + source.data.annotationsByElementId['ct-section-tabs'] = MARKDOWN_MAP['ct-section-tabs']; + } +} + source.markdownMap = { ...source.markdownMap, ...MARKDOWN_MAP }; source.data.updatedAt = Date.now(); diff --git a/src/prototypes/contract-template-management/styles/contract-template.css b/src/prototypes/contract-template-management/styles/contract-template.css index 0a88231..215c301 100644 --- a/src/prototypes/contract-template-management/styles/contract-template.css +++ b/src/prototypes/contract-template-management/styles/contract-template.css @@ -1572,10 +1572,123 @@ table-layout: auto; } +.vm-page.ct-page .ct-rich-editor--word .ct-word-doc--imported, +.vm-page.ct-page .ct-preview-page__body.ct-word-doc--imported, +.vm-page.ct-page .ct-preview-doc .ct-word-doc--imported { + display: block; + width: 100%; + min-width: 0; + padding: 0 !important; + font-size: unset; + line-height: unset; + color: #000; + letter-spacing: normal; + word-break: normal; + overflow-wrap: break-word; +} + +.vm-page.ct-page .ct-word-doc--imported p, +.vm-page.ct-page .ct-word-doc--imported li { + margin: 0; + text-align: inherit; + text-indent: inherit; +} + +.vm-page.ct-page .ct-word-doc--imported h1, +.vm-page.ct-page .ct-word-doc--imported h2, +.vm-page.ct-page .ct-word-doc--imported h3, +.vm-page.ct-page .ct-word-doc--imported h4, +.vm-page.ct-page .ct-word-doc--imported h5, +.vm-page.ct-page .ct-word-doc--imported h6 { + margin: 0; + font-size: inherit; + font-weight: inherit; + line-height: inherit; +} + +.vm-page.ct-page .ct-word-doc--imported .ct-word-section-title { + font-weight: 700; + margin: 0; +} + +.vm-page.ct-page .ct-word-doc--imported mark.ct-word-highlight, +.vm-page.ct-page .ct-word-doc--imported .ct-word-highlight { + background-color: #ffff00; + color: inherit; + padding: 0; +} + +.vm-page.ct-page .ct-word-doc--imported table { + border-collapse: collapse; + width: auto; + max-width: 100%; + table-layout: auto; +} + +.vm-page.ct-page .ct-word-doc--imported td, +.vm-page.ct-page .ct-word-doc--imported th { + vertical-align: top; +} + +.vm-page.ct-page .ct-word-doc--imported img { + max-width: 100%; + height: auto; +} + +.vm-page.ct-page .ct-preview-page__body.ct-word-doc--imported { + overflow: visible; + word-break: normal; +} + .vm-page.ct-page .ct-rich-editor--word .ct-word-doc--v266 style { display: none !important; } +.vm-page.ct-page .ct-preview-pages--docx-fidelity { + align-items: center; + width: 100%; +} + +.vm-page.ct-page .ct-docx-fidelity-banner { + width: 100%; + max-width: 210mm; + margin: 0 auto 12px; + padding: 8px 12px; + font-size: 12px; + line-height: 1.5; + color: #334155; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-sizing: border-box; +} + +.vm-page.ct-page .ct-docx-fidelity-root { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.vm-page.ct-page .ct-docx-fidelity-root .docx-wrapper { + background: #fff; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); + margin: 0 auto; +} + +.vm-page.ct-page .ct-docx-fidelity-fallback { + width: 210mm; + max-width: calc(100% - 10px); + padding: 24px; + text-align: center; + color: #64748b; + font-size: 13px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + .vm-page.ct-page .ct-rich-wrap--word-a4 .ct-editor-a4-shell { position: relative; flex: 1; @@ -2047,10 +2160,36 @@ } .vm-page.ct-page .ct-contract-type-card__form { - max-width: 360px; + max-width: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: flex-end; + gap: 16px; } -.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-input { +.vm-page.ct-page .ct-contract-type-card__field { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + flex: 1 1 0; + min-width: 0; +} + +.vm-page.ct-page .ct-contract-type-card__field--type { + flex: 0 0 200px; + max-width: 220px; +} + +.vm-page.ct-page .ct-contract-type-card__label { + font-size: 12px; + font-weight: 500; + color: #64748b; +} + +.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-input, +.vm-page.ct-page .ct-contract-type-card__form .ct-editor-contract-type-select { width: 100%; } diff --git a/src/prototypes/customer-management/annotation-source.json b/src/prototypes/customer-management/annotation-source.json index 99e819a..62d6028 100644 --- a/src/prototypes/customer-management/annotation-source.json +++ b/src/prototypes/customer-management/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "customer-management", "pageId": "list", - "updatedAt": 1783328981485, + "updatedAt": 1783352657922, "nodes": [ { "id": "cm-list-filter", diff --git a/src/prototypes/customer-management/components/FilterPanel.tsx b/src/prototypes/customer-management/components/FilterPanel.tsx index dda9be3..88edc2a 100644 --- a/src/prototypes/customer-management/components/FilterPanel.tsx +++ b/src/prototypes/customer-management/components/FilterPanel.tsx @@ -175,7 +175,7 @@ export function FilterPanel({ return (
-

客户管理

+

筛选条件

{visibleFields.map((field) => ( diff --git a/src/prototypes/customer-management/components/MultiSelectField.tsx b/src/prototypes/customer-management/components/MultiSelectField.tsx index 82644ac..1b825e3 100644 --- a/src/prototypes/customer-management/components/MultiSelectField.tsx +++ b/src/prototypes/customer-management/components/MultiSelectField.tsx @@ -68,6 +68,7 @@ export function MultiSelectField({
- {LEASE_TABLE_COLUMNS.map((col) => { const sortable = col.sortable !== false; @@ -750,9 +834,10 @@ export function LeaseLedgerTable({ className={[ col.money ? 'ldb-col-money' : '', col.className, - col.sticky === 'left' ? 'sticky-col-left' : '', + stickyLeftClass(col.key, leftStickyLayout), 'ldb-col-resizable', ].filter(Boolean).join(' ')} + style={stickyLeftStyle(col.key, leftStickyLayout, true)} data-annotation-id={col.annotationId} >
@@ -782,7 +867,8 @@ export function LeaseLedgerTable({ {sortedRecords.map((row, index) => { const canEdit = canStaffEditRow(row); const cellCtx = { - maintenanceOrders, + returnSettlementFees, + maintenanceLedgerRecords, receipts, hydrogenFees, canEdit, @@ -793,13 +879,15 @@ export function LeaseLedgerTable({ onBrokerageFeeChange, onRemarkChange, onDeductionDetailChange, + onExemptionPolicyChange, }; return (
- diff --git a/src/prototypes/lease-business-ledger/components/LedgerDetailPopover.tsx b/src/prototypes/lease-business-ledger/components/LedgerDetailPopover.tsx index 8a31776..15a89d7 100644 --- a/src/prototypes/lease-business-ledger/components/LedgerDetailPopover.tsx +++ b/src/prototypes/lease-business-ledger/components/LedgerDetailPopover.tsx @@ -1,4 +1,6 @@ -import React, { useEffect, useId, useRef, useState } from 'react'; +import React, { useEffect, useId, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; interface LedgerDetailPopoverProps { label: string; @@ -16,44 +18,65 @@ export function LedgerDetailPopover({ footer, }: LedgerDetailPopoverProps) { const [open, setOpen] = useState(false); - const wrapRef = useRef(null); - const panelId = useId(); + const dialogId = useId(); + const titleId = useId(); useEffect(() => { if (!open) return; - const onDocClick = (event: MouseEvent) => { - if (!wrapRef.current?.contains(event.target as Node)) setOpen(false); - }; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); }; - document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; return () => { - document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; }; }, [open]); + const close = () => setOpen(false); + return ( -
+ <> - {open && ( - + {open && createPortal( +
+ +
, + document.body, )} -
+ ); } @@ -69,27 +92,38 @@ export function LedgerDetailTable({ columns, rows, emptyText = '暂无明细' }: } return ( -
-
toggle(item.id)} aria-label={`选择 ${item.accidentNo}`} diff --git a/src/prototypes/lease-business-ledger/components/FilterPanel.tsx b/src/prototypes/lease-business-ledger/components/FilterPanel.tsx index e7d7d73..a3f71a1 100644 --- a/src/prototypes/lease-business-ledger/components/FilterPanel.tsx +++ b/src/prototypes/lease-business-ledger/components/FilterPanel.tsx @@ -1,12 +1,25 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ChevronDown, Search } from 'lucide-react'; import type { LeaseLedgerFilters, LeaseLedgerRow } from '../types'; import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField'; +import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField'; import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField'; import { buildLeaseOptions } from '../utils/ledger'; const STATUS_OPTIONS = ['未收款', '部分收款', '已收款']; -const DEFAULT_VISIBLE = 4; + +/** PRD §2:默认首行 4 项 */ +const PRIMARY_FIELD_KEYS = ['year', 'month', 'dept', 'salesman'] as const; +/** PRD §2:「更多筛选」展开其余 4 项 */ +const EXTRA_FIELD_KEYS = ['plateNos', 'customerName', 'status', 'paymentDue'] as const; + +type FieldKey = (typeof PRIMARY_FIELD_KEYS)[number] | (typeof EXTRA_FIELD_KEYS)[number]; + +interface FilterFieldDef { + key: FieldKey; + label: string; + control: React.ReactNode; +} interface FilterPanelProps { records: LeaseLedgerRow[]; @@ -16,24 +29,125 @@ interface FilterPanelProps { onSearch: () => void; } +function hasExtraFiltersActive(filters: LeaseLedgerFilters): boolean { + return Boolean( + filters.plateNos.length > 0 + || filters.customerName.trim() + || filters.status + || filters.paymentFrom + || filters.paymentTo, + ); +} + export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(() => hasExtraFiltersActive(filters)); + + useEffect(() => { + if (hasExtraFiltersActive(filters)) { + setExpanded(true); + } + }, [filters.plateNos, filters.customerName, filters.status, filters.paymentFrom, filters.paymentTo]); const yearOptions = useMemo(() => buildLeaseOptions(records, 'year'), [records]); const monthOptions = useMemo(() => buildLeaseOptions(records, 'month'), [records]); const deptOptions = useMemo(() => buildLeaseOptions(records, 'dept'), [records]); const salesmanOptions = useMemo(() => buildLeaseOptions(records, 'salesman'), [records]); - const plateOptions = useMemo(() => buildLeaseOptions(records, 'plateNo'), [records]); - const fields = [ - { label: '年份', control: onChange({ year })} /> }, - { label: '月份', control: onChange({ month })} /> }, - { label: '业务部门', control: onChange({ dept })} /> }, - { label: '业务员', control: onChange({ salesman })} /> }, - { label: '车牌号码', control: onChange({ plateNo })} /> }, - { label: '客户名称', control: onChange({ customerName: e.target.value })} /> }, - { label: '明细状态', control: onChange({ status })} /> }, - { label: '应付款日', control: ( + const fieldMap = useMemo>(() => ({ + year: { + key: 'year', + label: '年份', + control: ( + onChange({ year })} + /> + ), + }, + month: { + key: 'month', + label: '月份', + control: ( + onChange({ month })} + /> + ), + }, + dept: { + key: 'dept', + label: '业务部门', + control: ( + onChange({ dept })} + /> + ), + }, + salesman: { + key: 'salesman', + label: '业务员', + control: ( + onChange({ salesman })} + /> + ), + }, + plateNos: { + key: 'plateNos', + label: '车牌号码', + control: ( + onChange({ plateNos })} + /> + ), + }, + customerName: { + key: 'customerName', + label: '客户名称', + control: ( + onChange({ customerName: e.target.value })} + /> + ), + }, + status: { + key: 'status', + label: '明细状态', + control: ( + onChange({ status })} + /> + ), + }, + paymentDue: { + key: 'paymentDue', + label: '应付款日', + control: ( ), }, - ]; + }), [ + filters, + onChange, + yearOptions, + monthOptions, + deptOptions, + salesmanOptions, + ]); - const primaryFields = fields.slice(0, DEFAULT_VISIBLE); - const extraFields = fields.slice(DEFAULT_VISIBLE); + const primaryFields = PRIMARY_FIELD_KEYS.map((key) => fieldMap[key]); + const extraFields = EXTRA_FIELD_KEYS.map((key) => fieldMap[key]); + const extraActiveCount = [ + filters.plateNos.length > 0, + filters.customerName.trim(), + filters.status, + filters.paymentFrom || filters.paymentTo, + ].filter(Boolean).length; + + const renderField = (field: FilterFieldDef) => ( + + ); return ( -
+
-

租赁业务台账

+

筛选条件

-
- {primaryFields.map((field) => ( - - ))} -
- {extraFields.length > 0 && ( -
+ +
+
+ {primaryFields.map(renderField)} +
+ +
-
- {extraFields.map((field) => ( - - ))} +
+ {expanded ? extraFields.map(renderField) : null}
- )} -
- {extraFields.length > 0 && ( - - )} - +
+ +
+ + + +
+
+
+ 1 + 下载导入模板 +
+ +
+
+
+ 2 + 上传填写好的文件 +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/src/prototypes/lease-business-ledger/components/LeaseLedgerTable.tsx b/src/prototypes/lease-business-ledger/components/LeaseLedgerTable.tsx index 5d1a74a..633c954 100644 --- a/src/prototypes/lease-business-ledger/components/LeaseLedgerTable.tsx +++ b/src/prototypes/lease-business-ledger/components/LeaseLedgerTable.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp, History } from 'lucide-react'; -import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceOrder, ReceiptRecord } from '../types'; +import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceLedgerRecord, ReceiptRecord, ReturnSettlementFeeRecord } from '../types'; import { calcMonthlyIncome, calcUnreceived, @@ -12,10 +12,13 @@ import { formatMoney, getHydrogenFeesForBill, getLinkedReceiptsForRow, - getMaintenanceOrdersForBill, + getMaintenanceLedgerRecordsForBill, + getReturnSettlementFeesForBill, isBlankDisplayValue, } from '../utils/ledger'; -import { LEASE_TABLE_COLUMNS, STATUS_CLASS, ARCHIVE_STATUS_CLASS, type LeaseTableColumn } from './tableColumns'; +import { LEASE_TABLE_COLUMNS, LEFT_STICKY_LAST_COLUMN_KEY, STATUS_CLASS, ARCHIVE_STATUS_CLASS, type LeaseTableColumn } from './tableColumns'; +import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField'; +import { EXEMPTION_POLICY_OPTIONS } from '../utils/constants'; import { resolveColumnHeaderTip } from '../columnHeaderTips'; import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover'; import { LedgerLineItemsCell } from './LedgerLineItemsCell'; @@ -25,7 +28,8 @@ interface LeaseLedgerTableProps { records: LeaseLedgerRow[]; selectedIds: Set; loading?: boolean; - maintenanceOrders: MaintenanceOrder[]; + returnSettlementFees: ReturnSettlementFeeRecord[]; + maintenanceLedgerRecords: MaintenanceLedgerRecord[]; receipts: ReceiptRecord[]; hydrogenFees: HydrogenFeeRecord[]; onToggleRow: (id: string, checked: boolean) => void; @@ -40,6 +44,7 @@ interface LeaseLedgerTableProps { onBrokerageFeeChange: (recordId: string, value: number) => void; onRemarkChange: (recordId: string, value: string) => void; onDeductionDetailChange: (recordId: string, value: string) => void; + onExemptionPolicyChange: (recordId: string, value: string) => void; } type SortDir = 'asc' | 'desc'; @@ -47,6 +52,45 @@ type SortDir = 'asc' | 'desc'; const CHECK_COL_WIDTH = 48; const ACTION_COL_WIDTH = 208; +function buildLeftStickyLayout(columnWidths: Record) { + const keys: string[] = []; + for (const col of LEASE_TABLE_COLUMNS) { + keys.push(col.key); + if (col.key === LEFT_STICKY_LAST_COLUMN_KEY) break; + } + const keySet = new Set(keys); + let left = CHECK_COL_WIDTH; + const leftOffsets: Record = {}; + for (const key of keys) { + leftOffsets[key] = left; + left += columnWidths[key] ?? DEFAULT_COLUMN_WIDTHS[key] ?? 100; + } + return { keys, keySet, leftOffsets, lastKey: LEFT_STICKY_LAST_COLUMN_KEY }; +} + +function stickyLeftClass(colKey: string, layout: ReturnType): string { + if (!layout.keySet.has(colKey)) return ''; + const classes = ['sticky-col-left']; + if (colKey === layout.lastKey) classes.push('sticky-col-left-last'); + return classes.join(' '); +} + +function stickyLeftStyle( + colKey: string, + layout: ReturnType, + isHeader: boolean, +): React.CSSProperties | undefined { + const index = layout.keys.indexOf(colKey); + if (index < 0) return undefined; + return { + left: layout.leftOffsets[colKey], + zIndex: (isHeader ? 4 : 3) + index, + }; +} + +const CHECK_STICKY_HEAD_STYLE: React.CSSProperties = { left: 0, zIndex: 3 }; +const CHECK_STICKY_BODY_STYLE: React.CSSProperties = { left: 0, zIndex: 2 }; + const DEFAULT_COLUMN_WIDTHS: Record = { status: 88, billingYearMonth: 88, @@ -58,7 +102,7 @@ const DEFAULT_COLUMN_WIDTHS: Record = { plateNo: 120, customerName: 160, valueAddedService: 96, - exemptionPolicy: 96, + exemptionPolicy: 176, pickupDate: 108, contractStartDate: 116, contractEndDate: 116, @@ -284,7 +328,8 @@ function renderCell( col: LeaseTableColumn, index: number, ctx: { - maintenanceOrders: MaintenanceOrder[]; + returnSettlementFees: ReturnSettlementFeeRecord[]; + maintenanceLedgerRecords: MaintenanceLedgerRecord[]; receipts: ReceiptRecord[]; hydrogenFees: HydrogenFeeRecord[]; canEdit: boolean; @@ -295,6 +340,7 @@ function renderCell( onBrokerageFeeChange: (recordId: string, value: number) => void; onRemarkChange: (recordId: string, value: string) => void; onDeductionDetailChange: (recordId: string, value: string) => void; + onExemptionPolicyChange: (recordId: string, value: string) => void; }, ) { switch (col.render) { @@ -316,6 +362,19 @@ function renderCell( {displayText(row.valueAddedService)} ); + case 'exemption-policy': + if (!ctx.canEdit) { + return {displayText(row.exemptionPolicy)}; + } + return ( + ctx.onExemptionPolicyChange(row.id, value)} + /> + ); case 'stack-dept': return (
@@ -370,25 +429,25 @@ function renderCell( {formatMoney(row.insuranceSurcharge)} ); case 'detail-popover-maintenance-income': { - const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '客户承担'); - const tableRows = orders.map((item) => ({ - date: item.completedAt.split(' ')[0], + const fees = getReturnSettlementFeesForBill(row, ctx.returnSettlementFees); + const tableRows = fees.map((item) => ({ + date: item.settlementDate.split(' ')[0], plateNo: item.plateNo, - type: item.type, + feeItem: item.feeItem, amount: formatMoney(item.amount), })); return ( @@ -396,17 +455,17 @@ function renderCell( ); } case 'detail-popover-maintenance-cost': { - const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '我司承担'); - const tableRows = orders.map((item) => ({ - date: item.completedAt.split(' ')[0], + const records = getMaintenanceLedgerRecordsForBill(row, ctx.maintenanceLedgerRecords); + const tableRows = records.map((item) => ({ + date: item.repairDate.split(' ')[0], plateNo: item.plateNo, type: item.type, - amount: formatMoney(item.amount), + amount: formatMoney(item.totalCost), })); return ( @@ -540,8 +599,8 @@ function renderCell( { key: 'refuelDate', title: '加氢日期' }, { key: 'plateNo', title: '车牌号' }, { key: 'stationName', title: '加氢站名称' }, - { key: 'volume', title: '加氢量', align: 'right' }, - { key: 'unitCost', title: '成本单价', align: 'right' }, + { key: 'volume', title: '加氢量' }, + { key: 'unitCost', title: '成本单价' }, { key: 'totalCost', title: '成本总价', align: 'right' }, { key: 'borneBy', title: '承担方式' }, ]} @@ -633,7 +692,8 @@ export function LeaseLedgerTable({ records, selectedIds, loading = false, - maintenanceOrders, + returnSettlementFees, + maintenanceLedgerRecords, receipts, hydrogenFees, onToggleRow, @@ -648,12 +708,17 @@ export function LeaseLedgerTable({ onBrokerageFeeChange, onRemarkChange, onDeductionDetailChange, + onExemptionPolicyChange, }: LeaseLedgerTableProps) { const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState('asc'); const [historyRow, setHistoryRow] = useState(null); const [columnWidths, setColumnWidths] = useState>(() => ({ ...DEFAULT_COLUMN_WIDTHS })); const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null); + const leftStickyLayout = useMemo( + () => buildLeftStickyLayout(columnWidths), + [columnWidths], + ); const sortedRecords = useMemo(() => { if (!sortKey) return records; @@ -669,6 +734,14 @@ export function LeaseLedgerTable({ }, [records, sortKey, sortDir]); const allSelected = sortedRecords.length > 0 && sortedRecords.every((r) => selectedIds.has(r.id)); + const someSelected = sortedRecords.some((r) => selectedIds.has(r.id)) && !allSelected; + const headerCheckRef = useRef(null); + + useEffect(() => { + if (headerCheckRef.current) { + headerCheckRef.current.indeterminate = someSelected; + } + }, [someSelected]); const handleSort = useCallback((key: string) => { setSortKey((prev) => { @@ -738,8 +811,19 @@ export function LeaseLedgerTable({
- onToggleAll(e.target.checked)} aria-label="全选" /> + + onToggleAll(e.target.checked)} + aria-label="全选" + />
+ onToggleRow(row.id, e.target.checked)} aria-label={`选择第 ${index + 1} 行`} @@ -811,8 +899,9 @@ export function LeaseLedgerTable({ className={[ col.money ? 'ldb-col-money tabular-nums' : '', col.className, - col.sticky === 'left' ? 'sticky-col-left' : '', + stickyLeftClass(col.key, leftStickyLayout), ].filter(Boolean).join(' ')} + style={stickyLeftStyle(col.key, leftStickyLayout, false)} > {renderCell(row, col, index, cellCtx)}
- - - {columns.map((col) => ( - - ))} - - - - {rows.map((row, index) => ( - + <> +

共 {rows.length} 条明细记录

+
+
{col.title}
+ + {columns.map((col) => ( - + ))} - ))} - -
- {row[col.key] ?? ''} - + {col.title} +
- + + + {rows.map((row, index) => ( + + {columns.map((col) => ( + + {row[col.key] ?? ''} + + ))} + + ))} + + + + ); } diff --git a/src/prototypes/lease-business-ledger/components/LedgerLineItemsCell.tsx b/src/prototypes/lease-business-ledger/components/LedgerLineItemsCell.tsx index f6e3b08..031b377 100644 --- a/src/prototypes/lease-business-ledger/components/LedgerLineItemsCell.tsx +++ b/src/prototypes/lease-business-ledger/components/LedgerLineItemsCell.tsx @@ -1,6 +1,8 @@ import React, { useMemo } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; import type { LedgerLineItem } from '../types'; import { formatMoney } from '../utils/ledger'; +import { lineItemsShowDetailColumns } from '../utils/ledger-batch-import'; import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover'; interface LedgerLineItemsCellProps { @@ -15,6 +17,117 @@ interface LedgerLineItemsCellProps { const EMPTY_ITEM: LedgerLineItem = { reason: '', amount: 0, remark: '' }; +function updateItem( + items: LedgerLineItem[], + index: number, + patch: Partial, +): LedgerLineItem[] { + const next = [...items]; + next[index] = { ...next[index], ...patch }; + return next; +} + +function LedgerLineItemsEditor({ + items, + reasonLabel, + ariaLabel, + onChange, +}: { + items: LedgerLineItem[]; + reasonLabel: string; + ariaLabel: string; + onChange: (items: LedgerLineItem[]) => void; +}) { + const rowCount = items.length; + const showDetailColumns = lineItemsShowDetailColumns(items); + + return ( + <> +

+ {rowCount > 0 ? `共 ${rowCount} 条明细记录` : '暂无明细,请添加记录'} +

+
+ + + + {showDetailColumns && } + + {showDetailColumns && } + + + + + {rowCount === 0 ? ( + + + + ) : items.map((item, index) => ( + + {showDetailColumns && ( + + )} + + {showDetailColumns && ( + + )} + + + ))} + +
{reasonLabel}金额备注操作
暂无明细
+ onChange(updateItem(items, index, { reason: event.target.value }))} + /> + + onChange(updateItem(items, index, { amount: Number(event.target.value) || 0 }))} + /> + + onChange(updateItem(items, index, { remark: event.target.value }))} + /> + + +
+
+
+ +
+ + ); +} + export function LedgerLineItemsCell({ items, total, @@ -26,6 +139,7 @@ export function LedgerLineItemsCell({ }: LedgerLineItemsCellProps) { const displayItems = items.length > 0 ? items : []; const sum = total; + const showDetailColumns = lineItemsShowDetailColumns(displayItems); const tableRows = useMemo( () => displayItems.map((item) => ({ @@ -36,65 +150,24 @@ export function LedgerLineItemsCell({ [displayItems], ); - const editor = canEdit ? ( -
- {displayItems.map((item, index) => ( -
- { - const next = [...displayItems]; - next[index] = { ...next[index], reason: e.target.value }; - onChange(next); - }} - /> - { - const next = [...displayItems]; - next[index] = { ...next[index], amount: Number(e.target.value) || 0 }; - onChange(next); - }} - /> - { - const next = [...displayItems]; - next[index] = { ...next[index], remark: e.target.value }; - onChange(next); - }} - /> -
- ))} - -
+ const readColumns = showDetailColumns + ? [ + { key: 'reason', title: reasonLabel }, + { key: 'amount', title: '金额', align: 'right' as const }, + { key: 'remark', title: '备注' }, + ] + : [{ key: 'amount', title: '金额', align: 'right' as const }]; + + const content = canEdit ? ( + ) : ( @@ -106,7 +179,7 @@ export function LedgerLineItemsCell({ title={title} triggerClassName={canEdit ? 'ldb-line-items-trigger--editable' : ''} > - {editor} + {content} ); } diff --git a/src/prototypes/lease-business-ledger/components/MaintenanceLinkModal.tsx b/src/prototypes/lease-business-ledger/components/MaintenanceLinkModal.tsx index fcd06d6..9af3094 100644 --- a/src/prototypes/lease-business-ledger/components/MaintenanceLinkModal.tsx +++ b/src/prototypes/lease-business-ledger/components/MaintenanceLinkModal.tsx @@ -87,6 +87,7 @@ export function MaintenanceLinkModal({ toggle(item.id)} aria-label={`选择 ${item.orderNo}`} diff --git a/src/prototypes/lease-business-ledger/components/ReceiptLinkModal.tsx b/src/prototypes/lease-business-ledger/components/ReceiptLinkModal.tsx index d8493f8..e396958 100644 --- a/src/prototypes/lease-business-ledger/components/ReceiptLinkModal.tsx +++ b/src/prototypes/lease-business-ledger/components/ReceiptLinkModal.tsx @@ -127,6 +127,7 @@ export function ReceiptLinkModal({ toggle(item.id, item.amount)} aria-label={`选择到账记录 ${item.arrivalTime}`} diff --git a/src/prototypes/lease-business-ledger/components/tableColumns.ts b/src/prototypes/lease-business-ledger/components/tableColumns.ts index 591f63b..17908e7 100644 --- a/src/prototypes/lease-business-ledger/components/tableColumns.ts +++ b/src/prototypes/lease-business-ledger/components/tableColumns.ts @@ -1,5 +1,7 @@ import type { BillArchiveStatus, LeaseDetailStatus } from '../types'; +export const LEFT_STICKY_LAST_COLUMN_KEY = 'customerName'; + /** 宽表列定义(对齐 Excel「租赁车辆收入明细表」) */ export interface LeaseTableColumn { key: string; @@ -21,7 +23,7 @@ export interface LeaseTableColumn { | 'invoice-date' | 'vehicle-standard-cost' | 'vehicle-actual-cost' | 'insurance-readonly' | 'detail-popover-hydrogen' | 'detail-popover-maintenance-cost' | 'brokerage-fee' | 'other-cost' | 'gps-mileage' - | 'deduction-detail' | 'remark-text' | 'year-month' | 'text' | 'maintainer-time' | 'value-added-service' | 'archive-status'; + | 'deduction-detail' | 'remark-text' | 'year-month' | 'text' | 'maintainer-time' | 'value-added-service' | 'exemption-policy' | 'archive-status'; } export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [ @@ -43,7 +45,7 @@ export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [ annotationId: 'lbl-col-value-added-service', sortable: false, }, - { key: 'exemptionPolicy', title: '享免政策', sortable: false }, + { key: 'exemptionPolicy', title: '享免政策', render: 'exemption-policy', annotationId: 'lbl-col-exemption-policy', sortable: false }, { key: 'pickupDate', title: '提车日期', date: true, annotationId: 'lbl-col-pickup-date' }, { key: 'contractStartDate', diff --git a/src/prototypes/lease-business-ledger/data/maintenance-ledger-records.json b/src/prototypes/lease-business-ledger/data/maintenance-ledger-records.json new file mode 100644 index 0000000..16e71fb --- /dev/null +++ b/src/prototypes/lease-business-ledger/data/maintenance-ledger-records.json @@ -0,0 +1,26 @@ +[ + { + "id": "mlr1", + "plateNo": "沪A32896F", + "orderNo": "BY202601003", + "type": "保养", + "repairDate": "2026-01-07", + "totalCost": 450 + }, + { + "id": "mlr2", + "plateNo": "沪A65889F", + "orderNo": "WX202601022", + "type": "维修", + "repairDate": "2026-01-16", + "totalCost": 1250 + }, + { + "id": "mlr3", + "plateNo": "浙F08991F", + "orderNo": "BY202601011", + "type": "保养", + "repairDate": "2026-01-09", + "totalCost": 560 + } +] diff --git a/src/prototypes/lease-business-ledger/data/return-settlement-fees.json b/src/prototypes/lease-business-ledger/data/return-settlement-fees.json new file mode 100644 index 0000000..8a65d51 --- /dev/null +++ b/src/prototypes/lease-business-ledger/data/return-settlement-fees.json @@ -0,0 +1,23 @@ +[ + { + "id": "rsf1", + "plateNo": "浙F05580F", + "feeItem": "未结算维修", + "amount": 860, + "settlementDate": "2026-01-12" + }, + { + "id": "rsf2", + "plateNo": "浙F05580F", + "feeItem": "未结算保养", + "amount": 420, + "settlementDate": "2026-01-18" + }, + { + "id": "rsf3", + "plateNo": "沪A50990F", + "feeItem": "未结算维修", + "amount": 1500, + "settlementDate": "2026-01-20" + } +] diff --git a/src/prototypes/lease-business-ledger/data/rows.json b/src/prototypes/lease-business-ledger/data/rows.json index a1994ab..eea84b9 100644 --- a/src/prototypes/lease-business-ledger/data/rows.json +++ b/src/prototypes/lease-business-ledger/data/rows.json @@ -15,7 +15,7 @@ "customerName": "安徽驰远供应链管理有限公司", "projectName": "安徽驰远供应链管租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安18T里程优惠政策", "pickupDate": "2024-11-30", "contractStartDate": "2025-12-01", "contractEndDate": "2026-11-30", @@ -88,7 +88,7 @@ "customerName": "安徽驰远供应链管理有限公司", "projectName": "安徽驰远供应链管租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安18T里程优惠政策", "pickupDate": "2024-11-30", "contractStartDate": "2025-12-01", "contractEndDate": "2026-11-30", @@ -164,7 +164,7 @@ "customerName": "安徽驰远供应链管理有限公司", "projectName": "安徽驰远供应链管租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安18T里程优惠政策", "pickupDate": "2024-08-20", "contractStartDate": "2025-09-01", "contractEndDate": "2026-08-31", @@ -231,7 +231,7 @@ "customerName": "嘉兴港区韵达快递有限公司", "projectName": "嘉兴港区韵达快递租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安4.5T里程优惠政策", "pickupDate": "2024-10-29", "contractStartDate": "2025-11-01", "contractEndDate": "2026-10-31", @@ -298,7 +298,7 @@ "customerName": "嘉兴港区韵达快递有限公司", "projectName": "嘉兴港区韵达快递租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安18T里程优惠政策", "pickupDate": "2024-12-03", "contractStartDate": "2025-12-03", "contractEndDate": "2026-12-02", @@ -367,7 +367,7 @@ "customerName": "上海利合供应链管理有限公司", "projectName": "上海利合供应链管租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安18T里程优惠政策", "pickupDate": "2024-03-01", "contractStartDate": "2025-03-01", "contractEndDate": "2026-02-28", @@ -435,7 +435,7 @@ "customerName": "上海朔宇物流有限公司", "projectName": "上海朔宇物流租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安18T里程优惠政策", "pickupDate": "2025-09-26", "contractStartDate": "2025-10-01", "contractEndDate": "2026-09-30", @@ -507,7 +507,7 @@ "customerName": "苏州望亭远方物流有限公司", "projectName": "苏州望亭远方物流租赁项目", "valueAddedService": "", - "exemptionPolicy": "", + "exemptionPolicy": "帕力安18T里程优惠政策", "pickupDate": "2025-08-21", "contractStartDate": "2025-09-01", "contractEndDate": "2026-08-31", diff --git a/src/prototypes/lease-business-ledger/index.tsx b/src/prototypes/lease-business-ledger/index.tsx index 186b811..c22bfb5 100644 --- a/src/prototypes/lease-business-ledger/index.tsx +++ b/src/prototypes/lease-business-ledger/index.tsx @@ -14,7 +14,8 @@ import { } from '@axhub/annotation'; import seedRows from './data/rows.json'; import seedReceipts from './data/receipts.json'; -import seedMaintenance from './data/maintenance-orders.json'; +import seedReturnSettlementFees from './data/return-settlement-fees.json'; +import seedMaintenanceLedger from './data/maintenance-ledger-records.json'; import seedBiMileage from './data/bi-mileage.json'; import seedHydrogenFees from './data/hydrogen-fees.json'; import { FilterPanel } from './components/FilterPanel'; @@ -29,8 +30,9 @@ import { type LeaseLedgerFilters, type LeaseLedgerRow, type ListViewMode, - type MaintenanceOrder, + type MaintenanceLedgerRecord, type ReceiptRecord, + type ReturnSettlementFeeRecord, } from './types'; import { applyLeaseFilters, @@ -42,9 +44,18 @@ import { CURRENT_STAFF_USER, sumLeaseKpi, } from './utils/ledger'; +import { LeaseLedgerImportModal } from './components/LeaseLedgerImportModal'; +import { + applyLedgerImportToRow, + createDraftLedgerRowFromImport, + downloadLedgerBatchImportTemplate, + matchLedgerImportRow, + parseLedgerBatchImportFile, +} from './utils/ledger-batch-import'; import annotationSourceDocument from './annotation-source.json'; -const MAINTENANCE_ORDERS = seedMaintenance as MaintenanceOrder[]; +const RETURN_SETTLEMENT_FEES = seedReturnSettlementFees as ReturnSettlementFeeRecord[]; +const MAINTENANCE_LEDGER_RECORDS = seedMaintenanceLedger as MaintenanceLedgerRecord[]; const BI_MILEAGE = seedBiMileage as BiMileageRecord[]; const HYDROGEN_FEES = seedHydrogenFees as HydrogenFeeRecord[]; @@ -55,7 +66,8 @@ function formatMaintainerTime(date = new Date()): string { function mapRows(rows: LeaseLedgerRow[], receipts: ReceiptRecord[]): LeaseLedgerRow[] { return rows.map((row) => enrichLeaseRow(row, { - maintenanceOrders: MAINTENANCE_ORDERS, + returnSettlementFees: RETURN_SETTLEMENT_FEES, + maintenanceLedgerRecords: MAINTENANCE_LEDGER_RECORDS, biMileage: BI_MILEAGE, hydrogenFees: HYDROGEN_FEES, receipts, @@ -76,6 +88,7 @@ export default function LeaseBusinessLedgerApp() { const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [tableLoading, setTableLoading] = useState(false); const [editingRowId, setEditingRowId] = useState(null); + const [importOpen, setImportOpen] = useState(false); const showToast = useCallback((msg: string) => { setToast(msg); @@ -91,6 +104,12 @@ export default function LeaseBusinessLedgerApp() { () => applyListViewFilter(filtered, { showUnpaidOnly, listViewMode }), [filtered, showUnpaidOnly, listViewMode], ); + const toolbarListCount = useMemo(() => { + if (showUnpaidOnly) { + return filtered.filter((row) => row.status !== '已收款').length; + } + return filtered.length; + }, [filtered, showUnpaidOnly]); const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]); const totalPages = Math.max(1, Math.ceil(listFiltered.length / pageSize)); const pagedRecords = useMemo(() => { @@ -256,6 +275,44 @@ export default function LeaseBusinessLedgerApp() { )); }; + const handleExemptionPolicyChange = (recordId: string, value: string) => { + setRecords((prev) => mapRows( + prev.map((row) => (row.id === recordId ? { ...row, exemptionPolicy: value } : row)), + receipts, + )); + }; + + const handleBatchImport = async (file: File) => { + const text = await file.text(); + const importRows = parseLedgerBatchImportFile(text); + if (importRows.length === 0) { + showToast('未识别到有效数据,请使用模板填写必填项及需导入的字段'); + return; + } + + let updated = 0; + let created = 0; + setRecords((prev) => { + const next = [...prev]; + importRows.forEach((importRow, index) => { + const hitIndex = next.findIndex((row) => matchLedgerImportRow(row, importRow)); + if (hitIndex >= 0) { + next[hitIndex] = applyLedgerImportToRow(next[hitIndex], importRow); + updated += 1; + return; + } + next.push(createDraftLedgerRowFromImport(importRow, index)); + created += 1; + }); + return mapRows(next, receipts); + }); + setImportOpen(false); + const parts: string[] = []; + if (updated > 0) parts.push(`更新 ${updated} 条`); + if (created > 0) parts.push(`新增 ${created} 条`); + showToast(`导入完成:${parts.join(',')};自动计算字段已重新汇总`); + }; + const handleListViewChange = (mode: ListViewMode) => { setListViewMode(mode); setPage(1); @@ -295,20 +352,32 @@ export default function LeaseBusinessLedgerApp() {
- +
+ + + 共 {toolbarListCount} 条 + +
- @@ -341,7 +410,8 @@ export default function LeaseBusinessLedgerApp() {
- 共 {listFiltered.length} 条 -
- { - setPageSize(size); - setPage(1); - }} - /> -
+ { + setPageSize(size); + setPage(1); + }} + />
@@ -429,6 +497,12 @@ export default function LeaseBusinessLedgerApp() { onClose={() => setReceiptRow(null)} onConfirm={handleReceiptConfirm} /> + setImportOpen(false)} + onDownloadTemplate={downloadLedgerBatchImportTemplate} + onImport={handleBatchImport} + /> ); diff --git a/src/prototypes/lease-business-ledger/scripts/field-annotation-spec.mjs b/src/prototypes/lease-business-ledger/scripts/field-annotation-spec.mjs index f44d45f..1d27a48 100644 --- a/src/prototypes/lease-business-ledger/scripts/field-annotation-spec.mjs +++ b/src/prototypes/lease-business-ledger/scripts/field-annotation-spec.mjs @@ -28,6 +28,11 @@ export const FIELD_ANNOTATION_SPEC = { formula: '按车牌匹配合同增值服务配置,多项服务分行展示。', editable: '只读。', }, + 'lbl-col-exemption-policy': { + source: '租赁合同或业务手工维护。', + formula: '手选枚举:帕力安18T里程优惠政策、帕力安4.5T里程优惠政策。', + editable: '未归档/草稿状态可手选。', + }, 'lbl-col-pickup-date': { source: '交车管理 / 运维 E 签宝签字完成时间。', formula: '取该车辆实际交车办结时间。', @@ -99,9 +104,9 @@ export const FIELD_ANNOTATION_SPEC = { editable: '关联后自动汇总,支持多选事故单。', }, 'lbl-col-ops-income': { - source: '维修/保养台账(客户承担)、账单月内该车牌完工记录。', - formula: '汇总 bearBy=客户承担 的维保金额;可关联 linkedMaintenanceIds(sumMaintenanceForBill)。', - editable: '关联后自动汇总;最新账单可关联记录。', + source: '还车应结款费用明细(运维相关 10 项)。', + formula: '汇总账单月内该车牌还车应结款中清洗费、未结算保养、未结算维修等 10 项费用(sumReturnSettlementOpsFeeForBill)。', + editable: '只读,系统自动汇总。', }, 'lbl-col-other-income': { source: '行内其他收入明细(事由、金额、备注)。', @@ -164,8 +169,8 @@ export const FIELD_ANNOTATION_SPEC = { editable: '只读。', }, 'lbl-col-ops-cost': { - source: '维修/保养台账(我司承担)、账单月内该车牌。', - formula: '汇总 bearBy=我司承担 的维保金额(sumMaintenanceForBill)。', + source: '维修保养明细台账。', + formula: '汇总账单月内该车牌维修保养明细「费用总计(含税)」(sumMaintenanceLedgerOpsFeeCostForBill)。', editable: '只读。', }, 'lbl-col-brokerage-fee': { diff --git a/src/prototypes/lease-business-ledger/styles/index.css b/src/prototypes/lease-business-ledger/styles/index.css index 9bf4bca..ebcf6a0 100644 --- a/src/prototypes/lease-business-ledger/styles/index.css +++ b/src/prototypes/lease-business-ledger/styles/index.css @@ -104,20 +104,93 @@ justify-content: space-between; } +.ldb-toolbar-unpaid-group { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + .ldb-toolbar-unpaid-filter { display: inline-flex; align-items: center; - gap: 8px; + gap: 10px; + min-height: 36px; + padding: 0 14px 0 10px; font-size: 0.875rem; + font-weight: 500; + line-height: 1.2; color: var(--ln-ink); + background: linear-gradient(180deg, var(--ln-surface-card) 0%, color-mix(in srgb, var(--ln-canvas-soft) 35%, var(--ln-surface-card)) 100%); + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-pill); + box-shadow: var(--ln-shadow-soft); cursor: pointer; user-select: none; + transition: + border-color 0.18s ease, + background-color 0.18s ease, + box-shadow 0.18s ease, + color 0.18s ease, + transform 0.12s ease; } -.ldb-toolbar-unpaid-filter input { - width: 16px; - height: 16px; - cursor: pointer; +.ldb-toolbar-unpaid-filter:hover { + border-color: color-mix(in srgb, var(--ln-primary) 42%, var(--ln-hairline)); + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.07); +} + +.ldb-toolbar-unpaid-filter:active { + transform: translateY(1px); + box-shadow: var(--ln-shadow-soft); +} + +.ldb-toolbar-unpaid-filter.is-active { + border-color: var(--ln-primary); + background: color-mix(in srgb, var(--ln-primary) 9%, var(--ln-surface-card)); + color: var(--ln-primary-focus); + font-weight: 600; + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ln-primary) 20%, transparent), + 0 2px 10px color-mix(in srgb, var(--ln-primary) 12%, transparent); +} + +.ldb-toolbar-unpaid-filter.is-active:hover { + border-color: var(--ln-primary-focus); + background: color-mix(in srgb, var(--ln-primary) 12%, var(--ln-surface-card)); +} + +.ldb-toolbar-unpaid-filter:focus-within { + outline: 2px solid color-mix(in srgb, var(--ln-primary) 35%, transparent); + outline-offset: 2px; +} + +.ldb-toolbar-data-count { + display: inline-flex; + align-items: center; + gap: 2px; + min-height: 28px; + padding: 4px 10px; + font-size: 0.8125rem; + color: var(--ln-muted); + background: color-mix(in srgb, var(--ln-primary) 7%, var(--ln-surface-muted, #f1f5f9)); + border-radius: var(--ln-radius-pill); + white-space: nowrap; +} + +.ldb-toolbar-data-count-num { + font-weight: 700; + color: var(--ln-primary); +} + +@media (prefers-reduced-motion: reduce) { + .ldb-toolbar-unpaid-filter { + transition: none; + } + + .ldb-toolbar-unpaid-filter:active { + transform: none; + } } .ldb-kpi-card--clickable { @@ -191,8 +264,7 @@ white-space: nowrap; } -.ldb-col-check { width: 48px; } -.ldb-col-money { text-align: right; white-space: nowrap; } +.ldb-col-money { text-align: left; white-space: nowrap; } .ldb-col-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ldb-col-remark { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ldb-col-actions { min-width: 168px; white-space: nowrap; } @@ -418,7 +490,7 @@ .ldb-table .vm-table thead th.ldb-col-money, .ldb-table .vm-table tbody td.ldb-col-money { - text-align: right; + text-align: left; } .ldb-link-preview { @@ -434,7 +506,7 @@ .ldb-income-cell { display: inline-flex; align-items: center; - justify-content: flex-end; + justify-content: flex-start; gap: 6px; width: 100%; } @@ -468,7 +540,7 @@ max-width: 120px; padding: 4px 8px; font-size: 0.8125rem; - text-align: right; + text-align: left; } .ldb-computed-val { @@ -567,7 +639,7 @@ width: 88px; padding: 4px 6px; font-size: 0.8125rem; - text-align: right; + text-align: left; } .ldb-col-unreceived-warn { @@ -580,21 +652,16 @@ line-height: 1.45; } -.ldb-detail-popover-wrap { - position: relative; - display: inline-flex; - justify-content: flex-end; - width: 100%; -} - .ldb-detail-popover-trigger { + display: inline-block; + max-width: 100%; border: none; background: transparent; padding: 2px 6px; margin: -2px -6px; font: inherit; color: inherit; - text-align: right; + text-align: left; } .ldb-cell-clickable { @@ -623,96 +690,244 @@ color: var(--ln-ink); } -.ldb-detail-popover-card { - position: absolute; - right: 0; - top: calc(100% + 6px); - z-index: 30; - min-width: 320px; - max-width: min(520px, 80vw); - padding: 12px; - border-radius: var(--ln-radius-card); - border: 1px solid var(--ln-hairline); - background: var(--ln-surface-card); - box-shadow: var(--ln-shadow-hover); +.ldb-detail-card-modal { + width: min(640px, calc(100vw - 48px)); + max-height: min(560px, calc(100vh - 48px)); + display: flex; + flex-direction: column; + overflow: hidden; + animation: ldb-detail-card-in 0.22s ease-out; } -.ldb-detail-popover-title { - margin-bottom: 8px; +.ldb-detail-card-backdrop { + animation: ldb-detail-backdrop-in 0.18s ease-out; +} + +@keyframes ldb-detail-card-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes ldb-detail-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .ldb-detail-card-modal, + .ldb-detail-card-backdrop { + animation: none; + } +} + +.ldb-detail-card-header { + flex-shrink: 0; + padding-bottom: 16px; + border-bottom: 1px solid var(--ln-hairline); +} + +.ldb-detail-card-body { + flex: 1; + min-height: 0; + padding: 16px 24px 24px; + overflow: auto; + max-height: none; +} + +.ldb-detail-card-summary { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + padding: 12px 14px; + border-radius: var(--ln-radius-control); + background: var(--ln-surface-strong); + border: 1px solid var(--ln-hairline); +} + +.ldb-detail-card-summary-label { font-size: 0.8125rem; + color: var(--ln-muted); + font-weight: 500; +} + +.ldb-detail-card-summary-value { + font-size: 1.125rem; font-weight: 600; color: var(--ln-ink); + letter-spacing: -0.01em; } -.ldb-detail-popover-body { - max-height: 280px; - overflow: auto; +.ldb-detail-card-content { + min-width: 0; } -.ldb-detail-popover-footer { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--ln-hairline); - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.ldb-detail-popover-empty { - margin: 0; +.ldb-detail-card-meta { + margin: 0 0 8px; font-size: 0.8125rem; color: var(--ln-muted); } -.ldb-detail-popover-table-wrap { +.ldb-detail-card-table-wrap { overflow: auto; + max-height: min(360px, 48vh); + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-surface-card); +} + +.ldb-detail-card-footer { + flex-shrink: 0; + margin: 0; + padding: 16px 24px 20px; + border-top: 1px solid var(--ln-hairline); +} + +.ldb-detail-popover-empty { + margin: 0; + padding: 28px 16px; + text-align: center; + font-size: 0.875rem; + color: var(--ln-muted); + border: 1px dashed var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-canvas-soft); } .ldb-detail-popover-table { width: 100%; border-collapse: collapse; - font-size: 0.75rem; + font-size: 0.8125rem; } .ldb-detail-popover-table th, .ldb-detail-popover-table td { - padding: 6px 8px; + padding: 10px 12px; border-bottom: 1px solid var(--ln-hairline); text-align: left; - vertical-align: top; + vertical-align: middle; +} + +.ldb-detail-popover-table thead { + position: sticky; + top: 0; + z-index: 1; } .ldb-detail-popover-table th { font-weight: 600; + font-size: 0.75rem; color: var(--ln-muted); white-space: nowrap; + background: var(--ln-canvas-soft); } -.ldb-detail-popover-table .text-right { +.ldb-detail-popover-table tbody tr:hover { + background: rgba(37, 99, 235, 0.04); +} + +.ldb-detail-popover-table tbody tr:last-child td { + border-bottom: none; +} + +.ldb-detail-col-amount { + text-align: right; + font-weight: 500; + color: var(--ln-ink); +} + +.ldb-line-items-table-wrap { + max-height: min(400px, 52vh); +} + +.ldb-line-items-table th.ldb-line-items-col-action, +.ldb-line-items-table td.ldb-line-items-col-action { + width: 56px; + text-align: center; + padding-left: 8px; + padding-right: 8px; +} + +.ldb-line-items-table td.ldb-detail-col-amount { text-align: right; } -.ldb-line-items-editor { - display: flex; - flex-direction: column; - gap: 8px; -} - -.ldb-line-items-row { - display: grid; - grid-template-columns: 1.2fr 0.8fr 1fr; - gap: 6px; -} - .ldb-line-items-input { - padding: 4px 6px; - font-size: 0.75rem; + width: 100%; + min-height: 36px; + padding: 6px 10px; + font-size: 0.8125rem; + border-radius: var(--ln-radius-control); +} + +.ldb-line-items-input--amount { + text-align: right; +} + +.ldb-line-items-input:focus { + outline: none; + border-color: var(--vm-focus-border-color, #2563eb); +} + +.ldb-line-items-empty-cell { + padding: 24px 12px !important; + text-align: center; + color: var(--ln-muted); + font-size: 0.875rem; +} + +.ldb-line-items-row-delete { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--ln-radius-control); + background: transparent; + color: var(--ln-muted); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} + +.ldb-line-items-row-delete:hover { + color: var(--ln-error, #dc2626); + background: #fef2f2; + border-color: #fecaca; +} + +.ldb-line-items-row-delete:focus-visible { + outline: none; + border-color: var(--vm-focus-border-color, #2563eb); +} + +.ldb-line-items-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +.ldb-import-modal { + width: min(640px, calc(100vw - 48px)); } .ldb-line-items-add { - align-self: flex-start; - padding: 4px 8px; - font-size: 0.75rem; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 0.8125rem; } .ldb-action-btn--history { diff --git a/src/prototypes/lease-business-ledger/types.ts b/src/prototypes/lease-business-ledger/types.ts index 08d847d..06c34ab 100644 --- a/src/prototypes/lease-business-ledger/types.ts +++ b/src/prototypes/lease-business-ledger/types.ts @@ -5,6 +5,8 @@ export type ListViewMode = 'all' | 'unpaid'; export type MaintenanceBearBy = '客户承担' | '我司承担'; export type ContractEndMode = 'term' | 'date'; +export type { ExemptionPolicy } from './utils/constants'; + export interface LedgerLineItem { reason: string; amount: number; @@ -103,7 +105,7 @@ export interface LeaseLedgerFilters { month: string; dept: string; salesman: string; - plateNo: string; + plateNos: string[]; customerName: string; status: string; paymentFrom: string; @@ -115,7 +117,7 @@ export const EMPTY_LEASE_FILTERS: LeaseLedgerFilters = { month: '', dept: '', salesman: '', - plateNo: '', + plateNos: [], customerName: '', status: '', paymentFrom: '', @@ -171,6 +173,25 @@ export interface MaintenanceOrder { completedAt: string; } +/** 还车应结款费用明细(原型种子) */ +export interface ReturnSettlementFeeRecord { + id: string; + plateNo: string; + feeItem: string; + amount: number; + settlementDate: string; +} + +/** 维修保养明细台账(原型种子) */ +export interface MaintenanceLedgerRecord { + id: string; + plateNo: string; + orderNo: string; + type: string; + repairDate: string; + totalCost: number; +} + export interface AccidentRecord { id: string; plateNo: string; diff --git a/src/prototypes/lease-business-ledger/utils/constants.ts b/src/prototypes/lease-business-ledger/utils/constants.ts new file mode 100644 index 0000000..58b73e5 --- /dev/null +++ b/src/prototypes/lease-business-ledger/utils/constants.ts @@ -0,0 +1,23 @@ +/** 享免政策枚举(手选) */ +export const EXEMPTION_POLICY_OPTIONS = [ + '帕力安18T里程优惠政策', + '帕力安4.5T里程优惠政策', +] as const; + +export type ExemptionPolicy = (typeof EXEMPTION_POLICY_OPTIONS)[number]; + +/** 还车应结款中计入收入侧运维费的费用项(与财务管理-还车应结款一致) */ +export const RETURN_SETTLEMENT_OPS_FEE_ITEMS = [ + '清洗费', + '未结算保养', + '未结算维修', + '车损费用', + '工具损坏丢失费用', + '证件丢失费用', + '广告损坏丢失费用', + '送车服务费', + '接车服务费', + '轮胎磨损费用', +] as const; + +export type ReturnSettlementOpsFeeItem = (typeof RETURN_SETTLEMENT_OPS_FEE_ITEMS)[number]; diff --git a/src/prototypes/lease-business-ledger/utils/ledger-batch-import.ts b/src/prototypes/lease-business-ledger/utils/ledger-batch-import.ts new file mode 100644 index 0000000..4ada9d2 --- /dev/null +++ b/src/prototypes/lease-business-ledger/utils/ledger-batch-import.ts @@ -0,0 +1,385 @@ +import type { BillArchiveStatus, LeaseLedgerRow } from '../types'; + +export interface LedgerImportRow { + year: number; + month: number; + plateNo: string; + patch: Partial; + otherIncomeAmounts: number[]; + otherDeductionAmounts: number[]; +} + +type ImportColumnType = 'string' | 'amount' | 'integer' | 'boolean'; + +interface ImportScalarColumn { + header: string; + key: keyof LeaseLedgerRow; + type?: ImportColumnType; + required?: boolean; +} + +const INCOME_AMOUNT_HEADERS = ['其他收入金额', '其他收入金额2', '其他收入金额3'] as const; +const DEDUCTION_AMOUNT_HEADERS = ['其他减免金额', '其他减免金额2', '其他减免金额3'] as const; + +/** 可导入字段(不含系统自动计算项) */ +export const LEDGER_MANUAL_IMPORT_COLUMNS: ImportScalarColumn[] = [ + { header: '年份', key: 'year', type: 'integer', required: true }, + { header: '月份', key: 'month', type: 'integer', required: true }, + { header: '车牌号码', key: 'plateNo', required: true }, + { header: '业务部门', key: 'dept' }, + { header: '业务员', key: 'salesman' }, + { header: '客户名称', key: 'customerName' }, + { header: '项目名称', key: 'projectName' }, + { header: '享免政策', key: 'exemptionPolicy' }, + { header: '提车日期', key: 'pickupDate' }, + { header: '退车日期', key: 'returnDate' }, + { header: '保证金', key: 'deposit' }, + { header: '合同标的租金', key: 'contractRent', type: 'amount' }, + { header: '付款周期月数', key: 'paymentCycleMonths', type: 'integer' }, + { header: '保险采购金额', key: 'insurancePurchaseAmount', type: 'amount' }, + { header: '保险有效天数', key: 'insuranceEffectiveDays', type: 'integer' }, + { header: '保险上浮费', key: 'insuranceSurcharge', type: 'amount' }, + { header: '氢费承担方式', key: 'hydrogenBorneBy' }, + { header: '维保包干', key: 'maintenancePackageEnabled', type: 'boolean' }, + { header: '维保包干单价', key: 'maintenanceUnitPricePerKm', type: 'amount' }, + { header: '里程数', key: 'mileage', type: 'amount' }, + { header: '里程减免单价', key: 'mileageDeductionRate', type: 'amount' }, + { header: '实收金额', key: 'receivedAmount', type: 'amount' }, + { header: '居间费', key: 'brokerageFee', type: 'amount' }, + { header: '其他', key: 'otherCost', type: 'amount' }, + { header: '其他减免金额明细内容', key: 'otherDeductionDetail' }, + { header: '备注', key: 'remark' }, + { header: '账单状态', key: 'archiveStatus' }, + { header: '资产归属', key: 'assetOwnership' }, +]; + +const TEMPLATE_SAMPLE: Record = { + 年份: '2026', + 月份: '1', + 车牌号码: '沪A61121F', + 业务部门: '业务二部', + 业务员: '刘念念', + 客户名称: '安徽驰远供应链管理有限公司', + 项目名称: '安徽驰远供应链管租赁项目', + 享免政策: '帕力安18T里程优惠政策', + 提车日期: '2024-11-30', + 退车日期: '', + 保证金: '/', + 合同标的租金: '4500', + 付款周期月数: '1', + 保险采购金额: '12000', + 保险有效天数: '365', + 保险上浮费: '0', + 氢费承担方式: '我司承担', + 维保包干: '否', + 维保包干单价: '0', + 里程数: '0', + 里程减免单价: '0.5', + 实收金额: '4500', + 其他收入金额: '120.00', + 其他收入金额2: '', + 其他收入金额3: '', + 其他减免金额: '30.00', + 其他减免金额2: '', + 其他减免金额3: '', + 居间费: '500', + 其他: '0', + 其他减免金额明细内容: '', + 备注: '', + 账单状态: '草稿', + 资产归属: '租赁', +}; + +function parseCsvLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if (ch === '"') { + inQuotes = !inQuotes; + continue; + } + if (ch === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + continue; + } + current += ch; + } + + result.push(current.trim()); + return result; +} + +function normalizeHeader(value: string): string { + return value.replace(/^\*/, '').trim(); +} + +function parseAmount(value: string): number { + const cleaned = value.replace(/,/g, '').trim(); + if (!cleaned) return 0; + const num = Number(cleaned); + return Number.isFinite(num) ? Math.round(num * 100) / 100 : 0; +} + +function parseInteger(value: string): number { + const cleaned = value.trim(); + if (!cleaned) return 0; + const num = Number(cleaned); + return Number.isFinite(num) ? Math.trunc(num) : 0; +} + +function parseBoolean(value: string): boolean | undefined { + const text = value.trim(); + if (!text) return undefined; + if (['是', 'true', '1', '启用', 'Y', 'y'].includes(text)) return true; + if (['否', 'false', '0', '关闭', 'N', 'n'].includes(text)) return false; + return undefined; +} + +function parseArchiveStatus(value: string): BillArchiveStatus | undefined { + const text = value.trim(); + if (text === '草稿' || text === '未归档' || text === '已归档') return text; + return undefined; +} + +function parseHydrogenBorneBy(value: string): LeaseLedgerRow['hydrogenBorneBy'] | undefined { + const text = value.trim(); + if (text === '我司承担' || text === '客户承担') return text; + return undefined; +} + +function parseAssetOwnership(value: string): LeaseLedgerRow['assetOwnership'] | undefined { + const text = value.trim(); + if (text === '自有' || text === '租赁' || text === '挂靠') return text; + return undefined; +} + +function collectAmountColumns(headers: string[], names: readonly string[]): number[] { + const cols: number[] = []; + headers.forEach((cell, index) => { + if (names.includes(cell as typeof names[number])) cols.push(index); + }); + return cols; +} + +function parseScalarValue( + column: ImportScalarColumn, + raw: string, +): string | number | boolean | BillArchiveStatus | LeaseLedgerRow['hydrogenBorneBy'] | LeaseLedgerRow['assetOwnership'] | undefined { + const text = raw.trim(); + if (!text) return undefined; + + switch (column.type) { + case 'amount': + return parseAmount(text); + case 'integer': + return parseInteger(text); + case 'boolean': { + const bool = parseBoolean(text); + return bool; + } + default: + break; + } + + if (column.key === 'archiveStatus') return parseArchiveStatus(text); + if (column.key === 'hydrogenBorneBy') return parseHydrogenBorneBy(text); + if (column.key === 'assetOwnership') return parseAssetOwnership(text); + if (column.key === 'deposit') return text; + return text; +} + +function buildTemplateHeaders(): string[] { + return [ + ...LEDGER_MANUAL_IMPORT_COLUMNS.map((col) => (col.required ? `*${col.header}` : col.header)), + ...INCOME_AMOUNT_HEADERS, + ...DEDUCTION_AMOUNT_HEADERS, + ]; +} + +function buildTemplateSampleRow(): string[] { + const headers = buildTemplateHeaders().map(normalizeHeader); + return headers.map((header) => TEMPLATE_SAMPLE[header] ?? ''); +} + +export function lineItemsShowDetailColumns(items: { reason: string; remark: string }[]): boolean { + return items.some((item) => item.reason.trim() || item.remark.trim()); +} + +export function downloadLedgerBatchImportTemplate(): void { + const headers = buildTemplateHeaders(); + const sample = buildTemplateSampleRow(); + const csv = `\uFEFF${headers.join(',')}\n${sample.join(',')}`; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = '租赁业务台账导入模板.csv'; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function parseLedgerBatchImportFile(text: string): LedgerImportRow[] { + const lines = text + .replace(/^\uFEFF/, '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + let headerIndex = -1; + let headerCells: string[] = []; + const columnIndex = new Map(); + + for (let i = 0; i < lines.length; i += 1) { + const cells = parseCsvLine(lines[i]).map(normalizeHeader); + if (cells.includes('年份') && cells.includes('月份') && (cells.includes('车牌号码') || cells.includes('车牌号'))) { + headerIndex = i; + headerCells = cells; + cells.forEach((cell, index) => columnIndex.set(cell, index)); + if (!columnIndex.has('车牌号码') && columnIndex.has('车牌号')) { + columnIndex.set('车牌号码', columnIndex.get('车牌号')!); + } + break; + } + } + + if (headerIndex < 0) return []; + + const incomeAmountCols = collectAmountColumns(headerCells, INCOME_AMOUNT_HEADERS); + const deductionAmountCols = collectAmountColumns(headerCells, DEDUCTION_AMOUNT_HEADERS); + + const rows: LedgerImportRow[] = []; + for (let i = headerIndex + 1; i < lines.length; i += 1) { + const cells = parseCsvLine(lines[i]); + const year = parseInteger(cells[columnIndex.get('年份') ?? -1] ?? ''); + const month = parseInteger(cells[columnIndex.get('月份') ?? -1] ?? ''); + const plateNo = (cells[columnIndex.get('车牌号码') ?? -1] ?? '').trim(); + if (!year || !month || !plateNo) continue; + + const patch: Partial = {}; + for (const column of LEDGER_MANUAL_IMPORT_COLUMNS) { + if (column.key === 'year' || column.key === 'month' || column.key === 'plateNo') continue; + const colIdx = columnIndex.get(column.header); + if (colIdx == null) continue; + const parsed = parseScalarValue(column, cells[colIdx] ?? ''); + if (parsed === undefined) continue; + (patch as Record)[column.key] = parsed; + } + + const otherIncomeAmounts = incomeAmountCols + .map((col) => parseAmount(cells[col] ?? '')) + .filter((amount) => amount > 0); + const otherDeductionAmounts = deductionAmountCols + .map((col) => parseAmount(cells[col] ?? '')) + .filter((amount) => amount > 0); + + const hasPatch = Object.keys(patch).length > 0; + if (!hasPatch && otherIncomeAmounts.length === 0 && otherDeductionAmounts.length === 0) continue; + + rows.push({ year, month, plateNo, patch, otherIncomeAmounts, otherDeductionAmounts }); + } + + return rows; +} + +export function buildAmountOnlyLineItems(amounts: number[]): LeaseLedgerRow['otherIncomeItems'] { + return amounts.map((amount) => ({ reason: '', amount, remark: '' })); +} + +export function applyLedgerImportToRow( + row: LeaseLedgerRow, + importRow: LedgerImportRow, +): LeaseLedgerRow { + return { + ...row, + ...importRow.patch, + ...(importRow.otherIncomeAmounts.length > 0 + ? { otherIncomeItems: buildAmountOnlyLineItems(importRow.otherIncomeAmounts) } + : {}), + ...(importRow.otherDeductionAmounts.length > 0 + ? { otherDeductionItems: buildAmountOnlyLineItems(importRow.otherDeductionAmounts) } + : {}), + }; +} + +export function createDraftLedgerRowFromImport( + importRow: LedgerImportRow, + idSuffix: number, +): LeaseLedgerRow { + const plateKey = importRow.plateNo.replace(/\s+/g, ''); + const base: LeaseLedgerRow = { + id: `import-${importRow.year}-${importRow.month}-${plateKey}-${idSuffix}`, + status: '未收款', + archiveStatus: '草稿', + maintainerName: '批量导入', + year: importRow.year, + month: importRow.month, + plateNo: importRow.plateNo, + dept: '', + salesman: '', + vehicleBrand: '', + vehicleModel: '', + customerName: '', + projectName: '', + valueAddedService: '', + exemptionPolicy: '', + pickupDate: '', + contractStartDate: '', + contractEndDate: '', + periodStartDate: '', + returnDate: '', + avgDays: 0, + deposit: '/', + contractRent: 0, + paymentCycleMonths: 1, + serviceFee: 0, + maintenancePackageEnabled: false, + maintenanceUnitPricePerKm: 0, + mileage: 0, + customerBearsMaintenance: false, + linkedMaintenanceIds: [], + linkedAccidentIds: [], + linkedReceiptIds: [], + hydrogenBorneBy: '', + isLatestBill: false, + receivableTotal: 0, + receivableRent: 0, + monthlyIncome: 0, + monthlyRent: 0, + maintenanceIncome: 0, + insuranceSurcharge: 0, + opsFeeIncome: 0, + otherIncome: 0, + mileageDeduction: 0, + otherDeduction: 0, + receivedAmount: 0, + invoiceDate: '', + actualPaymentDate: '', + paymentMethod: '', + vehicleStandardCost: 0, + vehicleActualCost: 0, + insuranceFee: 0, + hydrogenFee: 0, + opsFeeCost: 0, + brokerageFee: 0, + otherCost: 0, + totalCost: 0, + profitLoss: 0, + gpsMileage: '', + otherDeductionDetail: '', + assetOwnership: '自有', + signingCompany: '', + remark: '', + }; + + return applyLedgerImportToRow(base, importRow); +} + +export function matchLedgerImportRow(row: LeaseLedgerRow, importRow: LedgerImportRow): boolean { + return row.year === importRow.year + && row.month === importRow.month + && row.plateNo.replace(/\s+/g, '') === importRow.plateNo.replace(/\s+/g, ''); +} diff --git a/src/prototypes/lease-business-ledger/utils/ledger.ts b/src/prototypes/lease-business-ledger/utils/ledger.ts index 2de7cda..ad4e99d 100644 --- a/src/prototypes/lease-business-ledger/utils/ledger.ts +++ b/src/prototypes/lease-business-ledger/utils/ledger.ts @@ -9,9 +9,12 @@ import type { LeaseKpiSummary, ListViewMode, MaintenanceBearBy, + MaintenanceLedgerRecord, MaintenanceOrder, ReceiptRecord, + ReturnSettlementFeeRecord, } from '../types'; +import { RETURN_SETTLEMENT_OPS_FEE_ITEMS } from './constants'; /** 原型:车牌主数据(导入/新增时按车牌自动匹配品牌型号) */ export const PLATE_VEHICLE_MAP: Record = { @@ -376,6 +379,48 @@ export function sumMaintenanceForBill( ); } +export function getReturnSettlementFeesForBill( + row: Pick, + records: ReturnSettlementFeeRecord[], +): ReturnSettlementFeeRecord[] { + const allowedItems = new Set(RETURN_SETTLEMENT_OPS_FEE_ITEMS); + return records.filter( + (item) => item.plateNo === row.plateNo + && isInBillMonth(item.settlementDate, row.year, row.month) + && allowedItems.has(item.feeItem), + ); +} + +export function sumReturnSettlementOpsFeeForBill( + row: Pick, + records: ReturnSettlementFeeRecord[], +): number { + return roundMoney( + getReturnSettlementFeesForBill(row, records) + .reduce((sum, item) => sum + item.amount, 0), + ); +} + +export function getMaintenanceLedgerRecordsForBill( + row: Pick, + records: MaintenanceLedgerRecord[], +): MaintenanceLedgerRecord[] { + return records.filter( + (item) => item.plateNo === row.plateNo + && isInBillMonth(item.repairDate, row.year, row.month), + ); +} + +export function sumMaintenanceLedgerOpsFeeCostForBill( + row: Pick, + records: MaintenanceLedgerRecord[], +): number { + return roundMoney( + getMaintenanceLedgerRecordsForBill(row, records) + .reduce((sum, item) => sum + item.totalCost, 0), + ); +} + export function getHydrogenFeesForBill( row: Pick, records: HydrogenFeeRecord[], @@ -517,6 +562,8 @@ export function enrichLeaseRow( row: LeaseLedgerRow, options?: { maintenanceOrders?: MaintenanceOrder[]; + returnSettlementFees?: ReturnSettlementFeeRecord[]; + maintenanceLedgerRecords?: MaintenanceLedgerRecord[]; accidentRecords?: AccidentRecord[]; biMileage?: BiMileageRecord[]; hydrogenFees?: HydrogenFeeRecord[]; @@ -546,7 +593,8 @@ export function enrichLeaseRow( const linkedReceiptIds = row.linkedReceiptIds ?? []; const hasReceipt = row.receivedAmount > 0 && (linkedReceiptIds.length > 0 || !isBlankDisplayValue(row.actualPaymentDate)); - const maintenanceOrders = options?.maintenanceOrders ?? []; + const returnSettlementFees = options?.returnSettlementFees ?? []; + const maintenanceLedgerRecords = options?.maintenanceLedgerRecords ?? []; const accidentRecords = options?.accidentRecords ?? []; const biMileage = options?.biMileage ?? []; const hydrogenFees = options?.hydrogenFees ?? []; @@ -568,8 +616,8 @@ export function enrichLeaseRow( const otherDeduction = sumLineItems(row.otherDeductionItems) || row.otherDeduction || 0; const maintenanceIncome = calcMaintenanceIncome(row); - const opsFeeIncome = sumMaintenanceForBill(row, maintenanceOrders, '客户承担'); - const opsFeeCost = sumMaintenanceForBill(row, maintenanceOrders, '我司承担'); + const opsFeeIncome = sumReturnSettlementOpsFeeForBill(row, returnSettlementFees); + const opsFeeCost = sumMaintenanceLedgerOpsFeeCostForBill(row, maintenanceLedgerRecords); const insuranceSurcharge = linkedAccidentIds.length > 0 ? roundMoney(sumLinkedAccidentSurcharge(linkedAccidentIds, accidentRecords)) : row.insuranceSurcharge; @@ -693,7 +741,10 @@ export function applyLeaseFilters(rows: LeaseLedgerRow[], filters: LeaseLedgerFi if (filters.month && String(row.month) !== filters.month) return false; if (filters.dept && row.dept !== filters.dept) return false; if (filters.salesman && row.salesman !== filters.salesman) return false; - if (filters.plateNo && row.plateNo !== filters.plateNo) return false; + if (filters.plateNos.length > 0) { + const rowPlate = row.plateNo.replace(/\s+/g, '').toUpperCase(); + if (!filters.plateNos.includes(rowPlate)) return false; + } if (filters.status && row.status !== filters.status) return false; if (filters.customerName && !row.customerName.includes(filters.customerName)) return false; if (filters.paymentFrom && (row.paymentDueDate ?? '') < filters.paymentFrom) return false; diff --git a/src/prototypes/lease-business-line-overview/annotation-source.json b/src/prototypes/lease-business-line-overview/annotation-source.json index 2dd0363..2a4046d 100644 --- a/src/prototypes/lease-business-line-overview/annotation-source.json +++ b/src/prototypes/lease-business-line-overview/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "lease-business-line-overview", "pageId": "overview", - "updatedAt": 1783328981487, + "updatedAt": 1783352657924, "nodes": [ { "id": "lblo-system-intro", diff --git a/src/prototypes/lease-business-line-overview/lines.ts b/src/prototypes/lease-business-line-overview/lines.ts index 7b425e5..aefff56 100644 --- a/src/prototypes/lease-business-line-overview/lines.ts +++ b/src/prototypes/lease-business-line-overview/lines.ts @@ -216,6 +216,8 @@ export const LEASE_MODULES: LineModule[] = [ ], closure: '业务管理组发起还车应结款总审批;审批完成后自动归档,租赁单条业务从签约到还车全链路闭环。', + prototypeHref: '/prototypes/vehicle-return-settlement', + prototypeLabel: '打开还车应结款', }, ]; @@ -590,7 +592,7 @@ export const OPS_MODULES: LineModule[] = [ '维修与保养费用计入车辆盈亏核算,实现业财一体化。', ], closure: '维保成本可追溯、可分摊,支撑单车盈亏与对客户计费闭环。', - prototypeHref: '/prototypes/oneos-web-ledger-data', + prototypeHref: '/prototypes/vehicle-maintenance-ledger', prototypeLabel: '打开车辆维修明细', }, { diff --git a/src/prototypes/lease-contract-management/LeaseContractManagement.jsx b/src/prototypes/lease-contract-management/LeaseContractManagement.jsx index 0481a8a..c76cbb4 100644 --- a/src/prototypes/lease-contract-management/LeaseContractManagement.jsx +++ b/src/prototypes/lease-contract-management/LeaseContractManagement.jsx @@ -4,6 +4,7 @@ import LeaseContractCreate from './LeaseContractCreate.jsx'; import LeaseContractFleetSummary from './LeaseContractFleetSummary.jsx'; import { StatusTag } from '../vehicle-management/components/StatusTag'; +import { TablePagination } from '../../common/TablePagination'; import { LEASE_CONTRACT_LIST_RECORDS, enrichLeaseContractListRecords, @@ -391,7 +392,7 @@ const Component = function(props) { var fleetKpiDesc = '按品牌型号统计在租车辆;点击展开下方车型卡片。隐藏面板后,再次点击将弹出各车型数量卡片。'; - var listChromeOffsetPx = (_fleetExpanded[0] && !_fleetPanelHidden[0]) ? 592 : 468; + var listChromeOffsetPx = (_fleetExpanded[0] && !_fleetPanelHidden[0]) ? 558 : 434; var filteredList = useMemo(function() { var overrides = _recordOverrides[0] || {}; @@ -475,99 +476,6 @@ const Component = function(props) { return filteredList.slice(start, start + _pageSize[0]); }, [filteredList, safePage, _pageSize[0]]); - function buildPageItems(current, pages) { - if (pages <= 7) { - var all = []; - for (var pi = 1; pi <= pages; pi++) all.push(pi); - return all; - } - var items = [1]; - if (current > 3) items.push('ellipsis'); - var startP = Math.max(2, current - 1); - var endP = Math.min(pages - 1, current + 1); - for (var pj = startP; pj <= endP; pj++) items.push(pj); - if (current < pages - 2) items.push('ellipsis'); - if (pages > 1) items.push(pages); - return items; - } - - function renderTablePagination() { - if (totalCount === 0) return null; - var pageItems = buildPageItems(safePage, totalPages); - var pageSizeOptions = [10, 20, 50, 100]; - return React.createElement( - 'div', - { className: 'vm-pagination', role: 'navigation', 'aria-label': '表格分页' }, - React.createElement( - 'label', - { className: 'vm-pagination-size' }, - React.createElement('span', { className: 'vm-pagination-size-label' }, '每页'), - React.createElement( - 'select', - { - className: 'vm-pagination-select', - value: _pageSize[0], - 'aria-label': '每页显示条数', - onChange: function(e) { - _pageSize[1](Number(e.target.value)); - _page[1](1); - } - }, - pageSizeOptions.map(function(size) { - return React.createElement('option', { key: size, value: size }, size); - }) - ), - React.createElement('span', { className: 'vm-pagination-size-label' }, '条') - ), - React.createElement( - 'div', - { className: 'vm-pagination-pages' }, - React.createElement( - 'button', - { - type: 'button', - className: 'vm-pagination-nav', - disabled: safePage <= 1, - 'aria-label': '上一页', - onClick: function() { _page[1](safePage - 1); } - }, - React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, - React.createElement('path', { d: 'm15 18-6-6 6-6' }) - ) - ), - pageItems.map(function(item, index) { - if (item === 'ellipsis') { - return React.createElement('span', { key: 'ellipsis-' + index, className: 'vm-pagination-ellipsis', 'aria-hidden': true }, '…'); - } - return React.createElement( - 'button', - { - key: item, - type: 'button', - className: 'vm-pagination-page' + (safePage === item ? ' active' : ''), - 'aria-current': safePage === item ? 'page' : undefined, - onClick: function() { _page[1](item); } - }, - item - ); - }), - React.createElement( - 'button', - { - type: 'button', - className: 'vm-pagination-nav', - disabled: safePage >= totalPages, - 'aria-label': '下一页', - onClick: function() { _page[1](safePage + 1); } - }, - React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, - React.createElement('path', { d: 'm9 18 6-6-6-6' }) - ) - ) - ) - ); - } - var handleQuery = useCallback(function() { _appliedFilter[1]({ contractCode: _contractCode[0], @@ -2765,21 +2673,23 @@ const Component = function(props) { style: { '--lc-list-chrome-offset': listChromeOffsetPx + 'px' }, }, React.createElement('style', null, ONEOS_ANT_TABLE_GLOBAL_FIX.join('\n')), - React.createElement('section', { className: 'vm-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' }, + React.createElement('section', { className: 'vm-filter-card ldb-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' }, React.createElement('header', { className: 'vm-filter-header' }, React.createElement('h2', { className: 'vm-filter-title' }, '筛选条件') ), - React.createElement('div', { className: 'vm-filter-grid' }, filterNodes), - React.createElement('div', { className: 'vm-filter-actions' }, + React.createElement('div', { className: 'ldb-filter-body' }, + React.createElement('div', { className: 'vm-filter-grid ldb-filter-grid ldb-filter-grid--primary' }, filterNodes) + ), + React.createElement('div', { className: 'vm-filter-actions ldb-filter-actions' }, filterItems.length > 4 ? React.createElement('button', { type: 'button', - className: 'vm-btn vm-btn-link', + className: 'vm-btn vm-btn-link ldb-filter-toggle', onClick: function() { _filterExpanded[1](!_filterExpanded[0]); } }, _filterExpanded[0] ? '收起' : '更多筛选') : null, - React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-ghost', onClick: handleReset }, '重置'), - React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-primary', onClick: handleQuery }, + React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', onClick: handleReset }, '重置'), + React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-primary ldb-toolbar-btn', onClick: handleQuery }, React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, @@ -2934,7 +2844,18 @@ const Component = function(props) { }, }) ), - React.createElement('div', { className: 'vm-table-footer' }, renderTablePagination()) + React.createElement('div', { className: 'vm-table-footer' }, + React.createElement(TablePagination, { + page: safePage, + pageSize: _pageSize[0], + total: totalCount, + onPageChange: function(nextPage) { _page[1](nextPage); }, + onPageSizeChange: function(size) { + _pageSize[1](size); + _page[1](1); + } + }) + ) ) ), React.createElement(Modal, { diff --git a/src/prototypes/lease-contract-management/annotation-source.json b/src/prototypes/lease-contract-management/annotation-source.json index bdbb104..366df72 100644 --- a/src/prototypes/lease-contract-management/annotation-source.json +++ b/src/prototypes/lease-contract-management/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "lease-contract-management", "pageId": "list", - "updatedAt": 1783328981487, + "updatedAt": 1783352657925, "nodes": [ { "id": "lc-list-filter", diff --git a/src/prototypes/lease-contract-management/lease-contract-vehicle-clause.js b/src/prototypes/lease-contract-management/lease-contract-vehicle-clause.js index 5eb7a28..3cdf2b4 100644 --- a/src/prototypes/lease-contract-management/lease-contract-vehicle-clause.js +++ b/src/prototypes/lease-contract-management/lease-contract-vehicle-clause.js @@ -111,9 +111,15 @@ function wrapClauseParagraph(html, def) { return html.slice(0, pStart) + wrapped + html.slice(pEnd + 4); } +function htmlHasVehicleClauseMarks(html) { + var source = String(html || ''); + return source.indexOf('ct-vehicle-clause') >= 0 || source.indexOf('data-vehicle-clause') >= 0; +} + /** 为 V26.6 标准合同注入演示用条件条款包裹(正式环境由模板管理产出) */ export function injectPrototypeVehicleClauses(html) { var next = String(html || ''); + if (htmlHasVehicleClauseMarks(next)) return next; PROTOTYPE_VEHICLE_CLAUSE_DEFS.forEach(function (def) { next = wrapClauseParagraph(next, def); }); diff --git a/src/prototypes/lease-contract-management/lease-order-vars.js b/src/prototypes/lease-contract-management/lease-order-vars.js index 9f11f6f..6925646 100644 --- a/src/prototypes/lease-contract-management/lease-order-vars.js +++ b/src/prototypes/lease-contract-management/lease-order-vars.js @@ -45,13 +45,9 @@ export function parsePlateSearchText(text) { .filter(Boolean); } -export var LEASE_VEHICLE_BRAND_MODEL_CATALOG = [ - { brand: '现代', models: ['帕力安牌4.5吨冷链车', '18吨氢燃料电池车', '9.6米厢式货车'] }, - { brand: '苏龙', models: ['9.6米氢燃料电池车'] }, - { brand: '飞驰', models: ['集卡头', '半挂车'] }, - { brand: '跃进', models: ['4.2米冷链车'] }, - { brand: '宇通', models: ['氢燃料电池客车'] }, -]; +import { getVehicleBrandModelCatalog } from '../../common/vehicle-brand-model-catalog.js'; + +export var LEASE_VEHICLE_BRAND_MODEL_CATALOG = getVehicleBrandModelCatalog(); export var PROVINCE_CITY_CASCADER_OPTIONS = [ { diff --git a/src/prototypes/lease-contract-management/styles/index.css b/src/prototypes/lease-contract-management/styles/index.css index 5f3019c..5805015 100644 --- a/src/prototypes/lease-contract-management/styles/index.css +++ b/src/prototypes/lease-contract-management/styles/index.css @@ -1,6 +1,7 @@ /** * 租赁合同管理 — 模块样式(基于 vehicle-management vm-*) */ +@import url('../../ledger-shared/styles/ledger-page.css'); @import url('../../contract-template-management/styles/contract-template.css'); @import url('./lease-contract.css'); @import url('./lease-contract-create.css'); diff --git a/src/prototypes/lease-contract-management/styles/lease-contract.css b/src/prototypes/lease-contract-management/styles/lease-contract.css index 90c0b8b..4319112 100644 --- a/src/prototypes/lease-contract-management/styles/lease-contract.css +++ b/src/prototypes/lease-contract-management/styles/lease-contract.css @@ -81,7 +81,7 @@ /* —— 列表紧凑模式:一屏展示更多行 —— */ .vm-page.lc-page.lc-page--list-dense { - --lc-list-chrome-offset: 468px; + --lc-list-chrome-offset: 434px; display: flex; flex-direction: column; min-height: calc(100vh - 32px); @@ -246,13 +246,13 @@ @media (max-height: 820px) { .vm-page.lc-page.lc-page--list-dense { - --lc-list-chrome-offset: 440px; + --lc-list-chrome-offset: 406px; } } @media (min-height: 960px) { .vm-page.lc-page.lc-page--list-dense { - --lc-list-chrome-offset: 452px; + --lc-list-chrome-offset: 418px; } } diff --git a/src/prototypes/ledger-shared/DESIGN.md b/src/prototypes/ledger-shared/DESIGN.md index d01dc20..21338e3 100644 --- a/src/prototypes/ledger-shared/DESIGN.md +++ b/src/prototypes/ledger-shared/DESIGN.md @@ -10,6 +10,7 @@ |------|----------|----------| | 租赁业务台账 | `vm-page ldb-page` | `lease-business-ledger/styles/index.css` | | 自营业务台账 | `vm-page ldb-page` | 同上(复用租赁台账模块样式) | +| 车辆维修明细 | `vm-page ldb-page mldb-page` | `vehicle-maintenance-ledger/styles/index.css` | | 租赁合同管理 | `vm-page lc-page` | `lease-contract-management/styles/lease-contract.css` | 台账页(`ldb-page`)与合同列表页(`lc-page`)在以下维度保持一致: @@ -30,7 +31,7 @@ vm-page ldb-page │ ├── vm-table-toolbar ldb-table-toolbar │ └── vm-table-card │ ├── ldb-table-wrap > ldb-table -│ └── vm-table-footer ldb-table-footer # 左侧总条数 + TablePagination(见 vm-shared/DESIGN.md) +│ └── vm-table-footer ldb-table-footer # TablePagination(见 vm-shared/DESIGN.md) ``` ## 类名约定 @@ -43,8 +44,8 @@ vm-page ldb-page ## 筛选区 -- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区 -- 日期区间分隔符使用「**至**」(`ldb-date-range-sep`),不用 em dash +- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区(与全局 `vm-filter-expand` / `src/common/vm-filter-panel.ts` 规则一致) +- 日期区间使用 `DateRangeFilterField`(见 `vm-shared/DESIGN.md`),展示分隔符为「**至**」 - 重置 / 查询按钮加 `ldb-toolbar-btn` ## KPI @@ -57,13 +58,16 @@ vm-page ldb-page - 容器:`ldb-table-wrap` + `ldb-table` - 底部分页:使用 `src/common/TablePagination.tsx`,布局见 `vm-shared/DESIGN.md` - 表头排序:`ldb-th-sort`;列宽:`ldb-col-resize-handle` -- 金额列:`ldb-col-money` + `tabular-nums` +- 金额列:`ldb-col-money` + `tabular-nums`;**与文本列统一左对齐**(含明细弹层触发按钮 `ldb-detail-popover-trigger`) - 操作列:`ldb-action-btn--edit` / `--delete`,右冻结 `sticky-col-right` - 勾选列:左冻结 `sticky-col-left` +- 租赁业务台账:自勾选列至「客户名称」(`LEFT_STICKY_LAST_COLUMN_KEY`)均为左冻结,「增值服务」及右侧列参与横向滚动;末列冻结列加 `sticky-col-left-last` 分隔阴影,列类名 `ldb-col-check`(全局规范见 [`vm-shared/DESIGN.md`](../vm-shared/DESIGN.md) 多选框章节) +- 工具栏 / 弹窗内多选:为 `input` 添加 `vm-checkbox` 类名 ## 共享样式文件 - 页面壳层:`ledger-shared/styles/ledger-page.css` - 台账组件样式:`lease-business-ledger/styles/index.css`(import 上述壳层) +- 多选框:`src/common/vm-checkbox.css`(由 `vehicle-management/style.css` 全局引入) 新增台账类页面应直接复用 `ldb-page` 与现有组件,避免再建模块专属页面类(如 `sob-page`)。 diff --git a/src/prototypes/ledger-shared/styles/ledger-page.css b/src/prototypes/ledger-shared/styles/ledger-page.css index 611524c..e7f0066 100644 --- a/src/prototypes/ledger-shared/styles/ledger-page.css +++ b/src/prototypes/ledger-shared/styles/ledger-page.css @@ -10,7 +10,27 @@ padding-bottom: 48px; } -.vm-page.ldb-page .vm-filter-card.ldb-filter-card { +.vm-page.ldb-page .ldb-page-header, +.vm-page.lc-page .lc-page-header { + margin-bottom: 16px; +} + +.vm-page.ldb-page .ldb-page-header h1, +.vm-page.lc-page .lc-page-header h1 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--ln-ink); + letter-spacing: -0.02em; +} + +.vm-page.ldb-page .vm-filter-card.ldb-filter-card, +.vm-page.lc-page .vm-filter-card.ldb-filter-card { + margin-bottom: 16px; +} + +.vm-page.ldb-page .vm-filter-card.ldb-filter-card .vm-filter-header, +.vm-page.lc-page .vm-filter-card.ldb-filter-card .vm-filter-header { margin-bottom: 16px; } @@ -78,7 +98,30 @@ position: sticky; left: 0; z-index: 2; - box-shadow: 4px 0 8px -4px rgba(15, 23, 42, 0.06); + box-shadow: none; +} + +.ldb-table .sticky-col-left-last { + box-shadow: 4px 0 8px -4px rgba(15, 23, 42, 0.08); +} + +.ldb-table .sticky-col-left-last::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: -12px; + width: 12px; + pointer-events: none; + background: linear-gradient(to right, var(--ln-surface-card) 40%, transparent); +} + +.ldb-table thead th.sticky-col-left-last::after { + background: linear-gradient(to right, var(--ln-canvas-soft) 40%, transparent); +} + +.ldb-table tbody tr:hover td.sticky-col-left-last::after { + background: linear-gradient(to right, var(--ln-canvas-soft) 40%, transparent); } .ldb-table .sticky-col-right { @@ -119,7 +162,7 @@ .ldb-table-footer { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; gap: 16px; flex-wrap: wrap; } @@ -154,24 +197,86 @@ color: var(--ln-muted); } +.ldb-filter-expand.is-expanded .ldb-filter-expand-inner { + overflow: visible; +} + +.ldb-filter-body { + display: flex; + flex-direction: column; + gap: 0; + overflow: visible; +} + +.vm-page.ldb-page .vm-filter-card.ldb-filter-card { + overflow: visible; +} + +.vm-page.ldb-page .vm-filter-grid.ldb-filter-grid--primary { + margin-bottom: 0; +} + .ldb-filter-expand { display: grid; grid-template-rows: 0fr; - transition: grid-template-rows 0.28s ease, opacity 0.22s ease; + transition: grid-template-rows 0.28s ease, opacity 0.22s ease, margin-top 0.22s ease; opacity: 0; + margin-top: 0; + pointer-events: none; + visibility: hidden; } .ldb-filter-expand.is-expanded { grid-template-rows: 1fr; opacity: 1; + margin-top: 12px; + pointer-events: auto; + visibility: visible; } .ldb-filter-expand-inner { overflow: hidden; + min-height: 0; } .ldb-filter-expand-grid { - padding-top: 4px; + padding-top: 0; +} + +.ldb-filter-actions { + margin-top: 16px; +} + +.ldb-filter-toggle { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ldb-filter-toggle-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--ln-primary); + color: #fff; + font-size: 0.6875rem; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +@media (prefers-reduced-motion: reduce) { + .ldb-filter-expand { + transition: none; + } + + .ldb-filter-toggle-icon { + transition: none; + } } .ldb-filter-toggle-icon { diff --git a/src/prototypes/oneos-web-finance/index.tsx b/src/prototypes/oneos-web-finance/index.tsx index b45732c..f3cbebe 100644 --- a/src/prototypes/oneos-web-finance/index.tsx +++ b/src/prototypes/oneos-web-finance/index.tsx @@ -1,32 +1,26 @@ /** * @name 财务管理 - * 自 ONE-OS web端 原稿复刻(10 个页面) + * 自 ONE-OS web端 原稿复刻(7 个页面) */ import '../../common/oneosWebLegacy/legacyGlobals'; import React from 'react'; -import Page1 from './pages/01-还车应结款-查看.jsx'; -import Page2 from './pages/02-还车应结款-费用明细.jsx'; -import Page3 from './pages/03-还车应结款.jsx'; -import Page4 from './pages/04-提车收款单-编辑.jsx'; -import Page5 from './pages/05-提车应收款-查看.jsx'; -import Page6 from './pages/06-提车应收款-开票信息.jsx'; -import Page7 from './pages/07-提车应收款-审核.jsx'; -import Page8 from './pages/08-提车应收款-提车收款单.jsx'; -import Page9 from './pages/09-提车应收款.jsx'; -import Page10 from './pages/10-租赁账单.jsx'; +import Page1 from './pages/04-提车收款单-编辑.jsx'; +import Page2 from './pages/05-提车应收款-查看.jsx'; +import Page3 from './pages/06-提车应收款-开票信息.jsx'; +import Page4 from './pages/07-提车应收款-审核.jsx'; +import Page5 from './pages/08-提车应收款-提车收款单.jsx'; +import Page6 from './pages/09-提车应收款.jsx'; +import Page7 from './pages/10-租赁账单.jsx'; import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell'; const pages = [ - { id: 'u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e-u', title: '还车应结款-查看', component: Page1 }, - { id: 'u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e-u-2', title: '还车应结款-费用明细', component: Page2 }, - { id: 'u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e', title: '还车应结款', component: Page3 }, - { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u6536u6b3eu5355-u', title: '提车收款单-编辑', component: Page4 }, - { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u', title: '提车应收款-查看', component: Page5 }, - { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-6', title: '提车应收款-开票信息', component: Page6 }, - { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-7', title: '提车应收款-审核', component: Page7 }, - { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-8', title: '提车应收款-提车收款单', component: Page8 }, - { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e', title: '提车应收款', component: Page9 }, - { id: 'u8d22u52a1u7ba1u7406-u79dfu8d41u8d26u5355', title: '租赁账单', component: Page10 }, + { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u6536u6b3eu5355-u', title: '提车收款单-编辑', component: Page1 }, + { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u', title: '提车应收款-查看', component: Page2 }, + { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-6', title: '提车应收款-开票信息', component: Page3 }, + { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-7', title: '提车应收款-审核', component: Page4 }, + { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e-u-8', title: '提车应收款-提车收款单', component: Page5 }, + { id: 'u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e', title: '提车应收款', component: Page6 }, + { id: 'u8d22u52a1u7ba1u7406-u79dfu8d41u8d26u5355', title: '租赁账单', component: Page7 }, ]; export default function OneosWebFinance() { @@ -34,7 +28,7 @@ export default function OneosWebFinance() { ); } diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md new file mode 100644 index 0000000..9e41d5e --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md @@ -0,0 +1,206 @@ +# 加氢站管理 · 站点信息 — 预付余额预警 / 已欠费 KPI 交互说明 + +| 项 | 说明 | +|---|---| +| 所属模块 | 加氢站管理 → 站点信息 | +| 关联页面 | `/prototypes/oneos-web-h2-station-site` | +| 目标用户 | 加氢站运营、财务结算 | +| 关联能力 | 余额提醒设置、发起充值单、预付余额下钻 | + +--- + +## 1. 业务背景 + +运维与财务需要**快速发现预付余额不足或已欠费的加氢站**,并批量发起充值,避免氢费结算中断。列表页通过两张 KPI 卡片汇总风险站点,点击后进入专项处理流程,而不是像「全部 / 无加氢」那样仅筛选主列表。 + +**与列表「加氢量」列高频/低频标签的区别**: + +| 维度 | KPI:预付余额预警 / 已欠费 | 列表列:加氢量旁高频/低频 | +|---|---|---| +| 依据 | 预付余额与提醒阈值 | 加氢次数(来自氢费明细) | +| 点击行为 | 打开站点列表弹窗 → 可批量充值 | 无 KPI 级交互;加氢量可下钻明细 | +| 业务目的 | 资金风险预警与充值 | 运营活跃度展示 | + +--- + +## 2. KPI 统计口径 + +两张卡片与主列表**并行展示**,统计基于当前台账内全部站点(不受列表筛选影响,但随站点数据实时重算)。 + +| KPI 卡片 | 计入规则 | 不计入 | +|---|---|---| +| **预付余额预警站点** | 已设置 `balanceAlertThreshold`(> 0),且 `prepaidBalance ≥ 0`,且 `prepaidBalance < balanceAlertThreshold` | 未设阈值;已欠费(余额 < 0) | +| **已欠费站点** | `prepaidBalance < 0` | 余额 ≥ 0 | + +**互斥关系**:同一站点若已欠费,只计入「已欠费」,不再计入「预付余额预警」。 + +**阈值来源**(影响预警站点数量): + +| 入口 | 行为 | +|---|---| +| 工具栏 → **全站余额提醒设置** | 将同一阈值批量写入全部站点 | +| 行内更多 → **余额提醒设置** | 仅更新当前站点阈值 | + +--- + +## 3. 与其他 KPI 的交互差异 + +| KPI 类型 | 点击后行为 | 选中态(绿色描边) | +|---|---|---| +| 全部加氢站 / 无加氢站点 | 筛选主列表,回到第 1 页;与签约筛选互斥 | `categoryTab` 匹配且未选签约筛选 | +| 签约站点 / 普通站点 | 快捷签约筛选,重置 KPI 为「全部」 | `appliedFilters.signed` 匹配 | +| **预付余额预警 / 已欠费** | **打开站点列表弹窗**,不筛选主列表 | 弹窗打开且 `type` 与卡片一致 | + +点击预警/欠费 KPI 时: + +- **不**修改主列表筛选条件 +- **不**切换 `categoryTab` 为列表筛选项 +- 弹窗打开期间,对应 KPI 卡片保持选中高亮 + +--- + +## 4. 点击 KPI → 站点列表弹窗 + +### 4.1 触发 + +用户点击「预付余额预警站点」或「已欠费站点」任一张 KPI 卡片。 + +系统按口径从当前台账筛选站点,打开居中弹窗: + +| 属性 | 说明 | +|---|---| +| 标题 | 「预付余额预警站点」或「已欠费站点」 | +| 宽度 | 约 1200px | +| 数据范围 | 符合该 KPI 口径的全部站点(非仅当前页) | + +### 4.2 弹窗内容 + +**顶部摘要区** + +- 标题与卡片名称一致 +- 副文案:`共 N 个站点 · 字段与列表页一致` +- 主操作按钮:**一键发起充值单**(站点数为 0 时禁用) + +**站点表格** + +- 列与主列表一致(**不含操作列**):加氢站名称、合作时间、营业状态、营业时间、当前成本价格、加氢次数、加氢量、预付余额、联系方式等 +- 预付余额列:负值标红并显示「已欠费」(与主列表一致) +- 超过 8 条时分页,每页 8 条 +- 无数据时展示「暂无站点」 + +**底部按钮** + +| 按钮 | 行为 | +|---|---| +| 关闭 | 关闭弹窗,清除 KPI 选中态 | +| 一键发起充值单 | 见下文第 5 节 | + +--- + +## 5. 一键发起充值单 + +### 5.1 流程 + +```mermaid +flowchart TD + A[点击 KPI 卡片] --> B[打开站点列表弹窗] + B --> C{用户操作} + C -->|关闭| D[结束] + C -->|一键发起充值单| E[关闭弹窗] + E --> F[打开发起充值单弹窗] + F --> G[预填弹窗内全部站点行] + G --> H[用户填写付款金额] + H --> I[发起付款流程] +``` + +### 5.2 预填规则 + +关闭站点列表弹窗后,打开「发起充值单」弹窗,并为弹窗内**每一个站点**自动生成一行: + +| 预填字段 | 来源 | +|---|---| +| 站点 | 站点名称(不可为空) | +| 当前余额 | 站点 `prepaidBalance` 格式化展示 | +| 收款公司 / 银行账号 / 开户行 | 站点关联供应商付款信息 | +| 转账用途 | 默认「羚牛预付氢费款 YYYY-MM-DD」 | +| 付款金额 | **留空**,由用户填写 | + +**提示文案**: + +- 预警站点:`已载入 N 个预警站点,请填写付款金额后发起` +- 欠费站点:`已载入 N 个欠费站点,请填写付款金额后发起` + +### 5.3 提交校验 + +与工具栏「发起充值单」一致: + +- 每行须选择站点并填写付款金额 +- 站点须已关联供应商且收款信息完整;否则提示补全后再发起 +- 原型环境:校验通过后提示「已发起审批流程」 + +### 5.4 与其他充值入口的关系 + +| 入口 | 预填站点 | +|---|---| +| 工具栏 → 发起充值单 | 空白一行,用户自选 | +| KPI 弹窗 → 一键发起充值单 | 预填当前弹窗内全部站点 | +| 行内更多(无批量) | — | + +--- + +## 6. 端到端场景示例 + +### 场景 A:日常巡检余额 + +1. 财务打开站点信息页,看到 KPI「预付余额预警站点 = 1」 +2. 点击该卡片,弹窗列出余额低于阈值但仍 ≥ 0 的站点 +3. 确认后点击「一键发起充值单」,填写各站付款金额并提交 + +### 场景 B:处理欠费 + +1. KPI「已欠费站点」显示 1,列表中对应行预付余额为红色「已欠费 -3,250.00」 +2. 点击 KPI,弹窗仅展示欠费站点 +3. 一键发起充值单,补足欠费金额 + +### 场景 C:配置阈值后统计变化 + +1. 工具栏「全站余额提醒设置」统一设为 50,000 元 +2. KPI 预警数量随 `prepaidBalance` 与阈值比较实时更新 +3. 某站余额扣减至阈值以下后自动出现在预警 KPI 中 + +--- + +## 7. 异常与边界 + +| 场景 | 预期行为 | +|---|---| +| KPI 数值为 0 | 可点击;弹窗内表格为空,「一键发起充值单」禁用 | +| 弹窗内点击关闭 | 弹窗关闭,KPI 选中态取消 | +| 站点未设阈值 | 不计入预警 KPI(即使余额较低) | +| 已欠费站点 | 只出现在「已欠费」,不出现在「预警」 | +| 主列表正在筛选 | 不影响 KPI 统计与弹窗站点范围(口径为全量台账) | + +--- + +## 8. 验收清单 + +- [ ] 预警 KPI 仅统计:有阈值、余额 ≥ 0 且 < 阈值 +- [ ] 欠费 KPI 仅统计:余额 < 0;与预警互斥 +- [ ] 点击预警/欠费 KPI 打开弹窗,**主列表不筛选** +- [ ] 弹窗打开时对应 KPI 卡片呈选中态 +- [ ] 弹窗表格字段与主列表一致(无操作列) +- [ ] 「一键发起充值单」预填全部站点及当前余额、供应商信息 +- [ ] 预填后付款金额须用户填写;提交校验与工具栏充值一致 +- [ ] 全站/单站余额提醒设置保存后,预警 KPI 数量联动更新 + +--- + +## 9. 实现参考(原型) + +| 能力 | 位置 | +|---|---| +| KPI 卡片配置 | `H2_STATION_KPI_CARDS` | +| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` | +| 打开弹窗 | `openAlertStationModal` | +| 批量充值 | `handleBatchRechargeFromAlert` → `openRechargeModal` | +| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md index b57e54a..6509288 100644 --- a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md +++ b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md @@ -16,7 +16,7 @@ 用户进入「加氢站管理 → 站点信息」后,列表页自上而下为: 1. **筛选区** — 名称、签约、地区、营业状态 -2. **KPI 分类** — 全部 / 签约·普通 / 高频 / 低频 / 无加氢 +2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢 3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点 4. **站点列表** — 主数据 + 运营指标 + 操作列 5. **分页栏** — 每页 5/10/20/50 条 @@ -50,11 +50,29 @@ | 全部加氢站 | 列表全部站点 | | 签约站点 | 已签约,点击快捷筛选 | | 普通站点 | 未签约,点击快捷筛选 | -| 高频加氢站 | 该站加氢次数 ≥ 3 | -| 低频加氢站 | 有加氢但次数 < 3 | +| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 | +| 已欠费站点 | 预付余额 < 0;点击打开站点列表弹窗,可一键发起充值单 | | 无加氢站点 | 加氢次数 = 0 | -加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)。 +加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换。 + +> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。 + +### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要) + +这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。 + +| 步骤 | 用户操作 | 系统反馈 | +|---|---|---| +| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 | +| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 | +| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 | +| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) | +| — | 点击「关闭」 | 关闭弹窗,KPI 选中态取消 | + +**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。 + +**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。 --- diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md index fecb251..13f7039 100644 --- a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md +++ b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md @@ -58,6 +58,7 @@ ├── 查看详情 ├── 营业状态 / 价格配置 ├── 加氢量下钻 / 预付余额下钻 + ├── 预付余额预警 / 已欠费 KPI 站点列表 → 一键充值 ├── 生成对账单(两阶段) └── 查看对账记录 ``` @@ -66,10 +67,11 @@ ## 3. 列表与 KPI -> 详见 `.spec/requirements-prd-list.md` +> 列表筛选与字段详见 `.spec/requirements-prd-list.md` +> **预付余额预警 / 已欠费 KPI 点击交互**详见 `.spec/requirements-prd-kpi-balance-alert.md` - 筛选:名称、签约、地区、营业状态 -- KPI:全部 / 签约·普通 / 高频≥3 / 低频1–2 / 无加氢0 +- KPI:全部 / 签约·普通 / **预付余额预警** / **已欠费** / 无加氢 - 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作 - 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体 diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/requirements.md b/src/prototypes/oneos-web-h2-station-site/.spec/requirements.md index 237b247..0eebd0f 100644 --- a/src/prototypes/oneos-web-h2-station-site/.spec/requirements.md +++ b/src/prototypes/oneos-web-h2-station-site/.spec/requirements.md @@ -20,7 +20,9 @@ ## KPI 口径(已确认) -全部 / 签约·普通 / **高频(≥3 次)** / **低频(1–2 次)** / **无加氢(0 次)**,与《站点管理使用说明书》及页面内嵌 PRD 一致。 +全部 / 签约·普通 / **预付余额预警** / **已欠费** / **无加氢(0 次)**,与 legacy《站点信息-产品需求说明》一致。 + +预警/欠费 KPI 点击打开站点列表弹窗,支持一键发起充值单。详见 `.spec/requirements-prd-kpi-balance-alert.md`。 ## 角色演示 diff --git a/src/prototypes/oneos-web-h2-station-site/annotation-source.json b/src/prototypes/oneos-web-h2-station-site/annotation-source.json index 6afe732..362f443 100644 --- a/src/prototypes/oneos-web-h2-station-site/annotation-source.json +++ b/src/prototypes/oneos-web-h2-station-site/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "oneos-web-h2-station-site", "pageId": "site-info", - "updatedAt": 1783328981488, + "updatedAt": 1783397628286, "nodes": [ { "id": "h2-site-filter", @@ -25,8 +25,8 @@ "hasMarkdown": true, "color": "#2563eb", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1783397628286, + "updatedAt": 1783397628286, "controls": [] }, { @@ -42,13 +42,13 @@ "fingerprint": "kpi|site-info", "path": [] }, - "aiPrompt": "六张 KPI 卡片口径与点击筛选。", - "annotationText": "全部 / 签约·普通 / 高频 / 低频 / 无加氢。", + "aiPrompt": "六张 KPI 卡片口径;预警/欠费点击打开弹窗并支持一键充值。", + "annotationText": "全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢。预警与欠费点击打开站点列表弹窗。", "hasMarkdown": true, "color": "#2563eb", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1783397628286, + "updatedAt": 1783397628286, "controls": [] }, { @@ -69,8 +69,8 @@ "hasMarkdown": true, "color": "#d97706", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1783397628286, + "updatedAt": 1783397628286, "controls": [] }, { @@ -91,150 +91,21 @@ "hasMarkdown": true, "color": "#32a06e", "images": [], - "createdAt": 1782912426507, - "updatedAt": 1782912426507, + "createdAt": 1783397628286, + "updatedAt": 1783397628286, "controls": [] } ] }, "markdownMap": { "h2-site-filter": "## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---", - "h2-site-kpi": "## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 高频加氢站 | 该站加氢次数 ≥ 3 |\n| 低频加氢站 | 有加氢但次数 < 3 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)。\n\n---", + "h2-site-kpi": "## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 |\n| 已欠费站点 | 预付余额 < 0;点击打开站点列表弹窗,可一键发起充值单 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换。\n\n> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。\n\n### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要)\n\n这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。\n\n| 步骤 | 用户操作 | 系统反馈 |\n|---|---|---|\n| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 |\n| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 |\n| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 |\n| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) |\n| — | 点击「关闭」 | 关闭弹窗,KPI 选中态取消 |\n\n**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。\n\n**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。\n\n---", "h2-site-statement": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---", "h2-site-table": "## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---" }, "assetMap": {}, "directory": { "nodes": [ - { - "type": "folder", - "id": "oneos-project-nav", - "title": "ONE-OS 原型导航", - "defaultExpanded": true, - "children": [ - { - "type": "folder", - "id": "nav-folder-1782874576229-r8atbu", - "title": "OneOS", - "defaultExpanded": true, - "children": [ - { - "type": "link", - "id": "nav-link-oneos-web-workbench", - "title": "工作台", - "href": "/prototypes/oneos-web-workbench", - "target": "self" - } - ] - }, - { - "type": "link", - "id": "nav-link-lease-business-line-overview", - "title": "业务条线说明", - "href": "/prototypes/lease-business-line-overview", - "target": "self" - }, - { - "type": "folder", - "id": "nav-folder-prototypes-oneos-web", - "title": "ONE-OS Web 端", - "defaultExpanded": true, - "children": [ - { - "type": "link", - "id": "nav-link-oneos-web-login", - "title": "登录", - "href": "/prototypes/oneos-web-login", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-vehicle-asset", - "title": "车辆管理", - "href": "/prototypes/oneos-web-vehicle-asset", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-contract-template", - "title": "合同模板管理", - "href": "/prototypes/oneos-web-contract-template", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-help-center", - "title": "帮助中心", - "href": "/prototypes/oneos-web-help-center", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-finance", - "title": "财务管理", - "href": "/prototypes/oneos-web-finance", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-procurement", - "title": "采购管理", - "href": "/prototypes/oneos-web-procurement", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-lease-contract", - "title": "车辆租赁合同", - "href": "/prototypes/oneos-web-lease-contract", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-h2-station", - "title": "加氢站管理", - "href": "/prototypes/oneos-web-h2-station", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-data-analysis", - "title": "数据分析", - "href": "/prototypes/oneos-web-data-analysis", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-ledger-data", - "title": "台账数据", - "href": "/prototypes/oneos-web-ledger-data", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-business", - "title": "业务管理", - "href": "/prototypes/oneos-web-business", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-ops", - "title": "运维管理", - "href": "/prototypes/oneos-web-ops", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-requirements", - "title": "需求说明", - "href": "/prototypes/oneos-web-requirements", - "target": "self" - } - ] - } - ] - }, { "type": "folder", "id": "h2-site-doc-root", @@ -245,20 +116,20 @@ "type": "markdown", "id": "h2-site-doc-overview", "title": "模块总览", - "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 加氢站管理 → 站点信息 |\n| 用户 | 运营、财务结算、管理员 |\n| 核心任务 | 站点主数据、运营配置、氢费对账结算 |\n| 页面结构 | 筛选 + KPI + 工具栏 + 列表 + 分页 |\n\n## 2. 关联模块\n\n- **车辆氢费明细**:氢费数据来源与对账回写目标\n- **加氢站合包**:加氢订单、加氢记录仍在合包入口\n\n## 3. 验收重点\n\n1. KPI 高频/低频/无加氢与氢费明细一致\n2. 对账单两阶段流程与跨页回写\n3. 列表布局与租赁合同 vm 规范一致" + "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 加氢站管理 → 站点信息 |\n| 用户 | 运营、财务结算、管理员 |\n| 核心任务 | 站点主数据、运营配置、氢费对账结算 |\n| 页面结构 | 筛选 + KPI + 工具栏 + 列表 + 分页 |\n\n## 2. 关联模块\n\n- **车辆氢费明细**:氢费数据来源与对账回写目标\n- **加氢站合包**:加氢订单、加氢记录仍在合包入口\n\n## 3. 验收重点\n\n1. KPI 预付余额预警/已欠费口径与点击弹窗、批量充值一致\n2. 对账单两阶段流程与跨页回写\n3. 列表布局与租赁合同 vm 规范一致" }, { "type": "link", "id": "h2-site-link-vh2", "title": "车辆氢费明细", - "href": "../vehicle-h2-fee-ledger/index.html", + "href": "/prototypes/vehicle-h2-fee-ledger", "target": "self" }, { "type": "link", "id": "h2-site-link-bundle", "title": "加氢站管理(合包)", - "href": "../oneos-web-h2-station/index.html", + "href": "/prototypes/oneos-web-h2-station", "target": "self" }, { @@ -271,16 +142,23 @@ "type": "markdown", "id": "h2-site-doc-prd-full", "title": "PRD 完整文档", - "markdown": "# 加氢站管理 · 站点信息 — 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.0 |\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢站合包](/prototypes/oneos-web-h2-station) |\n| 文档状态 | 已对齐原型 |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 业务背景\n\n加氢站是氢费成本结算的关键节点。运营侧需要维护站点主数据(签约、营业、成本价、预付余额),并在财务周期内按站点与加氢站完成对账、收票与付款闭环。\n\n### 1.2 本期目标\n\n- 站点台账的查询、新建、编辑、查看、删除与批量导入\n- 营业状态、成本价格、预付余额等运营配置\n- **按站点生成氢费对账单 → 填写收票信息 → 提交结算**,并与「车辆氢费明细」联动回写\n\n### 1.3 用户角色\n\n| 角色 | 典型诉求 |\n|---|---|\n| 加氢站运营 | 维护站点资料、营业与价格;查看加氢量与余额 |\n| 财务/结算 | 按周期生成对账单、登记收票、完成站点付款闭环 |\n| 管理员 | 批量导入站点、删除无效站点、配置系统账号 |\n\n### 1.4 非目标(本期不做)\n\n- 对账单审批流、ERP 自动过账\n- 发票 OCR 识别与验真\n- 加氢订单、加氢记录(仍在合包 `oneos-web-h2-station`)\n- 后端接口联调(原型使用 Mock + 共享 Store)\n\n---\n\n## 2. 信息架构\n\n```text\n站点信息\n├── 列表页\n│ ├── 筛选区(4 项)\n│ ├── KPI 卡片(6 张)\n│ ├── 工具栏\n│ └── 站点台账表 + 分页\n├── 新建 / 编辑(同页内嵌视图)\n│ ├── 基本信息\n│ ├── 系统账号绑定\n│ └── 供应商 / 付款信息\n└── 弹窗 / 抽屉\n ├── 查看详情\n ├── 营业状态 / 价格配置\n ├── 加氢量下钻 / 预付余额下钻\n ├── 生成对账单(两阶段)\n └── 查看对账记录\n```\n\n---\n\n## 3. 列表与 KPI\n\n> 详见 `.spec/requirements-prd-list.md`\n\n- 筛选:名称、签约、地区、营业状态\n- KPI:全部 / 签约·普通 / 高频≥3 / 低频1–2 / 无加氢0\n- 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作\n- 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体\n\n---\n\n## 4. 站点主数据\n\n### 4.1 新建站点\n\n同页内嵌视图:基本信息、系统账号绑定(可选)、供应商信息(新建或关联)。\n\n### 4.2 编辑 / 查看\n\n| 模式 | 交互 |\n|---|---|\n| 编辑 | 整页表单;系统账号仅 admin 可改 |\n| 查看 | 弹窗分块展示站点、账号、供应商、付款信息 |\n\n### 4.3 批量导入\n\n下载 CSV 模板 → 上传 → 解析预览 → 确认导入;站点名称不可重复。\n\n---\n\n## 5. 运营配置\n\n### 5.1 营业状态\n\n卡片式弹窗:营业状态、营业时间按钮组;非全天须配置时段;展示状态变更记录。\n\n### 5.2 价格配置\n\n配置成本价格(元/kg)与生效时间;到达生效时间后刷新列表「当前成本价格」;展示价格调整记录。\n\n### 5.3 下钻分析\n\n- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细\n- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出\n\n---\n\n## 6. 对账与结算(重点)\n\n> 详见 `.spec/requirements-prd-statement.md`\n\n与「车辆氢费明细」配合完成站点侧氢费结算闭环:\n\n1. 拉取已对账且未结算的记录\n2. 两阶段:选期生成 → 填收票提交\n3. 提交后更新预付余额、写对账历史、回写台账(对账日期、收票日期、已付款)\n\n---\n\n## 7. 数据与集成\n\n| 能力 | 实现 |\n|---|---|\n| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |\n| 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` |\n| 页面实现 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |\n| 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` |\n\n---\n\n## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---\n\n## 9. 修订记录\n\n| 版本 | 日期 | 说明 |\n|---|---|---|\n| v1.0 | 2026-07-01 | 独立入口、vm 规范对齐、标注目录与 PRD 接入 |\n", + "markdown": "# 加氢站管理 · 站点信息 — 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.0 |\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢站合包](/prototypes/oneos-web-h2-station) |\n| 文档状态 | 已对齐原型 |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 业务背景\n\n加氢站是氢费成本结算的关键节点。运营侧需要维护站点主数据(签约、营业、成本价、预付余额),并在财务周期内按站点与加氢站完成对账、收票与付款闭环。\n\n### 1.2 本期目标\n\n- 站点台账的查询、新建、编辑、查看、删除与批量导入\n- 营业状态、成本价格、预付余额等运营配置\n- **按站点生成氢费对账单 → 填写收票信息 → 提交结算**,并与「车辆氢费明细」联动回写\n\n### 1.3 用户角色\n\n| 角色 | 典型诉求 |\n|---|---|\n| 加氢站运营 | 维护站点资料、营业与价格;查看加氢量与余额 |\n| 财务/结算 | 按周期生成对账单、登记收票、完成站点付款闭环 |\n| 管理员 | 批量导入站点、删除无效站点、配置系统账号 |\n\n### 1.4 非目标(本期不做)\n\n- 对账单审批流、ERP 自动过账\n- 发票 OCR 识别与验真\n- 加氢订单、加氢记录(仍在合包 `oneos-web-h2-station`)\n- 后端接口联调(原型使用 Mock + 共享 Store)\n\n---\n\n## 2. 信息架构\n\n```text\n站点信息\n├── 列表页\n│ ├── 筛选区(4 项)\n│ ├── KPI 卡片(6 张)\n│ ├── 工具栏\n│ └── 站点台账表 + 分页\n├── 新建 / 编辑(同页内嵌视图)\n│ ├── 基本信息\n│ ├── 系统账号绑定\n│ └── 供应商 / 付款信息\n└── 弹窗 / 抽屉\n ├── 查看详情\n ├── 营业状态 / 价格配置\n ├── 加氢量下钻 / 预付余额下钻\n ├── 预付余额预警 / 已欠费 KPI 站点列表 → 一键充值\n ├── 生成对账单(两阶段)\n └── 查看对账记录\n```\n\n---\n\n## 3. 列表与 KPI\n\n> 列表筛选与字段详见 `.spec/requirements-prd-list.md` \n> **预付余额预警 / 已欠费 KPI 点击交互**详见 `.spec/requirements-prd-kpi-balance-alert.md`\n\n- 筛选:名称、签约、地区、营业状态\n- KPI:全部 / 签约·普通 / **预付余额预警** / **已欠费** / 无加氢\n- 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作\n- 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体\n\n---\n\n## 4. 站点主数据\n\n### 4.1 新建站点\n\n同页内嵌视图:基本信息、系统账号绑定(可选)、供应商信息(新建或关联)。\n\n### 4.2 编辑 / 查看\n\n| 模式 | 交互 |\n|---|---|\n| 编辑 | 整页表单;系统账号仅 admin 可改 |\n| 查看 | 弹窗分块展示站点、账号、供应商、付款信息 |\n\n### 4.3 批量导入\n\n下载 CSV 模板 → 上传 → 解析预览 → 确认导入;站点名称不可重复。\n\n---\n\n## 5. 运营配置\n\n### 5.1 营业状态\n\n卡片式弹窗:营业状态、营业时间按钮组;非全天须配置时段;展示状态变更记录。\n\n### 5.2 价格配置\n\n配置成本价格(元/kg)与生效时间;到达生效时间后刷新列表「当前成本价格」;展示价格调整记录。\n\n### 5.3 下钻分析\n\n- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细\n- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出\n\n---\n\n## 6. 对账与结算(重点)\n\n> 详见 `.spec/requirements-prd-statement.md`\n\n与「车辆氢费明细」配合完成站点侧氢费结算闭环:\n\n1. 拉取已对账且未结算的记录\n2. 两阶段:选期生成 → 填收票提交\n3. 提交后更新预付余额、写对账历史、回写台账(对账日期、收票日期、已付款)\n\n---\n\n## 7. 数据与集成\n\n| 能力 | 实现 |\n|---|---|\n| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |\n| 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` |\n| 页面实现 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |\n| 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` |\n\n---\n\n## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---\n\n## 9. 修订记录\n\n| 版本 | 日期 | 说明 |\n|---|---|---|\n| v1.0 | 2026-07-01 | 独立入口、vm 规范对齐、标注目录与 PRD 接入 |\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md" }, { "type": "markdown", "id": "h2-site-doc-prd-list", "title": "列表页 PRD", - "markdown": "# 加氢站管理 · 站点信息 — 列表页 PRD\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标用户 | 加氢站运营、财务结算、平台管理员 |\n| 核心任务 | 查询站点台账、维护主数据、查看运营指标、发起对账与充值 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 设计规范 | 与租赁合同管理一致的 vm 布局(筛选 / KPI / 表格 / 分页) |\n\n---\n\n## 1. 页面结构\n\n用户进入「加氢站管理 → 站点信息」后,列表页自上而下为:\n\n1. **筛选区** — 名称、签约、地区、营业状态\n2. **KPI 分类** — 全部 / 签约·普通 / 高频 / 低频 / 无加氢\n3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点\n4. **站点列表** — 主数据 + 运营指标 + 操作列\n5. **分页栏** — 每页 5/10/20/50 条\n\n---\n\n## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---\n\n## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 高频加氢站 | 该站加氢次数 ≥ 3 |\n| 低频加氢站 | 有加氢但次数 < 3 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---\n\n## 7. 双入口说明\n\n| 入口 | 路径 |\n|---|---|\n| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |\n| 合包 | ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录 |\n", + "markdown": "# 加氢站管理 · 站点信息 — 列表页 PRD\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标用户 | 加氢站运营、财务结算、平台管理员 |\n| 核心任务 | 查询站点台账、维护主数据、查看运营指标、发起对账与充值 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 设计规范 | 与租赁合同管理一致的 vm 布局(筛选 / KPI / 表格 / 分页) |\n\n---\n\n## 1. 页面结构\n\n用户进入「加氢站管理 → 站点信息」后,列表页自上而下为:\n\n1. **筛选区** — 名称、签约、地区、营业状态\n2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢\n3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点\n4. **站点列表** — 主数据 + 运营指标 + 操作列\n5. **分页栏** — 每页 5/10/20/50 条\n\n---\n\n## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---\n\n## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 |\n| 已欠费站点 | 预付余额 < 0;点击打开站点列表弹窗,可一键发起充值单 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换。\n\n> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。\n\n### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要)\n\n这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。\n\n| 步骤 | 用户操作 | 系统反馈 |\n|---|---|---|\n| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 |\n| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 |\n| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 |\n| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) |\n| — | 点击「关闭」 | 关闭弹窗,KPI 选中态取消 |\n\n**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。\n\n**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---\n\n## 7. 双入口说明\n\n| 入口 | 路径 |\n|---|---|\n| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |\n| 合包 | ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录 |\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md" }, + { + "type": "markdown", + "id": "h2-site-doc-prd-kpi-balance", + "title": "预付余额 KPI 交互", + "markdown": "# 加氢站管理 · 站点信息 — 预付余额预警 / 已欠费 KPI 交互说明\n\n| 项 | 说明 |\n|---|---|\n| 所属模块 | 加氢站管理 → 站点信息 |\n| 关联页面 | `/prototypes/oneos-web-h2-station-site` |\n| 目标用户 | 加氢站运营、财务结算 |\n| 关联能力 | 余额提醒设置、发起充值单、预付余额下钻 |\n\n---\n\n## 1. 业务背景\n\n运维与财务需要**快速发现预付余额不足或已欠费的加氢站**,并批量发起充值,避免氢费结算中断。列表页通过两张 KPI 卡片汇总风险站点,点击后进入专项处理流程,而不是像「全部 / 无加氢」那样仅筛选主列表。\n\n**与列表「加氢量」列高频/低频标签的区别**:\n\n| 维度 | KPI:预付余额预警 / 已欠费 | 列表列:加氢量旁高频/低频 |\n|---|---|---|\n| 依据 | 预付余额与提醒阈值 | 加氢次数(来自氢费明细) |\n| 点击行为 | 打开站点列表弹窗 → 可批量充值 | 无 KPI 级交互;加氢量可下钻明细 |\n| 业务目的 | 资金风险预警与充值 | 运营活跃度展示 |\n\n---\n\n## 2. KPI 统计口径\n\n两张卡片与主列表**并行展示**,统计基于当前台账内全部站点(不受列表筛选影响,但随站点数据实时重算)。\n\n| KPI 卡片 | 计入规则 | 不计入 |\n|---|---|---|\n| **预付余额预警站点** | 已设置 `balanceAlertThreshold`(> 0),且 `prepaidBalance ≥ 0`,且 `prepaidBalance < balanceAlertThreshold` | 未设阈值;已欠费(余额 < 0) |\n| **已欠费站点** | `prepaidBalance < 0` | 余额 ≥ 0 |\n\n**互斥关系**:同一站点若已欠费,只计入「已欠费」,不再计入「预付余额预警」。\n\n**阈值来源**(影响预警站点数量):\n\n| 入口 | 行为 |\n|---|---|\n| 工具栏 → **全站余额提醒设置** | 将同一阈值批量写入全部站点 |\n| 行内更多 → **余额提醒设置** | 仅更新当前站点阈值 |\n\n---\n\n## 3. 与其他 KPI 的交互差异\n\n| KPI 类型 | 点击后行为 | 选中态(绿色描边) |\n|---|---|---|\n| 全部加氢站 / 无加氢站点 | 筛选主列表,回到第 1 页;与签约筛选互斥 | `categoryTab` 匹配且未选签约筛选 |\n| 签约站点 / 普通站点 | 快捷签约筛选,重置 KPI 为「全部」 | `appliedFilters.signed` 匹配 |\n| **预付余额预警 / 已欠费** | **打开站点列表弹窗**,不筛选主列表 | 弹窗打开且 `type` 与卡片一致 |\n\n点击预警/欠费 KPI 时:\n\n- **不**修改主列表筛选条件\n- **不**切换 `categoryTab` 为列表筛选项\n- 弹窗打开期间,对应 KPI 卡片保持选中高亮\n\n---\n\n## 4. 点击 KPI → 站点列表弹窗\n\n### 4.1 触发\n\n用户点击「预付余额预警站点」或「已欠费站点」任一张 KPI 卡片。\n\n系统按口径从当前台账筛选站点,打开居中弹窗:\n\n| 属性 | 说明 |\n|---|---|\n| 标题 | 「预付余额预警站点」或「已欠费站点」 |\n| 宽度 | 约 1200px |\n| 数据范围 | 符合该 KPI 口径的全部站点(非仅当前页) |\n\n### 4.2 弹窗内容\n\n**顶部摘要区**\n\n- 标题与卡片名称一致\n- 副文案:`共 N 个站点 · 字段与列表页一致`\n- 主操作按钮:**一键发起充值单**(站点数为 0 时禁用)\n\n**站点表格**\n\n- 列与主列表一致(**不含操作列**):加氢站名称、合作时间、营业状态、营业时间、当前成本价格、加氢次数、加氢量、预付余额、联系方式等\n- 预付余额列:负值标红并显示「已欠费」(与主列表一致)\n- 超过 8 条时分页,每页 8 条\n- 无数据时展示「暂无站点」\n\n**底部按钮**\n\n| 按钮 | 行为 |\n|---|---|\n| 关闭 | 关闭弹窗,清除 KPI 选中态 |\n| 一键发起充值单 | 见下文第 5 节 |\n\n---\n\n## 5. 一键发起充值单\n\n### 5.1 流程\n\n```mermaid\nflowchart TD\n A[点击 KPI 卡片] --> B[打开站点列表弹窗]\n B --> C{用户操作}\n C -->|关闭| D[结束]\n C -->|一键发起充值单| E[关闭弹窗]\n E --> F[打开发起充值单弹窗]\n F --> G[预填弹窗内全部站点行]\n G --> H[用户填写付款金额]\n H --> I[发起付款流程]\n```\n\n### 5.2 预填规则\n\n关闭站点列表弹窗后,打开「发起充值单」弹窗,并为弹窗内**每一个站点**自动生成一行:\n\n| 预填字段 | 来源 |\n|---|---|\n| 站点 | 站点名称(不可为空) |\n| 当前余额 | 站点 `prepaidBalance` 格式化展示 |\n| 收款公司 / 银行账号 / 开户行 | 站点关联供应商付款信息 |\n| 转账用途 | 默认「羚牛预付氢费款 YYYY-MM-DD」 |\n| 付款金额 | **留空**,由用户填写 |\n\n**提示文案**:\n\n- 预警站点:`已载入 N 个预警站点,请填写付款金额后发起`\n- 欠费站点:`已载入 N 个欠费站点,请填写付款金额后发起`\n\n### 5.3 提交校验\n\n与工具栏「发起充值单」一致:\n\n- 每行须选择站点并填写付款金额\n- 站点须已关联供应商且收款信息完整;否则提示补全后再发起\n- 原型环境:校验通过后提示「已发起审批流程」\n\n### 5.4 与其他充值入口的关系\n\n| 入口 | 预填站点 |\n|---|---|\n| 工具栏 → 发起充值单 | 空白一行,用户自选 |\n| KPI 弹窗 → 一键发起充值单 | 预填当前弹窗内全部站点 |\n| 行内更多(无批量) | — |\n\n---\n\n## 6. 端到端场景示例\n\n### 场景 A:日常巡检余额\n\n1. 财务打开站点信息页,看到 KPI「预付余额预警站点 = 1」\n2. 点击该卡片,弹窗列出余额低于阈值但仍 ≥ 0 的站点\n3. 确认后点击「一键发起充值单」,填写各站付款金额并提交\n\n### 场景 B:处理欠费\n\n1. KPI「已欠费站点」显示 1,列表中对应行预付余额为红色「已欠费 -3,250.00」\n2. 点击 KPI,弹窗仅展示欠费站点\n3. 一键发起充值单,补足欠费金额\n\n### 场景 C:配置阈值后统计变化\n\n1. 工具栏「全站余额提醒设置」统一设为 50,000 元\n2. KPI 预警数量随 `prepaidBalance` 与阈值比较实时更新\n3. 某站余额扣减至阈值以下后自动出现在预警 KPI 中\n\n---\n\n## 7. 异常与边界\n\n| 场景 | 预期行为 |\n|---|---|\n| KPI 数值为 0 | 可点击;弹窗内表格为空,「一键发起充值单」禁用 |\n| 弹窗内点击关闭 | 弹窗关闭,KPI 选中态取消 |\n| 站点未设阈值 | 不计入预警 KPI(即使余额较低) |\n| 已欠费站点 | 只出现在「已欠费」,不出现在「预警」 |\n| 主列表正在筛选 | 不影响 KPI 统计与弹窗站点范围(口径为全量台账) |\n\n---\n\n## 8. 验收清单\n\n- [ ] 预警 KPI 仅统计:有阈值、余额 ≥ 0 且 < 阈值\n- [ ] 欠费 KPI 仅统计:余额 < 0;与预警互斥\n- [ ] 点击预警/欠费 KPI 打开弹窗,**主列表不筛选**\n- [ ] 弹窗打开时对应 KPI 卡片呈选中态\n- [ ] 弹窗表格字段与主列表一致(无操作列)\n- [ ] 「一键发起充值单」预填全部站点及当前余额、供应商信息\n- [ ] 预填后付款金额须用户填写;提交校验与工具栏充值一致\n- [ ] 全站/单站余额提醒设置保存后,预警 KPI 数量联动更新\n\n---\n\n## 9. 实现参考(原型)\n\n| 能力 | 位置 |\n|---|---|\n| KPI 卡片配置 | `H2_STATION_KPI_CARDS` |\n| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` |\n| 打开弹窗 | `openAlertStationModal` |\n| 批量充值 | `handleBatchRechargeFromAlert` → `openRechargeModal` |\n| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |\n", + "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md" + }, { "type": "markdown", "id": "h2-site-doc-prd-statement", @@ -292,7 +170,7 @@ "type": "markdown", "id": "h2-site-doc-scope", "title": "范围与决策", - "markdown": "# 站点信息独立原型 — 范围与决策\n\n## 背景\n\n「站点信息」从 ONE-OS Web 合包 `oneos-web-h2-station` 拆出,作为 OneOS → 加氢站管理下的独立 Axhub 入口,便于与正式菜单路径对齐。\n\n## 范围\n\n| 包含 | 不包含 |\n|------|--------|\n| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单、加氢记录(仍在合包) |\n| 营业状态、价格配置、余额下钻 | 后端接口联调 |\n| 两阶段对账单、充值单、批量导入 | |\n\n## 数据来源\n\n- **氢费/加氢明细**:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」原型共享 Store。\n- 先打开「车辆氢费明细」时,台账全量行会同步至 Store;仅打开加氢站模块时使用 canonical seed(10 条演示数据)。\n- 对账单提交 patch 经 `window.H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细。\n\n## KPI 口径(已确认)\n\n全部 / 签约·普通 / **高频(≥3 次)** / **低频(1–2 次)** / **无加氢(0 次)**,与《站点管理使用说明书》及页面内嵌 PRD 一致。\n\n## 角色演示\n\n页面右上角提供「平台管理员 / 加氢站账号」切换,影响编辑页系统账号字段可编辑性。\n\n## 双入口说明\n\n- **本原型**:OneOS → 加氢站管理 → 站点信息\n- **合包**:ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录\n", + "markdown": "# 站点信息独立原型 — 范围与决策\n\n## 背景\n\n「站点信息」从 ONE-OS Web 合包 `oneos-web-h2-station` 拆出,作为 OneOS → 加氢站管理下的独立 Axhub 入口,便于与正式菜单路径对齐。\n\n## 范围\n\n| 包含 | 不包含 |\n|------|--------|\n| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单、加氢记录(仍在合包) |\n| 营业状态、价格配置、余额下钻 | 后端接口联调 |\n| 两阶段对账单、充值单、批量导入 | |\n\n## 数据来源\n\n- **氢费/加氢明细**:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」原型共享 Store。\n- 先打开「车辆氢费明细」时,台账全量行会同步至 Store;仅打开加氢站模块时使用 canonical seed(10 条演示数据)。\n- 对账单提交 patch 经 `window.H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细。\n\n## KPI 口径(已确认)\n\n全部 / 签约·普通 / **预付余额预警** / **已欠费** / **无加氢(0 次)**,与 legacy《站点信息-产品需求说明》一致。\n\n预警/欠费 KPI 点击打开站点列表弹窗,支持一键发起充值单。详见 `.spec/requirements-prd-kpi-balance-alert.md`。\n\n## 角色演示\n\n页面右上角提供「平台管理员 / 加氢站账号」切换,影响编辑页系统账号字段可编辑性。\n\n## 双入口说明\n\n- **本原型**:OneOS → 加氢站管理 → 站点信息\n- **合包**:ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements.md" }, { @@ -319,7 +197,7 @@ "type": "markdown", "id": "h2-site-doc-node-h2-site-kpi", "title": "KPI 快捷筛选", - "markdown": "## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 高频加氢站 | 该站加氢次数 ≥ 3 |\n| 低频加氢站 | 有加氢但次数 < 3 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。KPI 与筛选区签约条件互斥切换(点 KPI 分类会清除签约筛选,点签约卡会重置 KPI 为全部)。\n\n---" + "markdown": "## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 |\n| 已欠费站点 | 预付余额 < 0;点击打开站点列表弹窗,可一键发起充值单 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换。\n\n> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。\n\n### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要)\n\n这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。\n\n| 步骤 | 用户操作 | 系统反馈 |\n|---|---|---|\n| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 |\n| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 |\n| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 |\n| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) |\n| — | 点击「关闭」 | 关闭弹窗,KPI 选中态取消 |\n\n**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。\n\n**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。\n\n---" }, { "type": "markdown", diff --git a/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs b/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs index 25c44bf..35a7517 100644 --- a/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs +++ b/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs @@ -9,6 +9,7 @@ const specRoot = path.join(protoRoot, '.spec'); const fullPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd.md'), 'utf8'); const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8'); const statementPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-statement.md'), 'utf8'); +const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8'); const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8'); const NODE_DEFS = [ @@ -29,8 +30,8 @@ const NODE_DEFS = [ title: 'KPI 快捷筛选', pageId: 'site-info', color: '#2563eb', - aiPrompt: '六张 KPI 卡片口径与点击筛选。', - annotationText: '全部 / 签约·普通 / 高频 / 低频 / 无加氢。', + aiPrompt: '六张 KPI 卡片口径;预警/欠费点击打开弹窗并支持一键充值。', + annotationText: '全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢。预警与欠费点击打开站点列表弹窗。', locator: { selectors: ['.h2-station-page .vm-kpi-row', '[data-annotation-id="h2-site-kpi"]'], fingerprint: 'kpi|site-info', @@ -155,7 +156,7 @@ const overviewMd = [ '', '## 3. 验收重点', '', - '1. KPI 高频/低频/无加氢与氢费明细一致', + '1. KPI 预付余额预警/已欠费口径与点击弹窗、批量充值一致', '2. 对账单两阶段流程与跨页回写', '3. 列表布局与租赁合同 vm 规范一致', ].join('\n'); @@ -222,6 +223,13 @@ const source = { markdown: listPrd, markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md', }, + { + type: 'markdown', + id: 'h2-site-doc-prd-kpi-balance', + title: '预付余额 KPI 交互', + markdown: kpiBalanceAlertPrd, + markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md', + }, { type: 'markdown', id: 'h2-site-doc-prd-statement', diff --git a/src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx b/src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx index 1a9356d..a4c3194 100644 --- a/src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx +++ b/src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx @@ -1,6 +1,8 @@ // 【重要】必须使用 const Component 作为组件变量名 // 加氢站管理 - 站点信息(列表 + KPI 分类 + 独立新建/编辑页 + 使用说明书) +import { TablePagination, COMPACT_PAGE_SIZE_OPTIONS } from '../../../common/TablePagination'; + var H2_REGION_CASCADER_OPTIONS = [ { value: '浙江省', label: '浙江省', children: [{ value: '嘉兴市', label: '嘉兴市' }, { value: '杭州市', label: '杭州市' }, { value: '宁波市', label: '宁波市' }] }, { value: '上海市', label: '上海市', children: [{ value: '上海市', label: '上海市' }] }, @@ -10,8 +12,8 @@ var H2_REGION_CASCADER_OPTIONS = [ var H2_STATION_KPI_CARDS = [ { key: 'all', type: 'total', title: '全部加氢站', desc: '纳入台账管理的全部站点' }, - { key: 'high', type: 'normal', title: '高频加氢站', desc: '近周期内加氢次数 ≥ 3 次的站点' }, - { key: 'low', type: 'warning', title: '低频加氢站', desc: '近周期内有加氢但次数 < 3 次的站点' }, + { key: 'balanceAlert', type: 'warning', title: '预付余额预警站点', desc: '预付余额低于所设提醒阈值的站点' }, + { key: 'arrears', type: 'unuploaded', title: '已欠费站点', desc: '预付余额小于 0 的站点' }, { key: 'none', type: 'unuploaded', title: '无加氢站点', desc: '暂无加氢记录的站点' } ]; @@ -2108,20 +2110,24 @@ function h2KpiCardIcon(key) { { tag: 'line', x1: 19, y1: 16, x2: 16, y2: 19 } ], size); } - if (key === 'high') { + if (key === 'balanceAlert') { return h2SvgIcon([ - { d: 'M3 22h18' }, - { d: 'M6 22V12l4-2v12' }, - { d: 'M12 22V8l4-2v16' }, - { d: 'M18 22V14l3-1.5v9.5' } + { d: 'M12 2v4' }, + { d: 'M12 18v4' }, + { d: 'M4.93 4.93l2.83 2.83' }, + { d: 'M16.24 16.24l2.83 2.83' }, + { d: 'M2 12h4' }, + { d: 'M18 12h4' }, + { d: 'M4.93 19.07l2.83-2.83' }, + { d: 'M16.24 7.76l2.83-2.83' }, + { tag: 'circle', cx: 12, cy: 12, r: 4 } ], size); } - if (key === 'low') { + if (key === 'arrears') { return h2SvgIcon([ - { d: 'M3 22h18' }, - { d: 'M8 22V16l3-1.5V22' }, - { d: 'M14 22V18l3-1.5V22' }, - { tag: 'line', x1: 6, y1: 22, x2: 20, y2: 22 } + { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }, + { tag: 'line', x1: 12, y1: 9, x2: 12, y2: 13 }, + { tag: 'line', x1: 12, y1: 17, x2: 12.01, y2: 17 } ], size); } if (key === 'none') { @@ -5866,13 +5872,12 @@ const Component = function () { }, [listData]); var categoryCounts = useMemo(function () { - var counts = { all: listData.length, high: 0, low: 0, none: 0 }; + var counts = { all: listData.length, balanceAlert: 0, arrears: 0, none: 0 }; listData.forEach(function (r) { + if (h2IsArrearsStation(r)) counts.arrears += 1; + else if (h2IsBalanceAlertStation(r)) counts.balanceAlert += 1; var stats = h2CalcRefuelStats(r.name); - var freq = h2DeriveFrequencyByRefuelCount(stats.count); - if (freq === 'high') counts.high += 1; - else if (freq === 'low') counts.low += 1; - else counts.none += 1; + if (h2DeriveFrequencyByRefuelCount(stats.count) === 'none') counts.none += 1; }); return counts; }, [listData, ledgerDataRevision]); @@ -5887,11 +5892,12 @@ const Component = function () { var list = listData.slice(); if (categoryTab !== 'all') { list = list.filter(function (r) { - var stats = h2CalcRefuelStats(r.name); - var freq = h2DeriveFrequencyByRefuelCount(stats.count); - if (categoryTab === 'high') return freq === 'high'; - if (categoryTab === 'low') return freq === 'low'; - if (categoryTab === 'none') return freq === 'none'; + if (categoryTab === 'balanceAlert') return h2IsBalanceAlertStation(r); + if (categoryTab === 'arrears') return h2IsArrearsStation(r); + if (categoryTab === 'none') { + var stats = h2CalcRefuelStats(r.name); + return h2DeriveFrequencyByRefuelCount(stats.count) === 'none'; + } return true; }); } @@ -5937,86 +5943,23 @@ const Component = function () { return sortedList.slice(start, start + pageSize); }, [sortedList, safePage, pageSize]); - function h2BuildPageItems(current, pages) { - if (pages <= 7) { - var all = []; - var pi; - for (pi = 1; pi <= pages; pi++) all.push(pi); - return all; - } - var items = [1]; - if (current > 3) items.push('ellipsis'); - var startP = Math.max(2, current - 1); - var endP = Math.min(pages - 1, current + 1); - for (var pj = startP; pj <= endP; pj++) items.push(pj); - if (current < pages - 2) items.push('ellipsis'); - if (pages > 1) items.push(pages); - return items; - } - var renderH2TablePagination = useCallback(function () { - if (totalCount === 0) return null; - var pageItems = h2BuildPageItems(safePage, totalPages); - var pageSizeOptions = [5, 10, 20, 50]; - return React.createElement('div', { className: 'vm-pagination', role: 'navigation', 'aria-label': '表格分页' }, - React.createElement('label', { className: 'vm-pagination-size' }, - React.createElement('span', { className: 'vm-pagination-size-label' }, '每页'), - React.createElement('select', { - className: 'vm-pagination-select', - value: pageSize, - 'aria-label': '每页显示条数', - onChange: function (e) { - setPageSize(Number(e.target.value)); - setPage(1); - } - }, pageSizeOptions.map(function (size) { - return React.createElement('option', { key: size, value: size }, size); - })), - React.createElement('span', { className: 'vm-pagination-size-label' }, '条') - ), - React.createElement('div', { className: 'vm-pagination-pages' }, - React.createElement('button', { - type: 'button', - className: 'vm-pagination-nav', - disabled: safePage <= 1, - 'aria-label': '上一页', - onClick: function () { setPage(safePage - 1); } - }, - React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, - React.createElement('path', { d: 'm15 18-6-6 6-6' }) - ) - ), - pageItems.map(function (item, index) { - if (item === 'ellipsis') { - return React.createElement('span', { key: 'ellipsis-' + index, className: 'vm-pagination-ellipsis', 'aria-hidden': true }, '…'); - } - return React.createElement('button', { - key: item, - type: 'button', - className: 'vm-pagination-page' + (safePage === item ? ' active' : ''), - 'aria-current': safePage === item ? 'page' : undefined, - onClick: function () { setPage(item); } - }, item); - }), - React.createElement('button', { - type: 'button', - className: 'vm-pagination-nav', - disabled: safePage >= totalPages, - 'aria-label': '下一页', - onClick: function () { setPage(safePage + 1); } - }, - React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, - React.createElement('path', { d: 'm9 18 6-6-6-6' }) - ) - ) - ), - React.createElement('div', { className: 'vm-table-total' }, '共 ' + totalCount + ' 条') - ); - }, [totalCount, safePage, totalPages, pageSize]); + return React.createElement(TablePagination, { + page: safePage, + pageSize: pageSize, + total: totalCount, + pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS, + onPageChange: setPage, + onPageSizeChange: function (size) { + setPageSize(size); + setPage(1); + } + }); + }, [totalCount, safePage, pageSize]); var handleKpiCardClick = useCallback(function (key) { setCategoryTab(key); - if (key === 'all' || key === 'high' || key === 'low' || key === 'none') { + if (key === 'all' || key === 'none') { setListFilters(function (p) { return Object.assign({}, p, { signed: undefined }); }); setAppliedFilters(function (p) { return Object.assign({}, p, { signed: undefined }); }); } @@ -9548,10 +9491,16 @@ const Component = function () { }); }), H2_STATION_KPI_CARDS.slice(1).map(function (card) { + var isAlertKpi = card.key === 'balanceAlert' || card.key === 'arrears'; return renderVmKpiCard(card, { - active: categoryTab === card.key && !appliedFilters.signed, + active: isAlertKpi + ? (alertStationModal.open && alertStationModal.type === card.key) + : (categoryTab === card.key && !appliedFilters.signed), count: categoryCounts[card.key] || 0, - onClick: function () { handleKpiCardClick(card.key); }, + onClick: function () { + if (isAlertKpi) openAlertStationModal(card.key); + else handleKpiCardClick(card.key); + }, icon: h2KpiIcon(card.key) }); }) @@ -9989,7 +9938,11 @@ const Component = function () { open: alertStationModal.open, onCancel: closeAlertStationModal, footer: React.createElement(Space, null, - React.createElement(Button, { onClick: closeAlertStationModal, style: { borderRadius: 8 } }, '关闭'), + React.createElement('button', { + type: 'button', + className: 'vm-btn vm-btn-ghost', + onClick: closeAlertStationModal + }, '关闭'), React.createElement(Button, { type: 'primary', disabled: !(alertStationModal.stations || []).length, diff --git a/src/prototypes/oneos-web-h2-station/styles/h2-station-vm-filter.css b/src/prototypes/oneos-web-h2-station/styles/h2-station-vm-filter.css index 31e6a8f..f7209cd 100644 --- a/src/prototypes/oneos-web-h2-station/styles/h2-station-vm-filter.css +++ b/src/prototypes/oneos-web-h2-station/styles/h2-station-vm-filter.css @@ -60,7 +60,7 @@ font-weight: 600; } -.vm-page.lc-page.h2-site-page .vm-table-total, +.vm-page.lc-page.h2-site-page .vm-pagination-total, .vm-page.lc-page.h2-site-page .vm-pagination-size, .vm-page.lc-page.h2-site-page .vm-pagination-nav, .vm-page.lc-page.h2-site-page .vm-pagination-page, @@ -69,7 +69,7 @@ font-size: 0.875rem; } -.vm-page.lc-page.h2-site-page .vm-table-total { +.vm-page.lc-page.h2-site-page .vm-pagination-total { color: var(--ln-muted); } @@ -163,12 +163,17 @@ --h2-table-scroll-x: 1624px; } -/* 锁定表头/表体同宽,避免容器宽于列宽合计时表体被拉伸导致错位(租赁合同 scroll.x 大于容器则无此问题) */ +/* 宽屏铺满容器;窄屏保持列宽合计最小值并可横向滚动 */ .vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-header > table, .vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-body > table { - width: var(--h2-table-scroll-x) !important; + width: 100% !important; + min-width: var(--h2-table-scroll-x) !important; + table-layout: fixed !important; +} + +.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-content > table { + width: 100% !important; min-width: var(--h2-table-scroll-x) !important; - max-width: var(--h2-table-scroll-x) !important; table-layout: fixed !important; } @@ -176,6 +181,15 @@ overflow-x: auto !important; } +.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-container { + width: 100%; +} + +.vm-page.lc-page.h2-site-page .h2-site-list-table.ant-table-wrapper, +.vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-wrapper { + width: 100%; +} + .vm-page.lc-page.h2-site-page .h2-site-list-table .ant-table-cell-scrollbar { width: 0 !important; min-width: 0 !important; diff --git a/src/prototypes/oneos-web-ledger-data/index.tsx b/src/prototypes/oneos-web-ledger-data/index.tsx index a308bbc..5c9afd7 100644 --- a/src/prototypes/oneos-web-ledger-data/index.tsx +++ b/src/prototypes/oneos-web-ledger-data/index.tsx @@ -1,12 +1,11 @@ /** * @name 台账数据 - * 自 ONE-OS web端 原稿复刻(5 个页面) + * 自 ONE-OS web端 原稿复刻(4 个页面;车辆维修明细已独立至 vehicle-maintenance-ledger) */ import '../../common/oneosWebLegacy/legacyGlobals'; import React from 'react'; import Page1 from './pages/01-保险分摊明细.jsx'; import Page2 from './pages/02-车辆氢费明细.jsx'; -import Page3 from './pages/03-车辆维修明细.jsx'; import Page4 from './pages/04-氢费采购端汇总报表.jsx'; import Page5 from './pages/05-租赁业务台账.jsx'; import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell'; @@ -14,7 +13,6 @@ import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyS const pages = [ { id: 'u53f0u8d26u6570u636e-u4fddu9669u5206u644au660eu7', title: '保险分摊明细', component: Page1 }, { id: 'u53f0u8d26u6570u636e-u8f66u8f86u6c22u8d39u660eu7', title: '车辆氢费明细', component: Page2 }, - { id: 'u53f0u8d26u6570u636e-u8f66u8f86u7ef4u4feeu660eu7', title: '车辆维修明细', component: Page3 }, { id: 'u53f0u8d26u6570u636e-u6c22u8d39u91c7u8d2du7aefu6', title: '氢费采购端汇总报表', component: Page4 }, { id: 'u53f0u8d26u6570u636e-u79dfu8d41u4e1au52a1u53f0u8', title: '租赁业务台账', component: Page5 }, ]; diff --git a/src/prototypes/oneos-web-workbench/pages/01-工作台.jsx b/src/prototypes/oneos-web-workbench/pages/01-工作台.jsx index 4e60160..030d10f 100644 --- a/src/prototypes/oneos-web-workbench/pages/01-工作台.jsx +++ b/src/prototypes/oneos-web-workbench/pages/01-工作台.jsx @@ -34,18 +34,25 @@ const Component = function () { var Title = Typography.Title; var RangePicker = DatePicker.RangePicker; - var pageBg = '#f5f7fa'; - var cardRadius = 12; - var cardShadow = '0 1px 2px rgba(0,0,0,0.04), 0 6px 16px rgba(15,23,42,0.06)'; - var cardStyle = { borderRadius: cardRadius, boxShadow: cardShadow, border: '1px solid rgba(0,0,0,0.05)' }; + var pageBg = '#f8fafc'; + var cardRadius = 14; + var cardShadow = '0 1px 2px rgba(15,23,42,0.04), 0 8px 24px rgba(15,23,42,0.06)'; + var cardStyle = { + borderRadius: cardRadius, + boxShadow: cardShadow, + border: '1px solid #e2e8f0', + background: '#ffffff' + }; var pagePadY = 16; - var pagePadX = 20; + var pagePadX = 24; var layoutGutter = 16; - var accentBlue = '#1677ff'; - var accentPurple = '#722ed1'; - var accentCyan = '#13c2c2'; - var accentOrange = '#fa8c16'; - var accentGeekblue = '#2f54eb'; + var accentBlue = '#2563eb'; + var accentPurple = '#7c3aed'; + var accentCyan = '#0891b2'; + var accentOrange = '#f97316'; + var accentGeekblue = '#1d4ed8'; + var textPrimary = '#1e293b'; + var textSecondary = '#64748b'; // 工作台卡片内待办 / 通知默认展示条数 var wbDashPreviewMax = 5; @@ -77,6 +84,47 @@ const Component = function () { var noticeModalOpen = noticeModalState[0]; var setNoticeModalOpen = noticeModalState[1]; + var noticePreviewState = useState(null); + var noticePreviewRecord = noticePreviewState[0]; + var setNoticePreviewRecord = noticePreviewState[1]; + + var quickCustomizeOpenState = useState(false); + var quickCustomizeOpen = quickCustomizeOpenState[0]; + var setQuickCustomizeOpen = quickCustomizeOpenState[1]; + + var quickCustomDraftState = useState([]); + var quickCustomDraft = quickCustomDraftState[0]; + var setQuickCustomDraft = quickCustomDraftState[1]; + + var QUICK_STORAGE_KEY = 'oneos-workbench-quick-v1'; + + function quickItemKey(it) { + if (!it) return ''; + return it.action ? 'action:' + it.action : 'p:' + (it.p || it.t); + } + + function loadQuickCustom() { + if (typeof window === 'undefined' || !window.localStorage) return {}; + try { + var raw = window.localStorage.getItem(QUICK_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch (_e) { + return {}; + } + } + + function saveQuickCustom(next) { + if (typeof window !== 'undefined' && window.localStorage) { + try { + window.localStorage.setItem(QUICK_STORAGE_KEY, JSON.stringify(next)); + } catch (_e) { /* ignore */ } + } + } + + var quickCustomState = useState(loadQuickCustom); + var quickCustom = quickCustomState[0]; + var setQuickCustom = quickCustomState[1]; + var reportTabState = useState('task'); var reportTab = reportTabState[0]; var setReportTab = reportTabState[1]; @@ -414,13 +462,48 @@ const Component = function () { var noticeListState = useState(function () { return [ - { id: 'n1', time: '2026-02-27 08:00', type: '商业险到期提醒', content: '「粤A12345」商业险将在2026-04-15到期,请尽快处理', read: false }, - { id: 'n2', time: '2026-02-26 18:00', type: '营运证到期提醒', content: '「粤B88888」营运证还有90天到期,请尽快更新营运证', read: false }, - { id: 'n3', time: '2026-02-26 10:00', type: '行驶证到期提醒', content: '「浙A11111」行驶证还有90天到期,请尽快进行年审', read: false }, - { id: 'n4', time: '2026-02-25 14:00', type: '租赁合同到期提醒', content: '「HT-2025-088」「某某产业园项目」还有30天到期,请尽快处理', read: false }, - { id: 'n5', time: '2026-02-25 09:00', type: '租赁账单生成提醒', content: '「HT-2025-088」「某某产业园项目」「ZD-202602-031」已生成,请尽快处理', read: false }, - { id: 'n6', time: '2026-02-24 11:00', type: '氢费余额不足提醒', content: '「某某物流」「华南干线项目」氢费余额已不足500元,请尽快通知客户处理', read: false }, - { id: 'n7', time: '2026-02-24 08:30', type: '审批流程提醒', content: '「李四」「异动审核」审批节点已到达,请进行审批', read: false } + { + id: 'n1', time: '2026-02-27 08:00', type: '商业险到期提醒', priority: 'high', + content: '「粤A12345」商业险将在2026-04-15到期,请尽快处理', read: false, + detail: '车牌粤A12345 的商业险保单将于 2026-04-15 到期。请核对保单信息与承保公司,在到期前完成续保,避免车辆脱保影响运营。', + target: { label: '前往证照管理', path: 'web端/运维管理/车辆业务/证照管理.jsx' } + }, + { + id: 'n2', time: '2026-02-26 18:00', type: '营运证到期提醒', priority: 'high', + content: '「粤B88888」营运证还有90天到期,请尽快更新营运证', read: false, + detail: '车牌粤B88888 的营运证剩余有效期 90 天。请提前准备年审/换证材料,并安排车辆送检时间。', + target: { label: '前往证照管理', path: 'web端/运维管理/车辆业务/证照管理.jsx' } + }, + { + id: 'n3', time: '2026-02-26 10:00', type: '行驶证到期提醒', priority: 'medium', + content: '「浙A11111」行驶证还有90天到期,请尽快进行年审', read: false, + detail: '车牌浙A11111 行驶证将于 90 天内到期。请确认车辆检验状态,并同步更新行驶证影像与关键日期。', + target: { label: '前往证照管理', path: 'web端/运维管理/车辆业务/证照管理.jsx' } + }, + { + id: 'n4', time: '2026-02-25 14:00', type: '租赁合同到期提醒', priority: 'high', + content: '「HT-2025-088」「某某产业园项目」还有30天到期,请尽快处理', read: false, + detail: '合同 HT-2025-088(某某产业园项目)将于 30 天后到期。请与业管确认续签或还车计划,避免超期运营。', + target: { label: '查看租赁合同', path: 'web端/车辆租赁合同/车辆租赁合同.jsx' } + }, + { + id: 'n5', time: '2026-02-25 09:00', type: '租赁账单生成提醒', priority: 'medium', + content: '「HT-2025-088」「某某产业园项目」「ZD-202602-031」已生成,请尽快处理', read: false, + detail: '账单 ZD-202602-031 已生成,关联合同 HT-2025-088 / 某某产业园项目。请核对账期与应收金额,并跟进客户付款。', + target: { label: '查看租赁账单', path: 'web端/业务管理/租赁账单.jsx' } + }, + { + id: 'n6', time: '2026-02-24 11:00', type: '氢费余额不足提醒', priority: 'high', + content: '「某某物流」「华南干线项目」氢费余额已不足500元,请尽快通知客户处理', read: false, + detail: '客户某某物流(华南干线项目)氢费账户余额低于 500 元。请通知客户充值,避免加氢订单中断。', + target: { label: '查看氢费账单', path: 'web端/财务管理/氢费账单.jsx' } + }, + { + id: 'n7', time: '2026-02-24 08:30', type: '审批流程提醒', priority: 'medium', + content: '「李四」「异动审核」审批节点已到达,请进行审批', read: false, + detail: '异动审核流程(申请人:李四)已流转至您的审批节点。请查看申请详情并在工作台或审批中心完成处理。', + target: { label: '前往审批中心', path: 'web端/审批中心.jsx' } + } ]; }); var noticeList = noticeListState[0]; @@ -448,11 +531,37 @@ const Component = function () { setNoticeModalOpen(true); }, []); + var openNoticePreview = useCallback(function (record) { + if (!record) return; + setNoticePreviewRecord(record); + }, []); + + var closeNoticePreview = useCallback(function () { + setNoticePreviewRecord(null); + }, []); + + var handleNoticePreviewNavigate = useCallback(function (record) { + if (!record || !record.target || !record.target.path) { + message.info('该通知暂无关联页面(原型)'); + return; + } + protoNav(record.target.path); + closeNoticePreview(); + }, [closeNoticePreview]); + var pushUrgeNotice = useCallback(function (taskRow) { var adminName = mockAdminDisplayName; var content = '(' + adminName + ')已对(' + taskRow.taskName + ')进行催办,请尽快处理'; setNoticeList(function (prev) { - return [{ id: 'urge-' + Date.now(), time: formatNoticeNow(), type: '催办提醒', content: content, read: false }].concat(prev || []); + return [{ + id: 'urge-' + Date.now(), + time: formatNoticeNow(), + type: '催办提醒', + priority: 'high', + content: content, + detail: '管理员 ' + adminName + ' 对任务「' + taskRow.taskName + '」发起了催办。请优先处理该待办,完成后状态将同步更新。', + read: false + }].concat(prev || []); }); message.success('催办提醒已推送至通知列表'); }, []); @@ -1257,6 +1366,66 @@ const Component = function () { }; }, []); + var openQuickCustomize = useCallback(function () { + var role = quickByRole[roleTab]; + var catalog = role ? role.items : []; + var saved = quickCustom[roleTab]; + var keys = saved && saved.length + ? saved.slice() + : catalog.map(quickItemKey); + setQuickCustomDraft(keys); + setQuickCustomizeOpen(true); + }, [quickByRole, roleTab, quickCustom]); + + var toggleQuickDraftKey = useCallback(function (key, checked) { + setQuickCustomDraft(function (prev) { + var next = (prev || []).slice(); + if (checked) { + if (next.indexOf(key) < 0) next.push(key); + } else { + next = next.filter(function (k) { return k !== key; }); + } + return next; + }); + }, []); + + var moveQuickDraftKey = useCallback(function (key, dir) { + setQuickCustomDraft(function (prev) { + var next = (prev || []).slice(); + var idx = next.indexOf(key); + if (idx < 0) return next; + var swap = dir === 'up' ? idx - 1 : idx + 1; + if (swap < 0 || swap >= next.length) return next; + var tmp = next[swap]; + next[swap] = next[idx]; + next[idx] = tmp; + return next; + }); + }, []); + + var saveQuickCustomize = useCallback(function () { + if (!quickCustomDraft.length) { + message.warning('请至少保留一个快速入口'); + return; + } + var next = Object.assign({}, quickCustom); + next[roleTab] = quickCustomDraft.slice(); + setQuickCustom(next); + saveQuickCustom(next); + setQuickCustomizeOpen(false); + message.success('快速入口已保存'); + }, [quickCustom, quickCustomDraft, roleTab]); + + var resetQuickCustomize = useCallback(function () { + var next = Object.assign({}, quickCustom); + delete next[roleTab]; + setQuickCustom(next); + saveQuickCustom(next); + var role = quickByRole[roleTab]; + setQuickCustomDraft(role ? role.items.map(quickItemKey) : []); + message.success('已恢复默认快速入口'); + }, [quickByRole, quickCustom, roleTab]); + var noticeColumns = useMemo(function () { return [ { title: '时间', dataIndex: 'time', key: 'time', width: 150 }, @@ -1274,22 +1443,35 @@ const Component = function () { }, { title: '操作', - key: 'markRead', - width: 88, + key: 'actions', + width: 140, render: function (_, record) { - if (record.read) { - return React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '—'); - } - return React.createElement(Button, { - type: 'link', - size: 'small', - style: { padding: 0, height: 'auto' }, - onClick: function () { markNoticeRead(record.id); } - }, '设为已读'); + return React.createElement(Space, { size: 4 }, + React.createElement(Button, { + type: 'link', + size: 'small', + style: { padding: 0, height: 'auto' }, + onClick: function (e) { + if (e && e.stopPropagation) e.stopPropagation(); + openNoticePreview(record); + } + }, '预览'), + !record.read + ? React.createElement(Button, { + type: 'link', + size: 'small', + style: { padding: 0, height: 'auto' }, + onClick: function (e) { + if (e && e.stopPropagation) e.stopPropagation(); + markNoticeRead(record.id); + } + }, '已读') + : null + ); } } ]; - }, [markNoticeRead]); + }, [markNoticeRead, openNoticePreview]); var overdueDeliveryPopoverTableStyle = { width: '100%', borderCollapse: 'collapse', fontSize: 12 }; var overdueDeliveryPopoverThStyle = { padding: '6px 8px', textAlign: 'left', borderBottom: '1px solid #f0f0f0', backgroundColor: '#fafafa', fontWeight: 600 }; @@ -1404,30 +1586,23 @@ const Component = function () { function renderTodoStatChip(filterKey, labelText, count, pillBg, numColor) { var selected = todoBoardFilter === filterKey; return React.createElement('span', { + className: 'workbench-filter-chip' + (selected ? ' workbench-filter-chip--active' : ''), role: 'button', tabIndex: 0, + 'data-tone': filterKey, onClick: function () { setTodoBoardFilter(filterKey); }, onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setTodoBoardFilter(filterKey); } - }, - style: { - display: 'inline-flex', - alignItems: 'center', - gap: 4, - padding: '3px 10px', - borderRadius: 6, - fontSize: 12, - cursor: 'pointer', - background: pillBg, - boxShadow: selected ? '0 0 0 2px ' + numColor : undefined, - outline: 'none' } }, - React.createElement('span', { style: { color: 'rgba(0,0,0,0.5)' } }, labelText), - React.createElement('span', { style: { fontWeight: 600, color: numColor } }, count) + React.createElement('span', { className: 'workbench-filter-chip-label' }, labelText), + React.createElement('span', { + className: 'workbench-filter-chip-count', + style: { color: numColor } + }, count) ); } @@ -1629,12 +1804,12 @@ const Component = function () { } return React.createElement(Card, { key: s.key, - className: 'workbench-warning-metric-card', + className: 'workbench-warning-metric-card workbench-metric-card' + (onClick ? ' workbench-metric-card--interactive' : ''), size: 'small', bordered: false, style: cardStyleMerged, bodyStyle: { - padding: '14px 16px', + padding: '16px 18px', flex: 1, display: 'flex', flexDirection: 'column', @@ -1656,28 +1831,17 @@ const Component = function () { React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 12, width: '100%', boxSizing: 'border-box' } }, React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 } }, React.createElement('div', { - style: { - width: 40, - height: 40, - borderRadius: 10, - background: s.iconBg, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: 16, - fontWeight: 600, - color: s.color, - flexShrink: 0 - } + className: 'workbench-metric-icon', + style: { background: s.iconBg, color: s.color } }, s.icon), - React.createElement('div', { style: { minWidth: 0 } }, - React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', marginBottom: 0 } }, - React.createElement('span', { style: { fontSize: 12, color: 'rgba(0,0,0,0.55)', lineHeight: 1.35 } }, s.title), + React.createElement('div', { className: 'workbench-metric-copy' }, + React.createElement('div', { className: 'workbench-metric-title-row' }, + React.createElement('span', { className: 'workbench-metric-title' }, s.title), renderWorkbenchMetricHintIcon(s.key) ), valueRow != null ? valueRow - : React.createElement('div', { style: { fontSize: 22, fontWeight: 600, color: accentOrange, lineHeight: 1.2 } }, s.value) + : React.createElement('div', { className: 'workbench-metric-value' }, s.value) ) ), trailing || null @@ -1719,16 +1883,16 @@ const Component = function () { }; if (s.key === 'w_en_h2_import_today') { cardProps.valueRow = energyH2ImportTodayCount > 0 - ? React.createElement('div', { style: { fontSize: 22, fontWeight: 600, color: accentOrange, lineHeight: 1.2 } }, energyH2ImportTodayCount) - : React.createElement('div', { style: { fontSize: 11, lineHeight: 1.45, display: 'block', color: accentOrange, fontWeight: 500 } }, + ? React.createElement('div', { className: 'workbench-metric-value' }, energyH2ImportTodayCount) + : React.createElement('div', { className: 'workbench-metric-hint-copy' }, '今日还未导入任何加氢记录,请确认是否有加氢明细需要导入' ); } if (s.key === 'w_fin_energy_recharge') { cardProps.value = financeEnergyRechargeYuan; cardProps.valueRow = financeEnergyRechargeYuan != null - ? React.createElement('div', { style: { fontSize: 22, fontWeight: 600, color: accentOrange, lineHeight: 1.2 } }, formatWorkbenchFinanceYuan(financeEnergyRechargeYuan)) - : React.createElement('div', { style: { fontSize: 11, lineHeight: 1.45, display: 'block', color: accentOrange, fontWeight: 500 } }, + ? React.createElement('div', { className: 'workbench-metric-value workbench-metric-value--compact' }, formatWorkbenchFinanceYuan(financeEnergyRechargeYuan)) + : React.createElement('div', { className: 'workbench-metric-hint-copy' }, '请确认是否有能源账户充值记录' ); } @@ -1740,58 +1904,67 @@ const Component = function () { var titleNode = React.createElement(Space, { size: 8, wrap: true, align: 'center' }, React.createElement('span', { className: 'workbench-dash-pair-head-title' }, '通知'), noticeNewCount > 0 - ? React.createElement(Badge, { count: noticeNewCount, style: { backgroundColor: '#fa8c16' } }) - : null + ? React.createElement(Badge, { count: noticeNewCount, style: { backgroundColor: accentOrange } }) + : null, + React.createElement(Text, { type: 'secondary', className: 'workbench-notice-panel-hint' }, '点击条目预览详情') ); - var lines = preview.map(function (n, idx) { - var isLast = idx === preview.length - 1; - return React.createElement('div', { + var lines = preview.map(function (n) { + var priorityClass = n.priority === 'high' + ? ' workbench-notice-card--high' + : (n.priority === 'medium' ? ' workbench-notice-card--medium' : ''); + var unreadClass = !n.read ? ' workbench-notice-card--unread' : ''; + return React.createElement('button', { key: n.id, - className: 'workbench-notice-detail-line', - style: { - padding: '6px 0', - borderBottom: isLast ? 'none' : '1px solid rgba(0,0,0,0.06)' - } + type: 'button', + className: 'workbench-notice-card' + priorityClass + unreadClass, + onClick: function () { openNoticePreview(n); }, + 'aria-label': '预览通知:' + n.type }, - React.createElement('div', { style: { fontSize: 11, color: 'rgba(0,0,0,0.45)', marginBottom: 4, lineHeight: 1.3 } }, - n.time, - React.createElement('span', { style: { margin: '0 6px', color: 'rgba(0,0,0,0.25)' } }, '·'), - n.type, + React.createElement('div', { className: 'workbench-notice-card-top' }, + React.createElement('span', { className: 'workbench-notice-card-type' }, n.type), !n.read - ? React.createElement(Tag, { color: 'warning', style: { marginLeft: 6, fontSize: 11, lineHeight: '18px', padding: '0 6px' } }, '未读') + ? React.createElement('span', { className: 'workbench-notice-card-badge' }, '未读') : null ), - React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%' } }, - React.createElement(Text, { - ellipsis: { tooltip: true }, - style: { fontSize: 12, color: 'rgba(0,0,0,0.78)', display: 'block', lineHeight: 1.45, flex: 1, minWidth: 0 } - }, n.content), - !n.read - ? React.createElement(Button, { - type: 'link', - size: 'small', - style: { padding: 0, flexShrink: 0, height: 'auto', lineHeight: 1.45, fontSize: 12 }, - onClick: function () { markNoticeRead(n.id); } - }, '设为已读') - : null + React.createElement('div', { className: 'workbench-notice-card-content' }, n.content), + React.createElement('div', { className: 'workbench-notice-card-foot' }, + React.createElement('span', { className: 'workbench-notice-card-time' }, n.time), + React.createElement('span', { className: 'workbench-notice-card-action', 'aria-hidden': true }, '预览 ›') ) ); }); return React.createElement(Card, { key: 'notice-panel', - className: 'workbench-notice-panel workbench-dash-pair-head', + className: 'workbench-notice-panel workbench-dash-pair-head workbench-surface-card', bordered: false, title: titleNode, style: Object.assign({}, cardStyle, { flex: 1, width: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }), bodyStyle: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }, - extra: React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: openNoticeModal }, '更多') + extra: React.createElement(Space, { size: 4 }, + noticeNewCount > 0 + ? React.createElement(Button, { + type: 'link', + size: 'small', + style: { padding: 0 }, + onClick: function () { + setNoticeList(function (prev) { + return (prev || []).map(function (item) { + return Object.assign({}, item, { read: true }); + }); + }); + message.success('已全部标为已读'); + } + }, '全部已读') + : null, + React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: openNoticeModal }, '更多') + ) }, React.createElement('div', { className: 'workbench-dash-card-body-center' }, preview.length === 0 ? React.createElement('div', { className: 'workbench-dash-card-body-inner workbench-dash-card-body-empty' }, - React.createElement(Text, { type: 'secondary', style: { fontSize: 12 } }, '暂无通知') + React.createElement(Text, { type: 'secondary', style: { fontSize: 13 } }, '暂无通知') ) - : React.createElement('div', { className: 'workbench-dash-card-body-inner workbench-notice-detail-scroll' }, lines) + : React.createElement('div', { className: 'workbench-dash-card-body-inner workbench-notice-detail-scroll workbench-notice-card-list' }, lines) ) ); } @@ -1802,12 +1975,11 @@ const Component = function () { key: 'task', label: '任务处理情况', children: React.createElement('div', { className: 'workbench-task-report-pane' }, - React.createElement('div', { style: { flexShrink: 0, fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, lineHeight: 1.5 } }, - '横轴:员工 · 纵轴:任务数量 · ', - React.createElement('span', { style: { color: '#52c41a', fontWeight: 500 } }, '■'), - ' 完成任务 ', - React.createElement('span', { style: { color: '#f5222d', fontWeight: 500 } }, '■'), - ' 超时任务(示意)' + React.createElement('div', { className: 'workbench-chart-legend' }, + React.createElement('span', { className: 'workbench-chart-legend-swatch', style: { background: '#16a34a' } }), + '完成任务 ', + React.createElement('span', { className: 'workbench-chart-legend-swatch', style: { background: '#f87171', marginLeft: 8 } }), + '超时任务(示意)' ), renderTaskEmployeeBarChart(taskEmployeeBars, 'done', 'overdue', '#52c41a', '#ff7875', 52) ) @@ -1843,13 +2015,27 @@ const Component = function () { label: '车辆运营情况', children: React.createElement('div', { style: { padding: '8px 0 0' } }, React.createElement('div', { style: { fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8 } }, '近12个月 · 运营车辆 / 闲置车辆'), - renderBarGroup(vehicleMonthBars, 'op', 'idle', '#1677ff', '#91caff') + renderBarGroup(vehicleMonthBars, 'op', 'idle', accentBlue, '#93c5fd') ) } ]; }, [vehicleMonthBars, contractMonthBars, taskEmployeeBars, contractPaymentDonut]); - var quickItems = quickByRole[roleTab] ? quickByRole[roleTab].items : []; + var quickItems = useMemo(function () { + var role = quickByRole[roleTab]; + if (!role) return []; + var catalog = role.items; + var catalogMap = {}; + catalog.forEach(function (it) { + catalogMap[quickItemKey(it)] = it; + }); + var saved = quickCustom[roleTab]; + if (!saved || !saved.length) return catalog; + return saved.map(function (key) { return catalogMap[key]; }).filter(Boolean); + }, [quickByRole, roleTab, quickCustom]); + + var quickCatalogItems = quickByRole[roleTab] ? quickByRole[roleTab].items : []; + var quickIsCustomized = !!(quickCustom[roleTab] && quickCustom[roleTab].length); var quickRoleKeys = ['ye', 'yeEnergy', 'ops', 'finance', 'safety', 'legal']; @@ -1867,6 +2053,57 @@ const Component = function () { }, [quickByRole, roleTab]); var oneScreenCss = + '@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");' + + '.workbench-one-screen{' + + '--wb-primary:#2563eb;--wb-primary-soft:#eff6ff;--wb-primary-border:#bfdbfe;' + + '--wb-accent:#f97316;--wb-accent-soft:#fff7ed;' + + '--wb-success:#16a34a;--wb-danger:#dc2626;' + + '--wb-text:#1e293b;--wb-text-secondary:#64748b;--wb-text-muted:#94a3b8;' + + '--wb-surface:#ffffff;--wb-bg:#f8fafc;--wb-border:#e2e8f0;' + + '--wb-radius:14px;--wb-shadow:0 1px 2px rgba(15,23,42,0.04),0 8px 24px rgba(15,23,42,0.06);' + + 'font-family:Inter,-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--wb-text);' + + '-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}' + + '.workbench-hero{flex-shrink:0;margin-bottom:16px;padding:18px 20px;border-radius:var(--wb-radius);' + + 'border:1px solid var(--wb-border);background:linear-gradient(135deg,#fff 0%,#f8fafc 52%,#eff6ff 100%);' + + 'box-shadow:var(--wb-shadow)}' + + '.workbench-hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}' + + '.workbench-hero-greeting{margin:0;font-size:22px;font-weight:700;line-height:1.3;color:var(--wb-text);letter-spacing:-0.02em}' + + '.workbench-hero-greeting em{font-style:normal;color:var(--wb-primary)}' + + '.workbench-hero-sub{margin-top:6px;font-size:14px;line-height:1.6;color:var(--wb-text-secondary)}' + + '.workbench-hero-sub strong{font-weight:600;color:var(--wb-text)}' + + '.workbench-hero-stats{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}' + + '.workbench-hero-stat{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;' + + 'background:var(--wb-surface);border:1px solid var(--wb-border);font-size:13px;line-height:1.4;color:var(--wb-text-secondary)}' + + '.workbench-hero-stat b{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--wb-text)}' + + '.workbench-hero-stat--warn b{color:var(--wb-accent)}' + + '.workbench-hero-stat--info b{color:var(--wb-primary)}' + + '.workbench-section-kicker{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;' + + 'letter-spacing:0.04em;text-transform:uppercase;color:var(--wb-text-muted)}' + + '.workbench-section-kicker-dot{width:6px;height:6px;border-radius:50%;background:var(--wb-primary);flex-shrink:0}' + + '.workbench-filter-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;min-height:32px;' + + 'border-radius:999px;font-size:13px;cursor:pointer;border:1px solid var(--wb-border);background:var(--wb-surface);' + + 'transition:border-color .18s,background .18s,box-shadow .18s,transform .18s;outline:none;user-select:none}' + + '.workbench-filter-chip:hover{border-color:var(--wb-primary-border);background:var(--wb-primary-soft)}' + + '.workbench-filter-chip:focus-visible{box-shadow:0 0 0 3px rgba(37,99,235,0.2)}' + + '.workbench-filter-chip--active{border-color:var(--wb-primary);background:var(--wb-primary-soft);box-shadow:0 0 0 1px rgba(37,99,235,0.12)}' + + '.workbench-filter-chip-label{color:var(--wb-text-secondary)}' + + '.workbench-filter-chip-count{font-weight:700;font-variant-numeric:tabular-nums}' + + '.workbench-metric-card{background:var(--wb-surface)!important;border:1px solid var(--wb-border)!important;box-shadow:var(--wb-shadow)!important;transition:transform .2s ease,box-shadow .2s ease,border-color .2s ease}' + + '.workbench-metric-card--interactive{cursor:pointer}' + + '.workbench-metric-card--interactive:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(15,23,42,0.08),0 12px 28px rgba(37,99,235,0.08)!important;border-color:var(--wb-primary-border)!important}' + + '.workbench-metric-card--interactive:focus-visible{outline:2px solid rgba(37,99,235,0.35);outline-offset:2px}' + + '.workbench-metric-icon{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;flex-shrink:0}' + + '.workbench-metric-copy{min-width:0}' + + '.workbench-metric-title-row{display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin-bottom:2px}' + + '.workbench-metric-title{font-size:13px;line-height:1.4;color:var(--wb-text-secondary);font-weight:500}' + + '.workbench-metric-value{font-size:24px;font-weight:700;line-height:1.2;color:var(--wb-accent);font-variant-numeric:tabular-nums;letter-spacing:-0.02em}' + + '.workbench-metric-value--compact{font-size:18px}' + + '.workbench-metric-hint-copy{font-size:12px;line-height:1.5;color:var(--wb-accent);font-weight:500}' + + '.workbench-dash-card-body-empty{min-height:140px;background:linear-gradient(180deg,transparent 0%,rgba(248,250,252,0.9) 100%);border-radius:12px}' + + '.workbench-chart-legend{font-size:13px;line-height:1.5;color:var(--wb-text-secondary);margin-bottom:10px}' + + '.workbench-chart-legend-swatch{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:-1px}' + + '.workbench-surface-card{background:var(--wb-surface)!important;border:1px solid var(--wb-border)!important;box-shadow:var(--wb-shadow)!important}' + + '@media (prefers-reduced-motion:reduce){.workbench-metric-card,.workbench-notice-card,.workbench-quick-item,.workbench-filter-chip{transition:none!important}.workbench-metric-card--interactive:hover{transform:none}}' + '.workbench-one-screen .workbench-report-tabs.ant-tabs{height:100%;display:flex;flex-direction:column;min-height:0}' + '.workbench-one-screen .workbench-report-tabs .ant-tabs-content-holder{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}' + '.workbench-one-screen .workbench-report-tabs .ant-tabs-content{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}' + @@ -1882,8 +2119,7 @@ const Component = function () { '.workbench-main-split>.ant-row.workbench-todo-notice-row,.workbench-main-split>.ant-row.workbench-report-quick-row{flex:1 1 0!important;min-height:0!important;height:auto!important;max-height:none!important;margin:0!important;overflow:hidden}' + '.workbench-todo-notice-row>.ant-col,.workbench-report-quick-row>.ant-col{display:flex!important;flex-direction:column;min-height:0}' + '.workbench-notice-slot,.workbench-todo-slot,.workbench-report-slot{min-height:0}' + - '.workbench-dash-pair-head.ant-card .ant-card-head{min-height:56px;padding:0 18px;display:flex;align-items:center;border-bottom:1px solid rgba(0,0,0,0.06)}' + - '.workbench-dash-pair-head.ant-card .ant-card-head-title,.workbench-dash-pair-head-title{font-size:16px;font-weight:600;line-height:24px;color:rgba(0,0,0,0.88)}' + + '.workbench-dash-pair-head.ant-card .ant-card-head{min-height:56px;padding:0 20px;display:flex;align-items:center;border-bottom:1px solid var(--wb-border);background:linear-gradient(180deg,#fff 0%,#fafbfc 100%)}' + '.workbench-dash-pair-head.ant-card .ant-card-extra{padding:0}' + '.workbench-dash-pair-head.ant-card .ant-card-body{padding-top:10px!important}' + '.workbench-dash-pair-head-sub{font-size:12px!important;line-height:20px!important}' + @@ -1896,6 +2132,42 @@ const Component = function () { '.workbench-report-tabs .ant-tabs-nav{margin-bottom:8px;}' + '.workbench-notice-inline-hint{font-size:11px;font-weight:500;color:#ad4e00;cursor:pointer;user-select:none;white-space:nowrap}' + '.workbench-notice-inline-hint:hover{color:#d46b08;text-decoration:underline}' + + '.workbench-notice-panel-hint{font-size:12px!important;line-height:20px!important}' + + '.workbench-notice-card-list{display:flex;flex-direction:column;gap:8px;padding:2px 0}' + + '.workbench-notice-card{display:block;width:100%;box-sizing:border-box;text-align:left;border:1px solid rgba(0,0,0,0.06);border-radius:10px;background:#fafafa;padding:10px 12px;cursor:pointer;transition:border-color .18s,background .18s,box-shadow .18s,transform .18s;outline:none;font:inherit}' + + '.workbench-notice-card:hover{border-color:rgba(37,99,235,0.28);background:#f5f9ff;box-shadow:0 2px 8px rgba(37,99,235,0.08)}' + + '.workbench-notice-card:focus-visible{box-shadow:0 0 0 2px rgba(37,99,235,0.24)}' + + '.workbench-notice-card--unread{border-left:3px solid var(--wb-accent);background:var(--wb-accent-soft)}' + + '.workbench-notice-card--unread:hover{background:#fff7ef}' + + '.workbench-notice-card--high.workbench-notice-card--unread{border-left-color:#f5222d}' + + '.workbench-notice-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}' + + '.workbench-notice-card-type{font-size:12px;font-weight:600;color:rgba(0,0,0,0.78);line-height:1.35}' + + '.workbench-notice-card-badge{font-size:11px;line-height:18px;padding:0 6px;border-radius:999px;background:#fff7e6;color:#d46b08;font-weight:500;flex-shrink:0}' + + '.workbench-notice-card:active{transform:scale(0.99)}' + + '.workbench-notice-card-content{font-size:14px;line-height:1.55;color:var(--wb-text);display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}' + + '.workbench-notice-card-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px}' + + '.workbench-notice-card-time{font-size:12px;color:rgba(0,0,0,0.45);font-variant-numeric:tabular-nums}' + + '.workbench-notice-card-action{font-size:12px;color:var(--wb-primary);font-weight:600;flex-shrink:0}' + + '.workbench-notice-table-row:hover>td{background:#f5f9ff!important}' + + '.workbench-notice-preview{display:flex;flex-direction:column;gap:14px}' + + '.workbench-notice-preview-meta{display:flex;align-items:center;flex-wrap:wrap;gap:8px}' + + '.workbench-notice-preview-summary{font-size:15px;line-height:1.55;font-weight:600;color:rgba(0,0,0,0.88)}' + + '.workbench-notice-preview-detail{border:1px solid rgba(0,0,0,0.06);border-radius:10px;background:#fafafa;padding:12px 14px}' + + '.workbench-notice-preview-detail-label{font-size:12px;font-weight:600;color:rgba(0,0,0,0.45);margin-bottom:6px;letter-spacing:0.02em}' + + '.workbench-notice-preview-detail-body{font-size:14px;line-height:1.6;color:rgba(0,0,0,0.78)}' + + '.workbench-quick-customize{display:grid;grid-template-columns:1fr 1fr;gap:16px;min-height:280px}' + + '@media (max-width: 640px){.workbench-quick-customize{grid-template-columns:1fr}}' + + '.workbench-quick-customize-col{display:flex;flex-direction:column;min-height:0;border:1px solid rgba(0,0,0,0.06);border-radius:10px;background:#fafafa;overflow:hidden}' + + '.workbench-quick-customize-title{padding:10px 12px;font-size:13px;font-weight:600;color:rgba(0,0,0,0.78);border-bottom:1px solid rgba(0,0,0,0.06);background:#fff}' + + '.workbench-quick-customize-count{font-weight:400;color:rgba(0,0,0,0.45)}' + + '.workbench-quick-customize-list,.workbench-quick-customize-order{flex:1;min-height:0;overflow:auto;padding:8px;display:flex;flex-direction:column;gap:6px}' + + '.workbench-quick-customize-option{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;background:#fff;border:1px solid rgba(0,0,0,0.04);cursor:pointer;transition:background .15s,border-color .15s}' + + '.workbench-quick-customize-option:hover{border-color:rgba(22,119,255,0.2);background:#f8fbff}' + + '.workbench-quick-customize-option.is-checked{border-color:rgba(22,119,255,0.28);background:#f0f7ff}' + + '.workbench-quick-customize-option-label{font-size:13px;line-height:1.4;color:rgba(0,0,0,0.78)}' + + '.workbench-quick-customize-order-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;background:#fff;border:1px solid rgba(0,0,0,0.04)}' + + '.workbench-quick-customize-order-index{width:22px;height:22px;border-radius:6px;background:#f0f5ff;color:#1677ff;font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;font-variant-numeric:tabular-nums}' + + '.workbench-quick-customize-order-label{flex:1;min-width:0;font-size:13px;line-height:1.4;color:rgba(0,0,0,0.78)}' + '.workbench-todo-table .ant-table-thead>tr>th{color:#707d8f!important;font-weight:400!important;font-size:12px!important;background:#f7f8fa!important;border-bottom:1px solid rgba(0,0,0,0.06)!important;padding:12px 12px!important}' + '.workbench-todo-table .ant-table-thead>tr>th::before{display:none!important}' + '.workbench-todo-table .ant-table-column-sorter{color:#98a1b0!important}' + @@ -1903,22 +2175,25 @@ const Component = function () { '.workbench-todo-table.ant-table-small .ant-table-thead>tr>th{padding:10px 12px!important}' + '.workbench-todo-table .ant-table-thead>tr>th.ant-table-column-sort{background:#f7f8fa!important}' + '.workbench-todo-table .ant-table-tbody>tr>td.ant-table-column-sort{background:#fff!important}' + - '.workbench-todo-table .ant-table-tbody>tr.ant-table-row:hover>td.ant-table-column-sort{background:#fafafa!important}' + + '.workbench-todo-table .ant-table-tbody>tr>td{font-size:14px!important;line-height:1.5!important;color:var(--wb-text)!important;padding:12px!important}' + + '.workbench-todo-table .ant-table-tbody>tr>td .ant-btn-link{font-weight:500}' + + '.workbench-todo-table .ant-table-tbody>tr.ant-table-row{cursor:pointer;transition:background .15s}' + + '.workbench-todo-table .ant-table-tbody>tr.ant-table-row:hover>td{background:#f8fbff!important}' + '.workbench-one-screen .workbench-quick-card.ant-card{display:flex;flex-direction:column;min-height:0;height:100%}' + '.workbench-one-screen .workbench-quick-card .ant-card-body{padding:0;flex:1;min-height:0;display:flex;flex-direction:column}' + '.workbench-quick-scroll{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch}' + - '.workbench-quick-item{display:flex;align-items:center;gap:6px;width:100%;box-sizing:border-box;padding:5px 6px;border-radius:6px;border:1px solid rgba(0,0,0,0.06);background:#fafafa;cursor:pointer;transition:border-color .15s,background .15s,box-shadow .15s;text-align:left;outline:none}' + - '.workbench-quick-item:hover{border-color:rgba(22,119,255,0.22);background:#f3f8ff;box-shadow:0 1px 2px rgba(22,119,255,0.06)}' + - '.workbench-quick-item:focus-visible{box-shadow:0 0 0 2px rgba(22,119,255,0.2)}' + - '.workbench-quick-item-icon{flex-shrink:0;width:26px;height:26px;border-radius:6px;background:linear-gradient(135deg,#f0f5ff,#d6e4ff);line-height:26px;text-align:center;font-size:12px;font-weight:600;color:#1677ff}' + - '.workbench-quick-item-label{flex:1;min-width:0;font-size:11px;line-height:1.3;color:rgba(0,0,0,0.78);word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}' + + '.workbench-quick-item{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:10px 12px;min-height:44px;border-radius:10px;border:1px solid var(--wb-border);background:var(--wb-surface);cursor:pointer;transition:border-color .18s,background .18s,box-shadow .18s,transform .18s;text-align:left;outline:none}' + + '.workbench-quick-item:hover{border-color:var(--wb-primary-border);background:var(--wb-primary-soft);box-shadow:0 2px 8px rgba(37,99,235,0.08);transform:translateY(-1px)}' + + '.workbench-quick-item:focus-visible{box-shadow:0 0 0 3px rgba(37,99,235,0.2)}' + + '.workbench-quick-item-icon{flex-shrink:0;width:32px;height:32px;border-radius:10px;background:linear-gradient(135deg,#eff6ff,#dbeafe);line-height:32px;text-align:center;font-size:13px;font-weight:700;color:var(--wb-primary)}' + + '.workbench-quick-item-label{flex:1;min-width:0;font-size:12px;line-height:1.35;color:rgba(0,0,0,0.78);word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}' + '.workbench-quick-item--action{padding:7px 10px;min-height:38px}' + - '.workbench-quick-item--primary{border-color:rgba(22,119,255,0.2);background:linear-gradient(135deg,#f8fbff 0%,#eef5ff 100%)}' + - '.workbench-quick-item--primary:hover{border-color:rgba(22,119,255,0.35);background:linear-gradient(135deg,#f0f7ff 0%,#e6f0ff 100%)}' + - '.workbench-quick-item--primary .workbench-quick-item-icon{background:linear-gradient(135deg,#1677ff,#4096ff);color:#fff;box-shadow:0 2px 6px rgba(22,119,255,0.25)}' + - '.workbench-quick-item--orange{border-color:rgba(250,140,22,0.22);background:linear-gradient(135deg,#fffaf5 0%,#fff3e6 100%)}' + - '.workbench-quick-item--orange:hover{border-color:rgba(250,140,22,0.38);background:linear-gradient(135deg,#fff7ef 0%,#ffebd6 100%)}' + - '.workbench-quick-item--orange .workbench-quick-item-icon{background:linear-gradient(135deg,#fa8c16,#ffa940);color:#fff;box-shadow:0 2px 6px rgba(250,140,22,0.22)}' + + '.workbench-quick-item--primary{border-color:rgba(37,99,235,0.22);background:linear-gradient(135deg,#f8fbff 0%,#eff6ff 100%)}' + + '.workbench-quick-item--primary:hover{border-color:rgba(37,99,235,0.38);background:linear-gradient(135deg,#eff6ff 0%,#dbeafe 100%)}' + + '.workbench-quick-item--primary .workbench-quick-item-icon{background:linear-gradient(135deg,#2563eb,#3b82f6);color:#fff;box-shadow:0 4px 10px rgba(37,99,235,0.28)}' + + '.workbench-quick-item--orange{border-color:rgba(249,115,22,0.24);background:linear-gradient(135deg,#fffaf5 0%,#fff7ed 100%)}' + + '.workbench-quick-item--orange:hover{border-color:rgba(249,115,22,0.4);background:linear-gradient(135deg,#fff7ed 0%,#ffedd5 100%)}' + + '.workbench-quick-item--orange .workbench-quick-item-icon{background:linear-gradient(135deg,#f97316,#fb923c);color:#fff;box-shadow:0 4px 10px rgba(249,115,22,0.24)}' + '.workbench-quick-section-hint{font-size:11px;line-height:1.35;color:rgba(0,0,0,0.45);padding:0 4px 6px;font-weight:500;letter-spacing:0.02em}' + '.workbench-gen-task-modal .ant-modal-body{padding-top:14px}' + '.workbench-gen-task-form-label{display:block;margin-bottom:6px;font-size:13px;font-weight:500;color:rgba(0,0,0,0.88);line-height:1.4}' + @@ -1932,7 +2207,9 @@ const Component = function () { '.workbench-header-warning-dept-tabs.ant-tabs{min-width:0}' + '.workbench-header-warning-dept-tabs .ant-tabs-nav{margin:0!important;min-height:32px}' + '.workbench-header-warning-dept-tabs .ant-tabs-nav::before{border-bottom:none!important}' + - '.workbench-header-warning-dept-tabs .ant-tabs-tab{padding:4px 8px!important;font-size:12px}' + + '.workbench-header-warning-dept-tabs .ant-tabs-tab{padding:6px 12px!important;font-size:13px;border-radius:999px;margin:0 2px!important;transition:background .18s,color .18s}' + + '.workbench-header-warning-dept-tabs .ant-tabs-tab-active{background:var(--wb-primary-soft)!important}' + + '.workbench-header-warning-dept-tabs .ant-tabs-tab-active .ant-tabs-tab-btn{color:var(--wb-primary)!important;font-weight:600}' + '.workbench-header-warning-dept-tabs .ant-tabs-nav-wrap{justify-content:flex-end}' + '.workbench-header-warning-dept-tabs .ant-tabs-content-holder{display:none!important}' + '.workbench-overdue-delivery-modal .ant-table-tbody>tr>td{color:#a8071a!important;background-color:#fff2f0!important;border-color:#ffccc7!important}' + @@ -1952,8 +2229,11 @@ const Component = function () { '.workbench-one-screen .ant-breadcrumb{font-size:13px;line-height:1.5}' + '.workbench-one-screen .ant-breadcrumb a,.workbench-one-screen .ant-breadcrumb .ant-breadcrumb-link{color:rgba(0,0,0,0.65)}' + '.workbench-one-screen .ant-breadcrumb li:last-child .ant-breadcrumb-link{color:rgba(0,0,0,0.88);font-weight:500}' + - '.workbench-report-tabs .ant-tabs-ink-bar{background:#1677ff;height:2px;border-radius:1px}' + - '.workbench-report-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:rgba(0,0,0,0.88);font-weight:600}' + + '.workbench-report-tabs .ant-tabs-ink-bar{background:var(--wb-primary);height:3px;border-radius:2px}' + + '.workbench-report-tabs .ant-tabs-tab .ant-tabs-tab-btn{font-size:14px;color:var(--wb-text-secondary);transition:color .18s}' + + '.workbench-report-tabs .ant-tabs-tab:hover .ant-tabs-tab-btn{color:var(--wb-text)}' + + '.workbench-report-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:var(--wb-text);font-weight:600}' + + '.workbench-dash-pair-head.ant-card .ant-card-head-title,.workbench-dash-pair-head-title{font-size:17px;font-weight:700;line-height:26px;color:var(--wb-text);letter-spacing:-0.01em}' + '.workbench-report-slot .ant-card .ant-card-head{min-height:52px;padding:0 18px;border-bottom:1px solid rgba(0,0,0,0.06)}' + '.workbench-report-slot .ant-card .ant-card-head-title{font-size:16px;font-weight:600;color:rgba(0,0,0,0.88)}'; @@ -1972,9 +2252,33 @@ const Component = function () { }, React.createElement('style', null, oneScreenCss), - React.createElement('div', { style: { flexShrink: 0, marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid rgba(0,0,0,0.06)' } }, - React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10, flexWrap: 'wrap', gap: 10 } }, - React.createElement(Breadcrumb, { items: [{ title: '运管平台' }, { title: '工作台' }] }), + React.createElement('div', { className: 'workbench-hero' }, + React.createElement('div', { className: 'workbench-hero-top' }, + React.createElement('div', { style: { minWidth: 0, flex: '1 1 280px' } }, + React.createElement('div', { className: 'workbench-section-kicker', style: { marginBottom: 8 } }, + React.createElement('span', { className: 'workbench-section-kicker-dot' }), + '运管平台 · 工作台' + ), + React.createElement('h1', { className: 'workbench-hero-greeting' }, + '欢迎回来,', + React.createElement('em', null, mockOperatorDisplayName) + ), + React.createElement('p', { className: 'workbench-hero-sub' }, + '集中查看待办、预警指标与业务通知,快速进入常用功能。' + ), + React.createElement('div', { className: 'workbench-hero-stats' }, + React.createElement('span', { className: 'workbench-hero-stat workbench-hero-stat--info' }, + React.createElement('b', null, todoSummary.pending + todoSummary.overdue), + ' 条待办' + ), + noticeNewCount > 0 + ? React.createElement('span', { className: 'workbench-hero-stat workbench-hero-stat--warn' }, + React.createElement('b', null, noticeNewCount), + ' 条未读通知' + ) + : React.createElement('span', { className: 'workbench-hero-stat' }, '通知已全部处理') + ) + ), React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', minWidth: 0 } }, React.createElement(Tabs, { className: 'workbench-header-warning-dept-tabs', @@ -1985,8 +2289,7 @@ const Component = function () { tabBarGutter: 4 }) ) - ), - React.createElement(Title, { level: 5, style: { margin: 0, fontWeight: 600, fontSize: 18, color: 'rgba(0,0,0,0.88)', letterSpacing: '-0.01em' } }, '欢迎回来,' + mockOperatorDisplayName) + ) ), React.createElement('div', { style: { flexShrink: 0, marginBottom: 12, boxSizing: 'border-box' } }, React.createElement('div', { className: 'workbench-top-metrics', style: { display: 'flex', flexWrap: 'wrap', gap: layoutGutter, alignItems: 'stretch' } }, @@ -2034,7 +2337,7 @@ const Component = function () { }, React.createElement('div', { className: 'workbench-todo-slot', style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', width: '100%' } }, React.createElement(Card, { - className: 'workbench-dash-pair-head', + className: 'workbench-dash-pair-head workbench-surface-card', title: React.createElement(Space, { size: 8, wrap: true, align: 'center' }, React.createElement('span', { className: 'workbench-dash-pair-head-title' }, '待办任务'), React.createElement(Badge, { count: todoSummary.pending + todoSummary.overdue, style: { backgroundColor: accentBlue } }), @@ -2091,6 +2394,7 @@ const Component = function () { React.createElement(Card, { title: '统计报表', bordered: false, + className: 'workbench-surface-card', style: Object.assign({}, cardStyle, { flex: 1, width: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }), bodyStyle: { paddingTop: 4, paddingBottom: 8, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }, @@ -2112,17 +2416,23 @@ const Component = function () { style: { display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0 } }, React.createElement(Card, { - className: 'workbench-quick-card', + className: 'workbench-quick-card workbench-surface-card', title: '快速入口', bordered: false, style: Object.assign({}, cardStyle, { flex: 1, width: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }), bodyStyle: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden', padding: 0 }, - extra: React.createElement(Dropdown, { - menu: roleSwitchMenuProps, - trigger: ['click'], - placement: 'bottomRight' - }, - React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 } }, '切换角色') + extra: React.createElement(Space, { size: 8, wrap: true }, + quickIsCustomized + ? React.createElement(Tag, { color: 'processing', style: { margin: 0, fontSize: 11, lineHeight: '18px' } }, '已自定义') + : null, + React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 }, onClick: openQuickCustomize }, '自定义'), + React.createElement(Dropdown, { + menu: roleSwitchMenuProps, + trigger: ['click'], + placement: 'bottomRight' + }, + React.createElement(Button, { type: 'link', size: 'small', style: { padding: 0 } }, '切换角色') + ) ) }, React.createElement('div', { className: 'workbench-quick-scroll', style: { padding: '6px 6px 4px' } }, @@ -2265,10 +2575,137 @@ const Component = function () { columns: noticeColumns, dataSource: noticeList, pagination: { pageSize: 8, showSizeChanger: false }, - scroll: { x: 720 } + scroll: { x: 720 }, + onRow: function (record) { + return { + className: 'workbench-notice-table-row', + style: { cursor: 'pointer' }, + onClick: function () { openNoticePreview(record); } + }; + } }) ), + React.createElement(Modal, { + title: noticePreviewRecord ? noticePreviewRecord.type : '通知详情', + open: !!noticePreviewRecord, + width: 560, + onCancel: closeNoticePreview, + destroyOnClose: true, + footer: noticePreviewRecord + ? React.createElement(Space, { wrap: true }, + !noticePreviewRecord.read + ? React.createElement(Button, { + onClick: function () { + markNoticeRead(noticePreviewRecord.id); + } + }, '标为已读') + : null, + noticePreviewRecord.target && noticePreviewRecord.target.path + ? React.createElement(Button, { + type: 'primary', + onClick: function () { handleNoticePreviewNavigate(noticePreviewRecord); } + }, noticePreviewRecord.target.label || '前往处理') + : null, + React.createElement(Button, { onClick: closeNoticePreview }, '关闭') + ) + : null + }, + noticePreviewRecord + ? React.createElement('div', { className: 'workbench-notice-preview' }, + React.createElement('div', { className: 'workbench-notice-preview-meta' }, + React.createElement(Tag, { + color: noticePreviewRecord.priority === 'high' ? 'error' : (noticePreviewRecord.priority === 'medium' ? 'warning' : 'default'), + style: { margin: 0 } + }, noticePreviewRecord.priority === 'high' ? '高优先级' : (noticePreviewRecord.priority === 'medium' ? '中优先级' : '普通')), + React.createElement(Text, { type: 'secondary', style: { fontSize: 13 } }, noticePreviewRecord.time), + !noticePreviewRecord.read + ? React.createElement(Tag, { color: 'warning', style: { margin: 0 } }, '未读') + : React.createElement(Tag, { style: { margin: 0 } }, '已读') + ), + React.createElement('div', { className: 'workbench-notice-preview-summary' }, noticePreviewRecord.content), + React.createElement('div', { className: 'workbench-notice-preview-detail' }, + React.createElement('div', { className: 'workbench-notice-preview-detail-label' }, '详细说明'), + React.createElement('div', { className: 'workbench-notice-preview-detail-body' }, + noticePreviewRecord.detail || noticePreviewRecord.content + ) + ) + ) + : null + ), + + React.createElement(Modal, { + title: '自定义快速入口 · ' + (quickByRole[roleTab] ? quickByRole[roleTab].label : ''), + open: quickCustomizeOpen, + width: 640, + onCancel: function () { setQuickCustomizeOpen(false); }, + destroyOnClose: true, + footer: React.createElement(Space, { wrap: true }, + React.createElement(Button, { onClick: resetQuickCustomize }, '恢复默认'), + React.createElement(Button, { onClick: function () { setQuickCustomizeOpen(false); } }, '取消'), + React.createElement(Button, { type: 'primary', onClick: saveQuickCustomize }, '保存') + ) + }, + React.createElement(Text, { type: 'secondary', style: { display: 'block', marginBottom: 12, fontSize: 13, lineHeight: 1.5 } }, + '勾选要在工作台展示的功能,并可调整顺序。设置将保存在本浏览器,不同角色独立配置。' + ), + React.createElement('div', { className: 'workbench-quick-customize' }, + React.createElement('div', { className: 'workbench-quick-customize-col' }, + React.createElement('div', { className: 'workbench-quick-customize-title' }, '可选功能'), + React.createElement('div', { className: 'workbench-quick-customize-list' }, + quickCatalogItems.map(function (it) { + var key = quickItemKey(it); + var checked = quickCustomDraft.indexOf(key) >= 0; + return React.createElement('label', { + key: key, + className: 'workbench-quick-customize-option' + (checked ? ' is-checked' : '') + }, + React.createElement(Checkbox, { + checked: checked, + onChange: function (e) { + toggleQuickDraftKey(key, e.target.checked); + } + }), + React.createElement('span', { className: 'workbench-quick-customize-option-label' }, it.t) + ); + }) + ) + ), + React.createElement('div', { className: 'workbench-quick-customize-col' }, + React.createElement('div', { className: 'workbench-quick-customize-title' }, + '已选顺序 ', + React.createElement('span', { className: 'workbench-quick-customize-count' }, '(' + quickCustomDraft.length + ')') + ), + quickCustomDraft.length === 0 + ? React.createElement(Text, { type: 'secondary', style: { fontSize: 13 } }, '请从左侧勾选至少一项') + : React.createElement('div', { className: 'workbench-quick-customize-order' }, + quickCustomDraft.map(function (key, idx) { + var item = quickCatalogItems.find(function (it) { return quickItemKey(it) === key; }); + if (!item) return null; + return React.createElement('div', { key: key, className: 'workbench-quick-customize-order-item' }, + React.createElement('span', { className: 'workbench-quick-customize-order-index' }, idx + 1), + React.createElement('span', { className: 'workbench-quick-customize-order-label' }, item.t), + React.createElement(Space, { size: 0 }, + React.createElement(Button, { + type: 'text', + size: 'small', + disabled: idx === 0, + onClick: function () { moveQuickDraftKey(key, 'up'); } + }, '上移'), + React.createElement(Button, { + type: 'text', + size: 'small', + disabled: idx === quickCustomDraft.length - 1, + onClick: function () { moveQuickDraftKey(key, 'down'); } + }, '下移') + ) + ); + }) + ) + ) + ) + ), + React.createElement(Modal, { title: '超期未交车', open: overdueDeliveryModalOpen, diff --git a/src/prototypes/payment-records/annotation-source.json b/src/prototypes/payment-records/annotation-source.json index 867cdfb..f52d311 100644 --- a/src/prototypes/payment-records/annotation-source.json +++ b/src/prototypes/payment-records/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "payment-records", "pageId": "list", - "updatedAt": 1783328981488, + "updatedAt": 1783401000000, "nodes": [ { "id": "pr-filter", @@ -19,8 +19,8 @@ "fingerprint": "pr-filter", "path": [] }, - "aiPrompt": "客户、收款时间、操作人、未认领筛选。", - "annotationText": "默认展示 4 项筛选;支持重置与查询。", + "aiPrompt": "汇款账户名、公司主体、银行、收款时间、操作人、未认领筛选。", + "annotationText": "默认首行展示 4 项筛选(汇款账户名、公司主体、银行、收款时间);超过 1 行时其余项收入「更多筛选」。支持重置与查询。", "hasMarkdown": false, "color": "#2563eb", "images": [], @@ -40,7 +40,7 @@ "path": [] }, "aiPrompt": "新增、批量导入、导出与收款列表。", - "annotationText": "列表含收款时间、客户、金额、备注、关联账单、未认领金额、操作人、操作时间;操作列支持关联账单。", + "annotationText": "列表含收款时间、汇款账户名、公司主体、银行、金额、备注、关联账单、未认领金额、操作人、操作时间;操作列支持关联账单。", "hasMarkdown": false, "color": "#0f766e", "images": [], @@ -66,6 +66,26 @@ "images": [], "createdAt": 1740787200000, "updatedAt": 1740787200000 + }, + { + "id": "pr-add-modal", + "index": 4, + "title": "新增收款", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"pr-add-modal\"]" + ], + "fingerprint": "pr-add-modal", + "path": [] + }, + "aiPrompt": "新增收款表单字段与校验。", + "annotationText": "必填:收款时间、汇款账户名、金额;选填:公司主体、银行、备注。字段口径与列表列一致。", + "hasMarkdown": false, + "color": "#ea580c", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1783401200000 } ] }, diff --git a/src/prototypes/payment-records/components/AddPaymentModal.tsx b/src/prototypes/payment-records/components/AddPaymentModal.tsx index 91b803d..21f65b1 100644 --- a/src/prototypes/payment-records/components/AddPaymentModal.tsx +++ b/src/prototypes/payment-records/components/AddPaymentModal.tsx @@ -1,16 +1,21 @@ -import React, { useEffect, useState } from 'react'; -import { DatePicker, Input, InputNumber, Modal } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { DatePicker, Input, InputNumber, Modal, Select } from 'antd'; import dayjs from 'dayjs'; +import type { PaymentRecord } from '../types'; +import { buildBankOptions, buildCompanyEntityOptions } from '../utils/payment'; export interface AddPaymentFormValues { paymentTime: string; customerName: string; + companyEntity: string; + bank: string; amount: number; remark: string; } interface AddPaymentModalProps { open: boolean; + records: PaymentRecord[]; onClose: () => void; onConfirm: (values: AddPaymentFormValues) => void; } @@ -18,13 +23,18 @@ interface AddPaymentModalProps { const DEFAULT_FORM: AddPaymentFormValues = { paymentTime: '', customerName: '', + companyEntity: '', + bank: '', amount: 0, remark: '', }; -export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalProps) { +export function AddPaymentModal({ open, records, onClose, onConfirm }: AddPaymentModalProps) { const [form, setForm] = useState(DEFAULT_FORM); + const companyEntityOptions = useMemo(() => buildCompanyEntityOptions(records), [records]); + const bankOptions = useMemo(() => buildBankOptions(records), [records]); + useEffect(() => { if (!open) setForm(DEFAULT_FORM); }, [open]); @@ -34,6 +44,8 @@ export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalPro onConfirm({ paymentTime: form.paymentTime, customerName: form.customerName.trim(), + companyEntity: form.companyEntity.trim(), + bank: form.bank.trim(), amount: Number(form.amount), remark: form.remark.trim(), }); @@ -69,14 +81,44 @@ export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalPro /> + - - + +
+
+ {primary.map(renderField)} +
+ + {showExpand ? ( +
+
+
+ {expanded ? extra.map(renderField) : null} +
+
+
+ ) : null}
+
+ {showExpand ? ( + + ) : null}
diff --git a/src/prototypes/payment-records/components/PaymentKpiDetailModal.tsx b/src/prototypes/payment-records/components/PaymentKpiDetailModal.tsx index da818d3..ab61830 100644 --- a/src/prototypes/payment-records/components/PaymentKpiDetailModal.tsx +++ b/src/prototypes/payment-records/components/PaymentKpiDetailModal.tsx @@ -99,24 +99,24 @@ function ExportIcon() { const RECEIVED_COLUMNS: ColumnsType = [ { title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' }, { title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 220, ellipsis: true }, - { title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, + { title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, { title: '备注', dataIndex: 'remark', key: 'remark', width: 172, ellipsis: true }, ]; const LINKED_COLUMNS: ColumnsType = [ { title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' }, { title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 200, ellipsis: true }, - { title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, - { title: '认领金额', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, + { title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, + { title: '认领金额', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, { title: '备注', dataIndex: 'remark', key: 'remark', width: 160, ellipsis: true }, ]; const UNLINKED_COLUMNS: ColumnsType = [ { title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 148, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' }, { title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 168, ellipsis: true }, - { title: '到账金额', dataIndex: 'amount', key: 'amount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, - { title: '已认领', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, - { title: '未关联金额', dataIndex: 'unlinkedAmount', key: 'unlinkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: (v: number) => ( + { title: '到账金额', dataIndex: 'amount', key: 'amount', width: 100, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, + { title: '已认领', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 100, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney }, + { title: '未关联金额', dataIndex: 'unlinkedAmount', key: 'unlinkedAmount', width: 112, align: 'left', className: 'tabular-nums ln-modal-detail-table__money', render: (v: number) => ( 0 ? 'pr-unlinked--pending' : undefined}>{formatMoney(v)} ) }, { title: '备注', dataIndex: 'remark', key: 'remark', width: 140, ellipsis: true }, diff --git a/src/prototypes/payment-records/components/PaymentRecordsTable.tsx b/src/prototypes/payment-records/components/PaymentRecordsTable.tsx index 3339d22..bfa2e3e 100644 --- a/src/prototypes/payment-records/components/PaymentRecordsTable.tsx +++ b/src/prototypes/payment-records/components/PaymentRecordsTable.tsx @@ -47,20 +47,35 @@ export function PaymentRecordsTable({ render: (value: string) => value || '—', }, { - title: '客户名称', + title: '汇款账户名', dataIndex: 'customerName', key: 'customerName', width: 200, ellipsis: true, render: (value: string) => value || '—', }, + { + title: '公司主体', + dataIndex: 'companyEntity', + key: 'companyEntity', + width: 240, + ellipsis: true, + render: (value: string) => value || '—', + }, + { + title: '银行', + dataIndex: 'bank', + key: 'bank', + width: 180, + ellipsis: true, + render: (value: string) => value || '—', + }, { title: '金额', dataIndex: 'amount', key: 'amount', width: 120, - align: 'right', - className: 'tabular-nums', + className: 'tabular-nums ldb-col-money', render: (value: number) => formatMoney(value), }, { @@ -99,8 +114,7 @@ export function PaymentRecordsTable({ title: '未关联金额', key: 'unlinked', width: 120, - align: 'right', - className: 'tabular-nums', + className: 'tabular-nums ldb-col-money', render: (_, row) => { const unlinked = calcUnclaimedAmount(row); return ( diff --git a/src/prototypes/payment-records/data/records.json b/src/prototypes/payment-records/data/records.json index 2a20a16..a49666e 100644 --- a/src/prototypes/payment-records/data/records.json +++ b/src/prototypes/payment-records/data/records.json @@ -3,6 +3,8 @@ "id": "pr1", "paymentTime": "2026-01-19 14:30", "customerName": "安徽驰远供应链管理有限公司", + "companyEntity": "浙江羚牛氢能科技有限公司", + "bank": "中国工商银行嘉兴分行", "amount": 5700, "remark": "银行转账", "linkedBills": [ @@ -17,6 +19,8 @@ "id": "pr2", "paymentTime": "2026-02-02 10:00", "customerName": "嘉兴港区韵达快递有限公司", + "companyEntity": "浙江羚牛氢能科技有限公司", + "bank": "招商银行嘉兴分行", "amount": 3500, "remark": "分两笔到账", "linkedBills": [ @@ -29,6 +33,8 @@ "id": "pr3", "paymentTime": "2026-02-02 16:20", "customerName": "嘉兴港区韵达快递有限公司", + "companyEntity": "浙江羚牛氢能科技有限公司", + "bank": "招商银行嘉兴分行", "amount": 1500, "remark": "第二笔到账", "linkedBills": [], @@ -39,6 +45,8 @@ "id": "pr4", "paymentTime": "2025-12-25 09:15", "customerName": "上海朔宇物流有限公司", + "companyEntity": "羚牛氢能科技(广东)有限公司", + "bank": "中国建设银行上海分行", "amount": 2500, "remark": "部分认领", "linkedBills": [ @@ -52,6 +60,8 @@ "id": "pr5", "paymentTime": "2025-12-26 15:40", "customerName": "上海利合供应链管理有限公司", + "companyEntity": "羚牛氢能科技(广东)有限公司", + "bank": "中国银行上海分行", "amount": 4000, "remark": "全额到账", "linkedBills": [ @@ -64,6 +74,8 @@ "id": "pr6", "paymentTime": "2026-01-08 11:20", "customerName": "苏州望亭远方物流有限公司", + "companyEntity": "广州开发区交投氢能运营管理有限公司", + "bank": "中国农业银行苏州分行", "amount": 1800, "remark": "待关联氢费账单", "linkedBills": [], diff --git a/src/prototypes/payment-records/index.tsx b/src/prototypes/payment-records/index.tsx index d21e78b..f5a194f 100644 --- a/src/prototypes/payment-records/index.tsx +++ b/src/prototypes/payment-records/index.tsx @@ -176,6 +176,8 @@ function PaymentRecordsContent() { id: `pr-${Date.now()}`, paymentTime: values.paymentTime.length === 16 ? `${values.paymentTime}:00` : values.paymentTime, customerName: values.customerName, + companyEntity: values.companyEntity, + bank: values.bank, amount: values.amount, remark: values.remark, linkedBills: [], @@ -294,6 +296,7 @@ function PaymentRecordsContent() { setAddOpen(false)} onConfirm={handleAdd} /> diff --git a/src/prototypes/payment-records/styles/index.css b/src/prototypes/payment-records/styles/index.css index 4d4bced..e37b922 100644 --- a/src/prototypes/payment-records/styles/index.css +++ b/src/prototypes/payment-records/styles/index.css @@ -440,7 +440,7 @@ .vm-page.lc-page.pr-page .pr-linked-bill-native-table__money { width: 15%; - text-align: right; + text-align: left; white-space: nowrap; font-size: 11px; } diff --git a/src/prototypes/payment-records/types.ts b/src/prototypes/payment-records/types.ts index 414be43..04dca86 100644 --- a/src/prototypes/payment-records/types.ts +++ b/src/prototypes/payment-records/types.ts @@ -19,6 +19,8 @@ export interface PaymentRecord { id: string; paymentTime: string; customerName: string; + companyEntity: string; + bank: string; amount: number; remark: string; linkedBills: LinkedBill[]; @@ -40,6 +42,8 @@ export interface BillCandidate { export interface PaymentRecordFilters { customerName: string; + companyEntity: string; + bank: string; paymentFrom: string; paymentTo: string; operatorName: string; @@ -48,6 +52,8 @@ export interface PaymentRecordFilters { export const EMPTY_PAYMENT_FILTERS: PaymentRecordFilters = { customerName: '', + companyEntity: '', + bank: '', paymentFrom: '', paymentTo: '', operatorName: '', diff --git a/src/prototypes/payment-records/utils/payment.ts b/src/prototypes/payment-records/utils/payment.ts index 9369067..f4b631f 100644 --- a/src/prototypes/payment-records/utils/payment.ts +++ b/src/prototypes/payment-records/utils/payment.ts @@ -37,6 +37,12 @@ export function applyPaymentFilters( if (filters.customerName.trim()) { if (row.customerName !== filters.customerName.trim()) return false; } + if (filters.companyEntity.trim()) { + if (row.companyEntity !== filters.companyEntity.trim()) return false; + } + if (filters.bank.trim()) { + if (row.bank !== filters.bank.trim()) return false; + } if (filters.operatorName.trim()) { if (row.operatorName !== filters.operatorName.trim()) return false; } @@ -65,6 +71,23 @@ export function buildCustomerOptions(records: PaymentRecord[]): Array<{ value: s return Array.from(set).sort().map((name) => ({ value: name, label: name })); } +function buildFieldOptions(records: PaymentRecord[], field: 'companyEntity' | 'bank'): Array<{ value: string; label: string }> { + const set = new Set(); + records.forEach((row) => { + const value = row[field]; + if (value) set.add(value); + }); + return Array.from(set).sort().map((name) => ({ value: name, label: name })); +} + +export function buildCompanyEntityOptions(records: PaymentRecord[]): Array<{ value: string; label: string }> { + return buildFieldOptions(records, 'companyEntity'); +} + +export function buildBankOptions(records: PaymentRecord[]): Array<{ value: string; label: string }> { + return buildFieldOptions(records, 'bank'); +} + export function sumPaymentAmount(records: PaymentRecord[]): number { return records.reduce((sum, row) => sum + (row.amount || 0), 0); } diff --git a/src/prototypes/self-operated-business-ledger/annotation-source.json b/src/prototypes/self-operated-business-ledger/annotation-source.json index 3e39821..9f5fdae 100644 --- a/src/prototypes/self-operated-business-ledger/annotation-source.json +++ b/src/prototypes/self-operated-business-ledger/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "self-operated-business-ledger", "pageId": "list", - "updatedAt": 1783328981489, + "updatedAt": 1783352657926, "nodes": [ { "id": "sob-filter", diff --git a/src/prototypes/self-operated-business-ledger/components/FilterPanel.tsx b/src/prototypes/self-operated-business-ledger/components/FilterPanel.tsx index c3d1f0c..fd225cc 100644 --- a/src/prototypes/self-operated-business-ledger/components/FilterPanel.tsx +++ b/src/prototypes/self-operated-business-ledger/components/FilterPanel.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import { ChevronDown, Search } from 'lucide-react'; import type { SelfOperatedFilters, SelfOperatedLedgerRow } from '../types'; import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField'; +import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField'; import { MultiSelectField } from '../../customer-management/components/MultiSelectField'; import { BRAND_MODEL_MAP, UNDERTAKER_OPTIONS, buildSelfOptions } from '../utils/ledger'; @@ -34,11 +35,14 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F { label: '出车日期', control: ( -
- onChange({ dispatchFrom: e.target.value })} /> - - onChange({ dispatchTo: e.target.value })} /> -
+ onChange({ dispatchFrom: startDate, dispatchTo: endDate })} + /> ), }, { label: '业务名称', control: onChange({ businessName })} /> }, @@ -91,33 +95,45 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F return (
-

自营业务台账

+

筛选条件

-
- {primaryFields.map((field) => ( - - ))} -
- {extraFields.length > 0 && ( -
-
-
- {extraFields.map((field) => ( - - ))} + +
+
+ {primaryFields.map((field) => ( + + ))} +
+ + {extraFields.length > 0 && ( +
+
+
+ {expanded + ? extraFields.map((field) => ( + + )) + : null} +
-
- )} -
+ )} +
+ +
{extraFields.length > 0 && ( - diff --git a/src/prototypes/self-operated-business-ledger/components/SelfLedgerTable.tsx b/src/prototypes/self-operated-business-ledger/components/SelfLedgerTable.tsx index c6beb57..ad270ff 100644 --- a/src/prototypes/self-operated-business-ledger/components/SelfLedgerTable.tsx +++ b/src/prototypes/self-operated-business-ledger/components/SelfLedgerTable.tsx @@ -98,6 +98,14 @@ export function SelfLedgerTable({ }, [records, sortKey, sortDir]); const allSelected = sortedRecords.length > 0 && sortedRecords.every((r) => selectedIds.has(r.id)); + const someSelected = sortedRecords.some((r) => selectedIds.has(r.id)) && !allSelected; + const headerCheckRef = useRef(null); + + useEffect(() => { + if (headerCheckRef.current) { + headerCheckRef.current.indeterminate = someSelected; + } + }, [someSelected]); const handleSort = useCallback((key: string) => { setSortKey((prev) => { @@ -168,7 +176,13 @@ export function SelfLedgerTable({ - onToggleAll(e.target.checked)} aria-label="全选" /> + onToggleAll(e.target.checked)} + aria-label="全选" + /> {SELF_TABLE_COLUMNS.map((col) => ( setDeleteTarget(row)} />
- 共 {filtered.length} 条
showToast(`删除:${record.supplierName}(原型演示)`)} />
-
共 {filtered.length} 条
1 ? parts[parts.length - 1] : ''; + } + + function getAttachmentKind(name, mime) { + var ext = getFileExt(name); + if (ATTACHMENT_EXT_KIND[ext]) return ATTACHMENT_EXT_KIND[ext]; + var m = String(mime == null ? '' : mime).toLowerCase(); + if (m.indexOf('image/') === 0) return 'image'; + if (m.indexOf('pdf') >= 0) return 'pdf'; + if (m.indexOf('word') >= 0 || m.indexOf('document') >= 0) return 'word'; + if (m.indexOf('sheet') >= 0 || m.indexOf('excel') >= 0 || m === 'text/csv') return 'excel'; + return 'other'; + } + + function isAttachmentAllowed(file) { + if (!file) return false; + return getAttachmentKind(file.name, file.type) !== 'other'; + } + + function formatFileSize(bytes) { + var n = Number(bytes) || 0; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / (1024 * 1024)).toFixed(1) + ' MB'; + } + + function buildAttachmentFromFile(file) { + var url = null; + try { + url = URL.createObjectURL(file); + } catch (eUrl) {} + return { + id: 'att-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8), + name: file.name, + size: file.size || 0, + type: file.type || '', + kind: getAttachmentKind(file.name, file.type), + url: url, + uploadedAt: new Date().toISOString() + }; + } + + function formatAttachmentListSummary(list) { + if (!list || !list.length) return '—'; + if (list.length === 1) return list[0].name; + return list.length + ' 个附件'; + } + + function attachmentNamesSignature(list) { + return (list || []) + .map(function (a) { return a && a.name; }) + .filter(Boolean) + .join('|'); + } + + var CURRENT_USER = { id: 'u_zhang', name: '张运维', role: 'staff' }; + + var SUBMIT_CONFIRM_TEXT = '提交后数据将无法修改,如需修改请联系运维主管进行修改'; + + var RECORD_TYPE_OPTIONS = [ + { value: '维修', label: '维修' }, + { value: '保养', label: '保养' } + ]; + + var IMPORT_RECORD_TYPE_ENUM = RECORD_TYPE_OPTIONS.map(function (o) { + return o.value; + }); + + var BEAR_BY_OPTIONS = [ + { value: '客户承担', label: '客户承担' }, + { value: '我司承担', label: '我司承担' } + ]; + + var IMPORT_TEMPLATE_HEADERS = [ + '进修日期', + '车牌号', + '类型', + '项目/材料', + '人工费单位', + '人工费数量', + '人工费单价(含税)', + '配件费单位', + '配件费数量', + '配件费单价(含税)', + '承担方式' + ]; + + var IMPORT_HEADER_MAP = { + '进修日期': 'repairDate', + '车牌号': 'plateNo', + '类型': 'recordType', + '项目/材料': 'itemDesc', + '人工费单位': 'laborUnit', + '人工费数量': 'laborQty', + '人工费单价(含税)': 'laborUnitPrice', + '配件费单位': 'partsUnit', + '配件费数量': 'partsQty', + '配件费单价(含税)': 'partsUnitPrice', + '承担方式': 'bearBy' + }; + + var IMPORT_TEMPLATE_SAMPLE = [ + ['2025-06-01', '沪A12345', '维修', '更换空压机滤芯', '工时', '2', '150', '件', '1', '320', '客户承担'] + ]; + + function buildImportTemplateAoa() { + var typeColIdx = IMPORT_TEMPLATE_HEADERS.indexOf('类型'); + var enumRows = IMPORT_RECORD_TYPE_ENUM.map(function (label, idx) { + var row = []; + var ci; + for (ci = 0; ci < IMPORT_TEMPLATE_HEADERS.length; ci++) { + row.push(ci === typeColIdx ? String(idx + 1) + '.' + label : ''); + } + return row; + }); + return [IMPORT_TEMPLATE_HEADERS].concat(enumRows).concat(IMPORT_TEMPLATE_SAMPLE); + } + + function isImportTemplateMetaRow(cells) { + var nonEmpty = 0; + var typeEnumLike = false; + var ci; + for (ci = 0; ci < (cells || []).length; ci++) { + var v = String(cells[ci] == null ? '' : cells[ci]).trim(); + if (!v) continue; + nonEmpty++; + if (/^[12][\.、.]?\s*(维修|保养)$/.test(v)) typeEnumLike = true; + } + if (!nonEmpty) return true; + return typeEnumLike && nonEmpty <= 2; + } + + function importParsedRowToLine(row) { + return [ + row.repairDate, + row.plateNo, + row.recordType, + row.itemDesc, + row.laborUnit, + row.laborQty, + row.laborUnitPrice, + row.partsUnit, + row.partsQty, + row.partsUnitPrice, + row.bearBy + ].map(function (v) { + return v == null ? '' : v; + }); + } + + var EXPORT_HEADERS = IMPORT_TEMPLATE_HEADERS.concat([ + '人工费金额(含税)', + '配件费金额(含税)', + '费用总计(含税)', + '提报人', + '提交状态', + '附件' + ]); + + var CHANGE_LOG_FIELD_LABELS = { + repairDate: '进修日期', + plateNo: '车牌号', + recordType: '类型', + itemDesc: '项目/材料', + laborUnit: '人工费单位', + laborQty: '人工费数量', + laborUnitPrice: '人工费单价(含税)', + partsUnit: '配件费单位', + partsQty: '配件费数量', + partsUnitPrice: '配件费单价(含税)', + bearBy: '承担方式', + attachments: '附件', + submitStatus: '提交状态', + saveStatus: '保存状态', + _copy: '复制新增', + _create: '新增记录' + }; + + /** + * 系统全部车辆车牌(原型枚举,与车辆管理模块一致;联调后由车辆清单接口加载) + */ + var SYSTEM_VEHICLE_PLATES = [ + '京C12345', + '京CN88771F', + '京E88888', + '京F33333', + '京G12345', + '京H88888', + '川AL55602F', + '沪A12345', + '沪AD12345F', + '沪B99999', + '沪C11111', + '沪D66666', + '浙AD12345F', + '浙AH55660F', + '浙BK33210F', + '苏EF99887F', + '粤A11B22', + '粤A12345', + '粤A66D66', + '粤A77F77', + '粤A88K88', + '粤A99A99', + '粤B12345', + '粤B22C33', + '粤B44E44', + '粤B55B55', + '粤B67890', + '粤BK33210F' + ]; + + function normPlateKey(plate) { + return String(plate == null ? '' : plate) + .replace(/[\s\-·]/g, '') + .toUpperCase(); + } + + function isPlateInVehicleTable(plate) { + if (!plate) return false; + var key = normPlateKey(plate); + for (var i = 0; i < SYSTEM_VEHICLE_PLATES.length; i++) { + if (normPlateKey(SYSTEM_VEHICLE_PLATES[i]) === key) return true; + } + return false; + } + + function isEmptyStr(v) { + return !String(v == null ? '' : v).trim(); + } + + function isEmptyNum(v) { + return v === null || v === undefined || v === ''; + } + + function getRowInvalidMap(row) { + var inv = {}; + if (!row.repairDate) inv.repairDate = true; + if (!row.plateNo) inv.plateNo = 'empty'; + else if (!isPlateInVehicleTable(row.plateNo)) inv.plateNo = 'not_exist'; + if (isEmptyStr(row.recordType)) inv.recordType = true; + if (isEmptyStr(row.itemDesc)) inv.itemDesc = true; + if (isEmptyStr(row.laborUnit)) inv.laborUnit = true; + if (isEmptyNum(row.laborQty)) inv.laborQty = true; + if (isEmptyNum(row.laborUnitPrice)) inv.laborUnitPrice = true; + if (isEmptyStr(row.partsUnit)) inv.partsUnit = true; + if (isEmptyNum(row.partsQty)) inv.partsQty = true; + if (isEmptyNum(row.partsUnitPrice)) inv.partsUnitPrice = true; + if (isEmptyStr(row.bearBy)) inv.bearBy = true; + return inv; + } + + /** 列表展示顺序:0 已确认提交 → 1 已保存 → 2 新增未保存 */ + function rowDisplaySortTier(row) { + if (row && row.submitStatus === 'submitted') return 0; + if (row && row.saveStatus === 'saved') return 1; + return 2; + } + + function rowStatusLabel(row) { + if (row && row.submitStatus === 'submitted') return '已提交'; + if (row && row.saveStatus === 'saved') return '已保存'; + return '待保存'; + } + + function isSavedDraftRow(row) { + return row && row.submitStatus !== 'submitted' && row.saveStatus === 'saved'; + } + + /** 状态为「已保存」:仅本人草稿在操作列展示「编辑」「删除」(主管不处理未提交草稿) */ + function canShowSavedActions(row, isSupervisorRole) { + if (!isSavedDraftRow(row)) return false; + if (isSupervisorRole) return false; + return row.createdBy === CURRENT_USER.id; + } + + /** 状态为「已提交」:仅运维主管在操作列展示「编辑」「删除」 */ + function canShowSubmittedActions(row, isSupervisorRole) { + return !!isSupervisorRole && row && row.submitStatus === 'submitted'; + } + + function canDeleteRow(row, isSupervisorRole) { + if (row && row.submitStatus === 'submitted') { + return !!isSupervisorRole; + } + return row && row.createdBy === CURRENT_USER.id; + } + + /** 已提交不可复制;仅本人未提交记录可复制 */ + function canCopyRow(row, isSupervisorRole) { + if (!row || row.submitStatus === 'submitted' || isSupervisorRole) return false; + return row.createdBy === CURRENT_USER.id; + } + + /** 本人未提交记录可随时补传附件;已提交仅主管进入编辑后可管理 */ + function canManageAttachments(row, isSupervisorRole, editingKeysArr) { + if (!row) return false; + if (row.submitStatus === 'submitted') { + if (!isSupervisorRole) return false; + return editingKeysArr.indexOf(row.key) >= 0; + } + if (isSupervisorRole) return false; + return row.createdBy === CURRENT_USER.id; + } + + function cloneRowTimeValue(t) { + if (!t) return t; + try { + if (window.dayjs && window.dayjs.isDayjs && window.dayjs.isDayjs(t)) return t.clone(); + if (window.dayjs) { + var d = window.dayjs(t); + return d.isValid() ? d : t; + } + } catch (eClone) {} + return t; + } + + function buildCopiedRow(sourceRow) { + var now = nowDayjs(); + return recalcRow({ + key: nextRowKey(), + createdBy: CURRENT_USER.id, + reporterName: CURRENT_USER.name, + repairDate: cloneRowTimeValue(sourceRow.repairDate), + plateNo: sourceRow.plateNo, + reportTime: now, + recordType: sourceRow.recordType, + bearBy: sourceRow.bearBy, + itemDesc: sourceRow.itemDesc || '', + laborUnit: sourceRow.laborUnit || '', + laborQty: sourceRow.laborQty, + laborUnitPrice: sourceRow.laborUnitPrice, + laborAmount: 0, + partsUnit: sourceRow.partsUnit || '', + partsQty: sourceRow.partsQty, + partsUnitPrice: sourceRow.partsUnitPrice, + partsAmount: 0, + totalCost: 0, + attachments: [], + submitStatus: 'draft', + saveStatus: 'unsaved', + submittedAt: null + }); + } + + function valuesEqualForLog(field, a, b) { + if (a === b) return true; + if ((a == null || a === '') && (b == null || b === '')) return true; + if (field === 'repairDate' && window.dayjs) { + try { + return window.dayjs(a).valueOf() === window.dayjs(b).valueOf(); + } catch (eLog) { + return false; + } + } + if ( + field === 'laborQty' || + field === 'laborUnitPrice' || + field === 'partsQty' || + field === 'partsUnitPrice' + ) { + if (isEmptyNum(a) && isEmptyNum(b)) return true; + if (field === 'laborQty' || field === 'partsQty') return toIntQty(a) === toIntQty(b); + return roundMoney(a) === roundMoney(b); + } + if (field === 'attachments') { + return attachmentNamesSignature(a) === attachmentNamesSignature(b); + } + return String(a) === String(b); + } + + function formatLogValue(field, value, rowCtx) { + if (value == null || value === '') return '—'; + if (field === 'repairDate') return formatDate(value) || formatDateTime(value) || String(value); + if (field === 'submitStatus') { + if (value === 'submitted') return '已提交'; + return '未提交'; + } + if (field === 'saveStatus') { + if (value === 'saved') return '已保存'; + return '待保存'; + } + if ( + field === 'laborQty' || + field === 'partsQty' + ) { + return isEmptyNum(value) ? '—' : String(toIntQty(value)); + } + if (field === 'laborUnitPrice' || field === 'partsUnitPrice') { + return isEmptyNum(value) ? '—' : fmtMoney(value); + } + if (field === 'attachments') { + return formatAttachmentListSummary(value); + } + return String(value); + } + + function rowTimeValue(row, field) { + var v = row && row[field]; + if (!v || !window.dayjs) return 0; + try { + return window.dayjs(v).valueOf(); + } catch (e1) { + return 0; + } + } + + function sortRowsWithinTier(rows) { + return rows.slice().sort(function (a, b) { + var ta = rowTimeValue(a, 'submittedAt') || rowTimeValue(a, 'reportTime'); + var tb = rowTimeValue(b, 'submittedAt') || rowTimeValue(b, 'reportTime'); + if (tb !== ta) return tb - ta; + return String(a.key || '').localeCompare(String(b.key || '')); + }); + } + + /** 分区合并,避免同层数据按日期与另一层穿插 */ + function sortRowsByDisplayTier(rows) { + var submitted = []; + var saved = []; + var unsaved = []; + (rows || []).forEach(function (r) { + var tier = rowDisplaySortTier(r); + if (tier === 0) submitted.push(r); + else if (tier === 1) saved.push(r); + else unsaved.push(r); + }); + return sortRowsWithinTier(submitted).concat(sortRowsWithinTier(saved)).concat(sortRowsWithinTier(unsaved)); + } + + function firstInvalidMessage(row, inv, seq) { + var prefix = '第' + seq + '行:'; + if (inv.repairDate) return prefix + '请填写进修日期'; + if (inv.plateNo === 'empty') return prefix + '请填写车牌号'; + if (inv.plateNo === 'not_exist') return prefix + '车辆不存在'; + if (inv.recordType) return prefix + '请选择类型'; + if (inv.itemDesc) return prefix + '请填写项目/材料'; + if (inv.laborUnit) return prefix + '请填写人工费单位'; + if (inv.laborQty) return prefix + '请填写人工费数量'; + if (inv.laborUnitPrice) return prefix + '请填写人工费单价(含税)'; + if (inv.partsUnit) return prefix + '请填写配件费单位'; + if (inv.partsQty) return prefix + '请填写配件费数量'; + if (inv.partsUnitPrice) return prefix + '请填写配件费单价(含税)'; + return prefix + '请完善必填项'; + } + + var OTHER_REPORTERS = [ + { id: 'u_zhou', name: '周敏' }, + { id: 'u_li_h', name: '李航' }, + { id: 'u_wang', name: '王磊' } + ]; + + var rowIdSeed = 0; + function nextRowKey() { + rowIdSeed += 1; + return 'mr-' + Date.now() + '-' + rowIdSeed; + } + + function filterOption(input, option) { + var label = (option && (option.label || option.children)) || ''; + return String(label).toLowerCase().indexOf(String(input || '').toLowerCase()) >= 0; + } + + /** 车牌号下拉:支持输入模糊匹配(忽略空格、大小写) */ + function filterPlateOption(input, option) { + var q = normPlateKey(input); + if (!q) return true; + var label = normPlateKey(option && (option.label != null ? option.label : option.value)); + return label.indexOf(q) >= 0; + } + + function numOrZero(v) { + if (v === null || v === undefined || v === '') return 0; + var n = Number(v); + return isNaN(n) ? 0 : n; + } + + function roundMoney(n) { + return Math.round(numOrZero(n) * 100) / 100; + } + + function toIntQty(v) { + if (v === null || v === undefined || v === '') return 0; + var n = Math.floor(Number(v)); + return isNaN(n) || n < 0 ? 0 : n; + } + + function fmtMoney(n) { + if (n === null || n === undefined || n === '') return '0.00'; + return roundMoney(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + function nowDayjs() { + try { + if (window.dayjs) return window.dayjs(); + } catch (e1) {} + return null; + } + + function formatDate(d) { + if (!d || !window.dayjs) return ''; + try { + return window.dayjs(d).format('YYYY-MM-DD'); + } catch (e2) { + return ''; + } + } + + function formatDateTime(d) { + if (!d || !window.dayjs) return ''; + try { + return window.dayjs(d).format('YYYY-MM-DD HH:mm'); + } catch (e3) { + return ''; + } + } + + function downloadWorkbookFile(filename, sheetName, aoa) { + var XLSX = window.XLSX; + if (!XLSX) { + message.error('导出组件未加载,请刷新页面后重试'); + return false; + } + var ws = XLSX.utils.aoa_to_sheet(aoa); + var wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, sheetName || '数据'); + XLSX.writeFile(wb, filename); + return true; + } + + function parseCsvLine(line) { + var out = []; + var cur = ''; + var inQuote = false; + var i; + for (i = 0; i < line.length; i++) { + var ch = line.charAt(i); + if (inQuote) { + if (ch === '"') { + if (line.charAt(i + 1) === '"') { + cur += '"'; + i++; + } else { + inQuote = false; + } + } else { + cur += ch; + } + } else if (ch === '"') { + inQuote = true; + } else if (ch === ',') { + out.push(cur.trim()); + cur = ''; + } else { + cur += ch; + } + } + out.push(cur.trim()); + return out; + } + + function csvTextToAoa(text) { + var raw = String(text || '').replace(/^\uFEFF/, ''); + var lines = raw.split(/\r?\n/).filter(function (ln) { return ln.trim().length > 0; }); + return lines.map(parseCsvLine); + } + + function normalizeHeaderLabel(h) { + return String(h == null ? '' : h).trim().replace(/^\uFEFF/, ''); + } + + function buildImportFieldIndex(headers) { + var fieldIdx = {}; + var hi; + for (hi = 0; hi < headers.length; hi++) { + var key = IMPORT_HEADER_MAP[normalizeHeaderLabel(headers[hi])]; + if (key && fieldIdx[key] == null) fieldIdx[key] = hi; + } + return fieldIdx; + } + + function parseImportDate(val) { + if (val == null || val === '') return null; + if (window.dayjs) { + if (typeof val === 'number' && window.XLSX && window.XLSX.SSF) { + try { + var parsed = window.XLSX.SSF.parse_date_code(val); + if (parsed) { + var excelDate = window.dayjs(new Date(parsed.y, parsed.m - 1, parsed.d)); + if (excelDate.isValid()) return excelDate; + } + } catch (eExcel) {} + } + var d = window.dayjs(val); + if (d.isValid()) return d; + } + return null; + } + + function parseImportNum(val) { + if (val == null || val === '') return null; + var n = Number(String(val).replace(/,/g, '').trim()); + return isNaN(n) ? null : n; + } + + function parseImportSheetAoa(aoa) { + if (!aoa || aoa.length < 2) { + return { rows: [], headerError: '文件至少需要表头行与一行数据' }; + } + var headers = (aoa[0] || []).map(normalizeHeaderLabel); + var fieldIdx = buildImportFieldIndex(headers); + if (fieldIdx.plateNo == null) { + return { rows: [], headerError: '缺少必填列「车牌号」,请使用系统提供的导入模板' }; + } + var rows = []; + var li; + for (li = 1; li < aoa.length; li++) { + var cells = aoa[li] || []; + var hasValue = false; + var ci; + for (ci = 0; ci < cells.length; ci++) { + if (String(cells[ci] == null ? '' : cells[ci]).trim()) { + hasValue = true; + break; + } + } + if (!hasValue) continue; + if (isImportTemplateMetaRow(cells)) continue; + var get = function (field) { + var idx = fieldIdx[field]; + return idx == null || idx < 0 ? '' : String(cells[idx] == null ? '' : cells[idx]).trim(); + }; + rows.push({ + lineNo: li + 1, + repairDate: get('repairDate'), + plateNo: get('plateNo'), + recordType: get('recordType'), + itemDesc: get('itemDesc'), + laborUnit: get('laborUnit'), + laborQty: get('laborQty'), + laborUnitPrice: get('laborUnitPrice'), + partsUnit: get('partsUnit'), + partsQty: get('partsQty'), + partsUnitPrice: get('partsUnitPrice'), + bearBy: get('bearBy') + }); + } + return { rows: rows, headerError: null }; + } + + function validateImportMaintenanceRow(row) { + var errors = []; + if (!parseImportDate(row.repairDate)) errors.push('进修日期无效'); + if (!row.plateNo) errors.push('车牌号不能为空'); + else if (!isPlateInVehicleTable(row.plateNo)) errors.push('车牌号不在车辆清单中'); + if (!row.recordType) errors.push('类型不能为空'); + else if (RECORD_TYPE_OPTIONS.every(function (o) { return o.value !== row.recordType; })) { + errors.push('类型须为「维修」或「保养」'); + } + if (!row.itemDesc) errors.push('项目/材料不能为空'); + if (!row.laborUnit) errors.push('人工费单位不能为空'); + if (row.laborQty === '' || row.laborQty == null) errors.push('人工费数量不能为空'); + else if (parseImportNum(row.laborQty) == null) errors.push('人工费数量须为数字'); + if (row.laborUnitPrice === '' || row.laborUnitPrice == null) errors.push('人工费单价不能为空'); + else if (parseImportNum(row.laborUnitPrice) == null) errors.push('人工费单价须为数字'); + if (!row.partsUnit) errors.push('配件费单位不能为空'); + if (row.partsQty === '' || row.partsQty == null) errors.push('配件费数量不能为空'); + else if (parseImportNum(row.partsQty) == null) errors.push('配件费数量须为整数'); + if (row.partsUnitPrice === '' || row.partsUnitPrice == null) errors.push('配件费单价不能为空'); + else if (parseImportNum(row.partsUnitPrice) == null) errors.push('配件费单价须为数字'); + if (!row.bearBy) errors.push('承担方式不能为空'); + else if (BEAR_BY_OPTIONS.every(function (o) { return o.value !== row.bearBy; })) { + errors.push('承担方式须为「客户承担」或「我司承担」'); + } + return errors; + } + + function importRowToMaintenanceRow(row) { + var now = nowDayjs(); + return recalcRow({ + key: nextRowKey(), + createdBy: CURRENT_USER.id, + reporterName: CURRENT_USER.name, + repairDate: parseImportDate(row.repairDate), + plateNo: row.plateNo, + reportTime: now, + recordType: row.recordType, + bearBy: row.bearBy, + itemDesc: row.itemDesc, + laborUnit: row.laborUnit, + laborQty: toIntQty(parseImportNum(row.laborQty)), + laborUnitPrice: parseImportNum(row.laborUnitPrice), + laborAmount: 0, + partsUnit: row.partsUnit, + partsQty: toIntQty(parseImportNum(row.partsQty)), + partsUnitPrice: parseImportNum(row.partsUnitPrice), + partsAmount: 0, + totalCost: 0, + attachments: [], + submitStatus: 'draft', + saveStatus: 'unsaved', + submittedAt: null + }); + } + + function rowToExportLine(row) { + var attachNames = (row.attachments || []) + .map(function (a) { return a && a.name; }) + .filter(Boolean) + .join(';'); + return [ + formatDate(row.repairDate), + row.plateNo || '', + row.recordType || '', + row.itemDesc || '', + row.laborUnit || '', + row.laborQty == null ? '' : String(toIntQty(row.laborQty)), + row.laborUnitPrice == null ? '' : roundMoney(row.laborUnitPrice), + row.partsUnit || '', + row.partsQty == null ? '' : String(toIntQty(row.partsQty)), + row.partsUnitPrice == null ? '' : roundMoney(row.partsUnitPrice), + row.bearBy || '', + roundMoney(row.laborAmount), + roundMoney(row.partsAmount), + roundMoney(row.totalCost), + row.reporterName || '', + row.submitStatus === 'submitted' ? '已提交' : '未提交', + attachNames + ]; + } + + function recalcRow(row) { + var laborQty = toIntQty(row.laborQty); + var partsQty = toIntQty(row.partsQty); + var laborAmt = roundMoney(laborQty * numOrZero(row.laborUnitPrice)); + var partsAmt = roundMoney(partsQty * numOrZero(row.partsUnitPrice)); + var total = roundMoney(laborAmt + partsAmt); + return Object.assign({}, row, { + laborQty: laborQty, + partsQty: partsQty, + laborAmount: laborAmt, + partsAmount: partsAmt, + totalCost: total + }); + } + + function buildEmptyRow(user) { + var now = nowDayjs(); + return recalcRow({ + key: nextRowKey(), + createdBy: user.id, + reporterName: user.name, + repairDate: now, + plateNo: undefined, + reportTime: now, + recordType: undefined, + bearBy: undefined, + itemDesc: '', + laborUnit: '', + laborQty: null, + laborUnitPrice: null, + laborAmount: 0, + partsUnit: '', + partsQty: null, + partsUnitPrice: null, + partsAmount: 0, + totalCost: 0, + attachments: [], + submitStatus: 'draft', + saveStatus: 'unsaved' + }); + } + + function buildMockAttachments(i) { + if (i === 0) { + return [ + { id: 'mock-att-1', name: '维修工单.pdf', size: 102400, kind: 'pdf', type: 'application/pdf' }, + { id: 'mock-att-2', name: '现场照片.jpg', size: 356000, kind: 'image', type: 'image/jpeg' } + ]; + } + if (i === 1) { + return [{ id: 'mock-att-3', name: '费用明细.xlsx', size: 45000, kind: 'excel', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }]; + } + return []; + } + + function buildMockAllRows() { + var plates = SYSTEM_VEHICLE_PLATES; + var items = ['更换空压机滤芯', '制动片检修', '冷却液补充', '高压线束检查', '轮胎换位']; + var rows = []; + var reporters = [{ id: CURRENT_USER.id, name: CURRENT_USER.name }].concat(OTHER_REPORTERS); + var i; + for (i = 0; i < 8; i++) { + var rep = reporters[i % reporters.length]; + var submitStatus = 'draft'; + var saveStatus = 'unsaved'; + if (rep.id === CURRENT_USER.id) { + if (i < 3) { + submitStatus = 'submitted'; + saveStatus = 'saved'; + } else if (i === 3) { + saveStatus = 'saved'; + } + } else { + saveStatus = 'saved'; + } + var laborQty = 1 + (i % 4); + var laborPrice = 120 + i * 15; + var partsQty = i % 2 === 0 ? 2 : 0; + var partsPrice = 80 + i * 20; + var d = nowDayjs(); + var repairDay = d && d.subtract ? d.subtract(i, 'day') : d; + rows.push( + recalcRow({ + key: 'mock-' + (i + 1), + createdBy: rep.id, + reporterName: rep.name, + repairDate: repairDay, + plateNo: plates[i % plates.length], + reportTime: repairDay, + recordType: i % 3 === 0 ? '保养' : '维修', + bearBy: i % 2 === 0 ? '客户承担' : '我司承担', + itemDesc: items[i % items.length], + laborUnit: '工时', + laborQty: laborQty, + laborUnitPrice: laborPrice, + laborAmount: 0, + partsUnit: '件', + partsQty: partsQty, + partsUnitPrice: partsPrice, + partsAmount: 0, + totalCost: 0, + attachments: buildMockAttachments(i), + submitStatus: submitStatus, + saveStatus: saveStatus, + submittedAt: submitStatus === 'submitted' ? repairDay : null + }) + ); + } + return rows; + } + + var rowsState = useState(buildMockAllRows); + var allRows = rowsState[0]; + var setAllRows = rowsState[1]; + + var vehiclePlateOptions = useMemo(function () { + return SYSTEM_VEHICLE_PLATES.map(function (p) { + return { value: p, label: p }; + }); + }, []); + + var plateDraftState = useState(undefined); + var plateDraft = plateDraftState[0]; + var setPlateDraft = plateDraftState[1]; + + /** 提报人多选:空数组表示全部 */ + var reporterDraftState = useState([]); + var reporterDraft = reporterDraftState[0]; + var setReporterDraft = reporterDraftState[1]; + + var dateRangeDraftState = useState(function () { + return { startDate: '', endDate: '' }; + }); + var dateRangeDraft = dateRangeDraftState[0]; + var setDateRangeDraft = dateRangeDraftState[1]; + + /** 类型多选:空数组表示全部(保养、维修) */ + var typeDraftState = useState([]); + var typeDraft = typeDraftState[0]; + var setTypeDraft = typeDraftState[1]; + + var plateAppliedState = useState(undefined); + var plateApplied = plateAppliedState[0]; + var setPlateApplied = plateAppliedState[1]; + + var reporterAppliedState = useState([]); + var reporterApplied = reporterAppliedState[0]; + var setReporterApplied = reporterAppliedState[1]; + + var dateRangeAppliedState = useState(function () { + return { startDate: '', endDate: '' }; + }); + var dateRangeApplied = dateRangeAppliedState[0]; + var setDateRangeApplied = dateRangeAppliedState[1]; + + var typeAppliedState = useState([]); + var typeApplied = typeAppliedState[0]; + var setTypeApplied = typeAppliedState[1]; + + var pageState = useState(1); + var page = pageState[0]; + var setPage = pageState[1]; + + var pageSizeState = useState(DEFAULT_PAGE_SIZE); + var pageSize = pageSizeState[0]; + var setPageSize = pageSizeState[1]; + + /** 联调后由账号权限判断是否运维主管 */ + /** 原型默认运维人员视角;预览主管权限可在 URL 加 ?role=supervisor */ + var isSupervisor = + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('role') === 'supervisor'; + var editingKeysState = useState([]); + var editingKeys = editingKeysState[0]; + var setEditingKeys = editingKeysState[1]; + + var rowInvalidState = useState({}); + var rowInvalid = rowInvalidState[0]; + var setRowInvalid = rowInvalidState[1]; + + var changeLogsByKeyState = useState({}); + var changeLogsByKey = changeLogsByKeyState[0]; + var setChangeLogsByKey = changeLogsByKeyState[1]; + + var changeLogModalState = useState({ open: false, rowKey: null, rowLabel: '' }); + var changeLogModal = changeLogModalState[0]; + var setChangeLogModal = changeLogModalState[1]; + + var attachmentModalState = useState({ open: false, rowKey: null, readOnly: false }); + var attachmentModal = attachmentModalState[0]; + var setAttachmentModal = attachmentModalState[1]; + + var importModalOpenState = useState(false); + var importModalOpen = importModalOpenState[0]; + var setImportModalOpen = importModalOpenState[1]; + var importFileListState = useState([]); + var importFileList = importFileListState[0]; + var setImportFileList = importFileListState[1]; + var importPreviewState = useState(null); + var importPreview = importPreviewState[0]; + var setImportPreview = importPreviewState[1]; + + var changeLogsSeededRef = useRef(false); + + var appendChangeLog = useCallback(function (rowKey, field, before, after, rowCtx, meta) { + if (!rowKey) return; + meta = meta || {}; + var fieldLabel = meta.fieldLabel || CHANGE_LOG_FIELD_LABELS[field] || field; + var beforeText = meta.beforeText != null ? meta.beforeText : formatLogValue(field, before, rowCtx); + var afterText = meta.afterText != null ? meta.afterText : formatLogValue(field, after, rowCtx); + if (!meta.force && beforeText === afterText) return; + setChangeLogsByKey(function (prev) { + var list = (prev[rowKey] || []).slice(); + list.unshift({ + id: 'clog-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8), + at: nowDayjs(), + userId: CURRENT_USER.id, + userName: CURRENT_USER.name, + field: field, + fieldLabel: fieldLabel, + before: beforeText, + after: afterText + }); + var next = Object.assign({}, prev); + next[rowKey] = list; + return next; + }); + }, []); + + var openChangeLogModal = useCallback(function (record) { + if (!record) return; + var label = (record.plateNo || '') + (record.repairDate ? ' · ' + formatDate(record.repairDate) : ''); + setChangeLogModal({ + open: true, + rowKey: record.key, + rowLabel: label.trim() + }); + }, []); + + var closeChangeLogModal = useCallback(function () { + setChangeLogModal({ open: false, rowKey: null, rowLabel: '' }); + }, []); + + var closeAttachmentModal = useCallback(function () { + setAttachmentModal({ open: false, rowKey: null, readOnly: false }); + }, []); + + var updateRowAttachments = useCallback( + function (rowKey, nextList) { + if (!rowKey) return; + setAllRows(function (prev) { + return prev.map(function (r) { + if (r.key !== rowKey) return r; + var before = r.attachments || []; + if (attachmentNamesSignature(before) !== attachmentNamesSignature(nextList)) { + appendChangeLog(rowKey, 'attachments', before, nextList, r, { + beforeText: formatAttachmentListSummary(before), + afterText: formatAttachmentListSummary(nextList) + }); + } + return Object.assign({}, r, { attachments: nextList }); + }); + }); + }, + [appendChangeLog] + ); + + var copyPopoverKeyState = useState(null); + var copyPopoverKey = copyPopoverKeyState[0]; + var setCopyPopoverKey = copyPopoverKeyState[1]; + var copyRowCountState = useState(1); + var copyRowCount = copyRowCountState[0]; + var setCopyRowCount = copyRowCountState[1]; + + var applyCopyRows = useCallback(function (sourceKey, count) { + var n = Math.floor(Number(count)); + if (!isFinite(n) || n < 1) { + message.warning('请输入大于0的复制行数'); + return; + } + if (n > 100) { + message.warning('单次最多复制100行'); + return; + } + var didCopy = false; + setAllRows(function (prev) { + var source = null; + var i; + for (i = 0; i < prev.length; i++) { + if (prev[i].key === sourceKey) { + source = prev[i]; + break; + } + } + if (!source || !canCopyRow(source, isSupervisor)) return prev; + var copies = []; + for (i = 0; i < n; i++) { + copies.push(buildCopiedRow(source)); + } + var ci; + var sourceLabel = (source.plateNo || '当前行') + (source.itemDesc ? ' · ' + source.itemDesc : ''); + for (ci = 0; ci < copies.length; ci++) { + appendChangeLog(copies[ci].key, '_copy', null, null, source, { + fieldLabel: '复制新增', + beforeText: '—', + afterText: '复制自「' + sourceLabel + '」', + force: true + }); + } + didCopy = true; + return prev.concat(copies); + }); + setCopyPopoverKey(null); + if (didCopy) message.success('已复制 ' + n + ' 行'); + else message.warning('无法复制该条记录'); + }, [appendChangeLog, isSupervisor]); + + var latestRefs = useRef({}); + latestRefs.current = { + setAllRows: setAllRows, + setEditingKeys: setEditingKeys, + setRowInvalid: setRowInvalid, + editingKeys: editingKeys, + rowInvalid: rowInvalid, + isSupervisor: isSupervisor, + vehicleOptions: vehiclePlateOptions, + appendChangeLog: appendChangeLog + }; + + React.useEffect(function () { + if (changeLogsSeededRef.current) return; + changeLogsSeededRef.current = true; + var demoAt = nowDayjs(); + var demoAt2 = demoAt && demoAt.subtract ? demoAt.subtract(2, 'hour') : demoAt; + setChangeLogsByKey({ + 'mock-1': [ + { + id: 'clog-demo-2', + at: demoAt, + userId: CURRENT_USER.id, + userName: CURRENT_USER.name, + field: 'itemDesc', + fieldLabel: '项目/材料', + before: '更换空压机滤芯', + after: '制动片检修' + }, + { + id: 'clog-demo-1', + at: demoAt2, + userId: CURRENT_USER.id, + userName: CURRENT_USER.name, + field: 'recordType', + fieldLabel: '类型', + before: '维修', + after: '保养' + } + ] + }); + }, []); + + var isRowSubmitted = useCallback(function (row) { + return row && row.submitStatus === 'submitted'; + }, []); + + var isRowSaved = useCallback(function (row) { + return row && row.saveStatus === 'saved'; + }, []); + + /** 是否可编辑单元格:本人未保存直接编辑;已保存需点「编辑」;已提交仅运维主管点「编辑」后可改;他人记录只读 */ + var canEditRow = useCallback( + function (row) { + if (!row) return false; + if (isRowSubmitted(row)) { + if (!isSupervisor) return false; + return editingKeys.indexOf(row.key) >= 0; + } + if (row.createdBy !== CURRENT_USER.id) return false; + if (isSavedDraftRow(row)) { + return editingKeys.indexOf(row.key) >= 0; + } + return true; + }, + [isSupervisor, editingKeys, isRowSubmitted] + ); + + var canManageAttachmentsForRow = useCallback( + function (row) { + return canManageAttachments(row, isSupervisor, editingKeys); + }, + [isSupervisor, editingKeys] + ); + + var visibleRows = useMemo(function () { + var list = allRows.slice(); + if (plateApplied) { + list = list.filter(function (r) { return r.plateNo === plateApplied; }); + } + if (reporterApplied && reporterApplied.length > 0) { + list = list.filter(function (r) { return reporterApplied.indexOf(r.reporterName) >= 0; }); + } + if (typeApplied && typeApplied.length > 0) { + list = list.filter(function (r) { return typeApplied.indexOf(r.recordType) >= 0; }); + } + if (dateRangeApplied.startDate || dateRangeApplied.endDate) { + list = list.filter(function (r) { + var d = formatDate(r.repairDate); + if (!d) return false; + if (dateRangeApplied.startDate && d < dateRangeApplied.startDate) return false; + if (dateRangeApplied.endDate && d > dateRangeApplied.endDate) return false; + return true; + }); + } + list = sortRowsByDisplayTier(list); + return list.map(function (r, idx) { + return Object.assign({}, r, { seq: idx + 1, displayTier: rowDisplaySortTier(r) }); + }); + }, [allRows, plateApplied, reporterApplied, typeApplied, dateRangeApplied]); + + var listTotalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize)); + var listSafePage = Math.min(page, listTotalPages); + + var pagedVisibleRows = useMemo(function () { + var start = (listSafePage - 1) * pageSize; + return visibleRows.slice(start, start + pageSize); + }, [visibleRows, listSafePage, pageSize]); + + React.useEffect(function () { + var maxPage = Math.max(1, Math.ceil(visibleRows.length / pageSize)); + if (page > maxPage) setPage(maxPage); + }, [visibleRows.length, pageSize, page]); + + /** 合计行仅汇总三个人含税金额字段 */ + var totals = useMemo(function () { + return visibleRows.reduce( + function (acc, r) { + acc.laborAmount += numOrZero(r.laborAmount); + acc.partsAmount += numOrZero(r.partsAmount); + acc.totalCost += numOrZero(r.totalCost); + return acc; + }, + { laborAmount: 0, partsAmount: 0, totalCost: 0 } + ); + }, [visibleRows]); + + var reporterOptions = useMemo(function () { + var names = {}; + names[CURRENT_USER.name] = true; + OTHER_REPORTERS.forEach(function (rep) { + if (rep.name) names[rep.name] = true; + }); + allRows.forEach(function (r) { + if (r.reporterName) names[r.reporterName] = true; + }); + return Object.keys(names).map(function (n) { return { value: n, label: n }; }); + }, [allRows]); + + var updateRow = useCallback(function (key, patch) { + setAllRows(function (prev) { + return prev.map(function (r) { + if (r.key !== key) return r; + return recalcRow(Object.assign({}, r, patch)); + }); + }); + }, []); + + var addRow = useCallback(function () { + var row = buildEmptyRow(CURRENT_USER); + appendChangeLog(row.key, '_create', null, null, row, { + fieldLabel: '新增记录', + beforeText: '—', + afterText: '新增空白行', + force: true + }); + setAllRows(function (prev) { + return prev.concat([row]); + }); + message.success('已新增一行,请填写维修/保养信息'); + }, [appendChangeLog]); + + var handleQuery = useCallback(function () { + setPlateApplied(plateDraft); + setReporterApplied(reporterDraft); + setTypeApplied(typeDraft); + setDateRangeApplied(dateRangeDraft); + setPage(1); + message.success('查询成功'); + }, [plateDraft, reporterDraft, typeDraft, dateRangeDraft]); + + var handleReset = useCallback(function () { + setPlateDraft(undefined); + setReporterDraft([]); + setTypeDraft([]); + setDateRangeDraft({ startDate: '', endDate: '' }); + setPlateApplied(undefined); + setReporterApplied([]); + setTypeApplied([]); + setDateRangeApplied(EMPTY_DATE_RANGE); + setPage(1); + }, []); + + function validateMaintainableRows(rows) { + var allInvalid = {}; + var firstMsg = ''; + (rows || []).forEach(function (r, idx) { + var inv = getRowInvalidMap(r); + if (Object.keys(inv).length > 0) { + allInvalid[r.key] = inv; + if (!firstMsg) firstMsg = firstInvalidMessage(r, inv, r.seq || idx + 1); + } + }); + if (firstMsg) { + setRowInvalid(allInvalid); + message.warning(firstMsg); + return false; + } + setRowInvalid({}); + return true; + } + + var pendingSubmitRows = useMemo(function () { + return allRows.filter(function (r) { + return r.createdBy === CURRENT_USER.id && r.submitStatus !== 'submitted' && r.saveStatus === 'saved'; + }); + }, [allRows]); + + var handleSave = useCallback(function () { + var toSave = allRows.filter(function (r) { + return r.createdBy === CURRENT_USER.id && r.submitStatus !== 'submitted'; + }); + if (!toSave.length) { + message.info('暂无待保存的草稿记录'); + return; + } + if (!validateMaintainableRows(toSave)) return; + setAllRows(function (prev) { + return prev.map(function (r) { + if (r.createdBy === CURRENT_USER.id && r.submitStatus !== 'submitted') { + return Object.assign({}, r, { saveStatus: 'saved' }); + } + return r; + }); + }); + setEditingKeys([]); + message.success('保存成功(原型)'); + }, [allRows]); + + var handleConfirmSubmit = useCallback(function () { + if (isSupervisor) { + message.info('运维主管无需提交,请使用编辑修改已提交记录'); + return; + } + if (!pendingSubmitRows.length) { + var hasUnsaved = allRows.some(function (r) { + return r.createdBy === CURRENT_USER.id && r.submitStatus !== 'submitted' && r.saveStatus !== 'saved'; + }); + message.info(hasUnsaved ? '请先保存后再确认提交' : '暂无待提交的已保存记录'); + return; + } + if (!validateMaintainableRows(pendingSubmitRows)) return; + Modal.confirm({ + title: '确认提交', + content: SUBMIT_CONFIRM_TEXT, + okText: '确认', + cancelText: '取消', + onOk: function () { + setAllRows(function (prev) { + return prev.map(function (r) { + if (r.createdBy === CURRENT_USER.id && r.submitStatus !== 'submitted' && r.saveStatus === 'saved') { + if (!valuesEqualForLog('submitStatus', r.submitStatus, 'submitted')) { + appendChangeLog(r.key, 'submitStatus', r.submitStatus, 'submitted', r, { force: true }); + } + return Object.assign({}, r, { + submitStatus: 'submitted', + saveStatus: 'saved', + submittedAt: nowDayjs() + }); + } + return r; + }); + }); + setEditingKeys([]); + message.success('提交成功,数据已锁定'); + } + }); + }, [isSupervisor, pendingSubmitRows, allRows, appendChangeLog]); + + var cellInputStyle = { width: '100%' }; + var cellNumStyle = { width: '100%' }; + + var columns = useMemo(function () { + function patch(key, field, val) { + var ref = latestRefs.current; + var patchObj = {}; + patchObj[field] = val; + ref.setAllRows(function (prev) { + return prev.map(function (r) { + if (r.key !== key) return r; + var next = recalcRow(Object.assign({}, r, patchObj)); + if (ref.appendChangeLog && !valuesEqualForLog(field, r[field], val)) { + ref.appendChangeLog(key, field, r[field], val, next); + } + return next; + }); + }); + ref.setRowInvalid(function (prev) { + if (!prev || !prev[key] || !prev[key][field]) return prev; + var next = Object.assign({}, prev); + var rowInv = Object.assign({}, next[key]); + delete rowInv[field]; + if (Object.keys(rowInv).length === 0) { + delete next[key]; + } else { + next[key] = rowInv; + } + return next; + }); + } + + function readOnlyCell(v, align) { + return React.createElement( + 'div', + { + className: 'ldb-cell-readonly', + style: { textAlign: align || 'center' } + }, + v === 0 || v === '0' ? v : v || '-' + ); + } + + function moneyColumnProps() { + return { + className: 'ldb-col-money tabular-nums', + onHeaderCell: function () { + return { className: 'ldb-col-money' }; + }, + onCell: function () { + return { className: 'ldb-col-money tabular-nums' }; + } + }; + } + + function fixedLeftColumnProps() { + return { + onHeaderCell: function () { + return { className: 'sticky-col-left' }; + }, + onCell: function () { + return { className: 'sticky-col-left' }; + } + }; + } + + function fixedRightColumnProps(extraClass) { + return { + onHeaderCell: function () { + return { className: 'sticky-col-right' + (extraClass ? ' ' + extraClass : '') }; + }, + onCell: function () { + return { className: 'sticky-col-right' + (extraClass ? ' ' + extraClass : '') }; + } + }; + } + + function renderStatusTag(record) { + var label = rowStatusLabel(record); + var statusClass = + label === '已提交' + ? 'ldb-status--archived' + : label === '已保存' + ? 'ldb-status--partial' + : 'ldb-status--draft'; + return React.createElement( + 'span', + { className: 'ldb-status-tag mldb-status-tag ' + statusClass }, + label + ); + } + + function fieldInvalid(record, field) { + var ref = latestRefs.current; + var rowInv = (ref.rowInvalid && ref.rowInvalid[record.key]) || {}; + return rowInv[field]; + } + + function reqTitle(text) { + return React.createElement( + 'span', + null, + React.createElement('span', { style: { color: '#ff4d4f', marginRight: 2 } }, '*'), + text + ); + } + + return [ + { + title: '序号', + dataIndex: 'seq', + key: 'seq', + width: 56, + align: 'center', + fixed: 'left', + onHeaderCell: fixedLeftColumnProps().onHeaderCell, + onCell: fixedLeftColumnProps().onCell + }, + { + title: '状态', + dataIndex: 'displayTier', + key: 'recordStatus', + width: 88, + align: 'center', + fixed: 'left', + onHeaderCell: fixedLeftColumnProps().onHeaderCell, + onCell: function () { + return { className: 'sticky-col-left mldb-col-status' }; + }, + render: function (_, record) { + return renderStatusTag(record); + } + }, + { + title: reqTitle('进修/保养日期'), + dataIndex: 'repairDate', + key: 'repairDate', + width: 152, + align: 'center', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(formatDate(v)); + return React.createElement(DatePicker, { + className: 'maint-repair-date-picker', + style: { width: '100%', minWidth: 132 }, + format: 'YYYY-MM-DD', + placeholder: '请选择进修日期', + allowClear: true, + value: v, + status: fieldInvalid(record, 'repairDate') ? 'error' : undefined, + onChange: function (d) { patch(record.key, 'repairDate', d); } + }); + } + }, + { + title: reqTitle('车牌号'), + dataIndex: 'plateNo', + key: 'plateNo', + width: 140, + align: 'center', + render: function (v, record) { + var ref = latestRefs.current; + if (!canEditRow(record)) return readOnlyCell(v); + var plateInv = fieldInvalid(record, 'plateNo'); + return React.createElement(Select, { + style: cellInputStyle, + showSearch: true, + allowClear: true, + placeholder: '请输入车牌号搜索', + value: v, + status: plateInv ? 'error' : undefined, + options: ref.vehicleOptions, + optionFilterProp: 'label', + filterOption: filterPlateOption, + notFoundContent: '未找到匹配车辆', + onChange: function (val) { patch(record.key, 'plateNo', val); } + }); + } + }, + { + title: reqTitle('类型'), + dataIndex: 'recordType', + key: 'recordType', + width: 100, + align: 'center', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(v); + return React.createElement(Select, { + style: cellInputStyle, + allowClear: true, + placeholder: '请选择类型', + value: v, + status: fieldInvalid(record, 'recordType') ? 'error' : undefined, + options: RECORD_TYPE_OPTIONS, + onChange: function (val) { patch(record.key, 'recordType', val); } + }); + } + }, + { + title: reqTitle('项目/材料'), + dataIndex: 'itemDesc', + key: 'itemDesc', + width: 160, + align: 'center', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(v); + return React.createElement(Input, { + className: 'maint-inline-input', + value: v, + placeholder: '请输入项目/材料', + status: fieldInvalid(record, 'itemDesc') ? 'error' : undefined, + onChange: function (e) { patch(record.key, 'itemDesc', e.target.value); } + }); + } + }, + { + title: '人工费', + key: 'laborGroup', + children: [ + { + title: reqTitle('单位'), + dataIndex: 'laborUnit', + key: 'laborUnit', + width: 72, + align: 'center', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(v); + return React.createElement(Input, { + className: 'maint-inline-input', + value: v, + placeholder: '单位', + status: fieldInvalid(record, 'laborUnit') ? 'error' : undefined, + onChange: function (e) { patch(record.key, 'laborUnit', e.target.value); } + }); + } + }, + { + title: reqTitle('数量'), + dataIndex: 'laborQty', + key: 'laborQty', + width: 88, + align: 'left', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(toIntQty(v), 'right'); + return React.createElement(InputNumber, { + style: cellNumStyle, + min: 0, + step: 1, + precision: 0, + placeholder: '数量', + value: isEmptyNum(v) ? null : toIntQty(v), + status: fieldInvalid(record, 'laborQty') ? 'error' : undefined, + onChange: function (val) { patch(record.key, 'laborQty', val == null ? null : toIntQty(val)); } + }); + } + }, + { + title: reqTitle('单价(含税)'), + dataIndex: 'laborUnitPrice', + key: 'laborUnitPrice', + width: 100, + align: 'left', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(fmtMoney(v), 'right'); + return React.createElement(InputNumber, { + style: cellNumStyle, + min: 0, + precision: 2, + placeholder: '单价', + value: isEmptyNum(v) ? null : v, + status: fieldInvalid(record, 'laborUnitPrice') ? 'error' : undefined, + onChange: function (val) { patch(record.key, 'laborUnitPrice', val == null ? null : val); } + }); + } + }, + { + title: '金额(含税)', + dataIndex: 'laborAmount', + key: 'laborAmount', + width: 100, + align: 'left', + onHeaderCell: moneyColumnProps().onHeaderCell, + onCell: moneyColumnProps().onCell, + render: function (v) { return fmtMoney(v); } + } + ] + }, + { + title: '配件费', + key: 'partsGroup', + children: [ + { + title: reqTitle('单位'), + dataIndex: 'partsUnit', + key: 'partsUnit', + width: 72, + align: 'center', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(v); + return React.createElement(Input, { + className: 'maint-inline-input', + value: v, + placeholder: '单位', + status: fieldInvalid(record, 'partsUnit') ? 'error' : undefined, + onChange: function (e) { patch(record.key, 'partsUnit', e.target.value); } + }); + } + }, + { + title: reqTitle('数量'), + dataIndex: 'partsQty', + key: 'partsQty', + width: 88, + align: 'left', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(toIntQty(v), 'right'); + return React.createElement(InputNumber, { + style: cellNumStyle, + min: 0, + step: 1, + precision: 0, + placeholder: '数量', + value: isEmptyNum(v) ? null : toIntQty(v), + status: fieldInvalid(record, 'partsQty') ? 'error' : undefined, + onChange: function (val) { patch(record.key, 'partsQty', val == null ? null : toIntQty(val)); } + }); + } + }, + { + title: reqTitle('单价(含税)'), + dataIndex: 'partsUnitPrice', + key: 'partsUnitPrice', + width: 100, + align: 'left', + render: function (v, record) { + if (!canEditRow(record)) return readOnlyCell(fmtMoney(v), 'right'); + return React.createElement(InputNumber, { + style: cellNumStyle, + min: 0, + precision: 2, + placeholder: '单价', + value: isEmptyNum(v) ? null : v, + status: fieldInvalid(record, 'partsUnitPrice') ? 'error' : undefined, + onChange: function (val) { patch(record.key, 'partsUnitPrice', val == null ? null : val); } + }); + } + }, + { + title: '金额(含税)', + dataIndex: 'partsAmount', + key: 'partsAmount', + width: 100, + align: 'left', + onHeaderCell: moneyColumnProps().onHeaderCell, + onCell: moneyColumnProps().onCell, + render: function (v) { return fmtMoney(v); } + } + ] + }, + { + title: '费用总计(含税)', + dataIndex: 'totalCost', + key: 'totalCost', + width: 120, + align: 'left', + fixed: 'right', + onHeaderCell: function () { + return { className: 'sticky-col-right ldb-col-money' }; + }, + onCell: function () { + return { className: 'sticky-col-right ldb-col-money tabular-nums' }; + }, + render: function (v) { + return React.createElement('span', { className: 'ldb-computed-val' }, fmtMoney(v)); + } + }, + { + title: '附件', + key: 'attachments', + dataIndex: 'attachments', + width: 76, + align: 'center', + fixed: 'right', + onHeaderCell: fixedRightColumnProps().onHeaderCell, + onCell: fixedRightColumnProps().onCell, + render: function (v, record) { + var count = (v && v.length) || 0; + var clipIcon = React.createElement( + 'svg', + { + viewBox: '0 0 24 24', + width: 16, + height: 16, + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + 'aria-hidden': true + }, + React.createElement('path', { + d: 'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48', + strokeLinecap: 'round', + strokeLinejoin: 'round' + }) + ); + var btn = React.createElement( + 'button', + { + type: 'button', + className: 'mldb-attach-btn' + (count > 0 ? ' has-files' : ''), + onClick: function () { + setAttachmentModal({ + open: true, + rowKey: record.key, + readOnly: !canManageAttachmentsForRow(record) + }); + }, + 'aria-label': count ? '查看 ' + count + ' 个附件' : '管理附件', + title: count ? count + ' 个附件' : '上传附件' + }, + clipIcon, + count > 0 + ? React.createElement('span', { className: 'mldb-attach-count' }, String(count)) + : null + ); + return btn; + } + }, + { + title: '提报人', + dataIndex: 'reporterName', + key: 'reporterName', + width: 88, + align: 'center', + fixed: 'right', + onHeaderCell: fixedRightColumnProps().onHeaderCell, + onCell: fixedRightColumnProps().onCell, + render: function (v) { return readOnlyCell(v); } + }, + { + title: '提报时间', + dataIndex: 'reportTime', + key: 'reportTime', + width: 168, + align: 'center', + fixed: 'right', + onHeaderCell: fixedRightColumnProps().onHeaderCell, + onCell: fixedRightColumnProps().onCell, + render: function (v) { + return React.createElement( + 'div', + { className: 'maint-cell-readonly', style: { padding: '4px 8px', textAlign: 'center', whiteSpace: 'nowrap', minWidth: 152 } }, + formatDateTime(v) || '-' + ); + } + }, + { + title: '操作', + key: 'action', + width: 128, + align: 'center', + fixed: 'right', + onHeaderCell: fixedRightColumnProps('ldb-col-actions').onHeaderCell, + onCell: fixedRightColumnProps('ldb-col-actions').onCell, + render: function (_, record) { + var ref = latestRefs.current; + var submitted = record.submitStatus === 'submitted'; + var isOwn = record.createdBy === CURRENT_USER.id; + var allowCopy = canCopyRow(record, ref.isSupervisor); + + function renderEditIcon() { + return React.createElement( + 'svg', + { viewBox: '64 64 896 896', width: 14, height: 14, fill: 'currentColor', 'aria-hidden': true }, + React.createElement('path', { + d: 'M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 0 0 0-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 0 0 9.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9z' + }) + ); + } + + function renderCopyIcon() { + return React.createElement( + 'svg', + { viewBox: '64 64 896 896', width: 14, height: 14, fill: 'currentColor', 'aria-hidden': true }, + React.createElement('path', { + d: 'M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530c0 17.7 14.3 32 32 32h512c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32z' + }) + ); + } + + function renderDeleteIcon() { + return React.createElement( + 'svg', + { viewBox: '64 64 896 896', width: 14, height: 14, fill: 'currentColor', 'aria-hidden': true }, + React.createElement('path', { + d: 'M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60v520c0 17.7 14.3 32 32 32h632c17.7 0 32-14.3 32-32V336h60c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM512 464c-17.7 0-32 14.3-32 32v288c0 17.7 14.3 32 32 32h64c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32h-64zm-192 0c-17.7 0-32 14.3-32 32v288c0 17.7 14.3 32 32 32h64c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32h-64zm-192 0c-17.7 0-32 14.3-32 32v288c0 17.7 14.3 32 32 32h64c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32h-64z' + }) + ); + } + + function renderMoreIcon() { + return React.createElement( + 'svg', + { viewBox: '0 0 16 16', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true }, + React.createElement('circle', { cx: '8', cy: '3', r: '1.5' }), + React.createElement('circle', { cx: '8', cy: '8', r: '1.5' }), + React.createElement('circle', { cx: '8', cy: '13', r: '1.5' }) + ); + } + + function renderIconAction(title, iconNode, onClick, extraClass, disabled) { + var cls = + 'maint-action-icon-btn' + (extraClass ? ' ' + extraClass : '') + (disabled ? ' is-disabled' : ''); + var node = React.createElement( + 'span', + { + className: cls, + role: 'button', + tabIndex: disabled ? -1 : 0, + 'aria-label': title, + onClick: + disabled || !onClick + ? undefined + : function (e) { + e.stopPropagation(); + onClick(e); + } + }, + iconNode + ); + return React.createElement(Tooltip, { title: title }, node); + } + + function renderMoreAction() { + return React.createElement( + Dropdown, + { + trigger: ['hover'], + placement: 'bottomRight', + menu: { + items: [ + { + key: 'changelog', + label: '变更日志', + onClick: function () { + openChangeLogModal(record); + } + } + ] + } + }, + renderIconAction('更多', renderMoreIcon(), null, 'maint-row-more-btn') + ); + } + + function renderCopyButton() { + if (!allowCopy) return null; + var popOpen = copyPopoverKey === record.key; + var popContent = React.createElement( + 'div', + { style: { width: 220 } }, + React.createElement( + 'div', + { style: { marginBottom: 8, fontSize: 13, color: 'rgba(15,23,42,0.65)' } }, + '复制当前行已填写的全部数据' + ), + React.createElement( + 'div', + { style: { marginBottom: 6, fontSize: 13, color: 'rgba(15,23,42,0.55)' } }, + '复制行数' + ), + React.createElement(InputNumber, { + style: { width: '100%', marginBottom: 10 }, + min: 1, + max: 100, + precision: 0, + value: popOpen ? copyRowCount : 1, + onChange: function (val) { + setCopyRowCount(val == null ? 1 : val); + } + }), + React.createElement( + Button, + { + type: 'primary', + block: true, + size: 'small', + onClick: function () { + applyCopyRows(record.key, copyRowCount); + } + }, + '确认复制' + ) + ); + return React.createElement( + Popover, + { + trigger: 'click', + placement: 'leftTop', + open: popOpen, + content: popContent, + onOpenChange: function (open) { + if (open) { + setCopyPopoverKey(record.key); + setCopyRowCount(1); + } else if (copyPopoverKey === record.key) { + setCopyPopoverKey(null); + } + } + }, + renderIconAction('复制', renderCopyIcon()) + ); + } + + function toggleEditKeys() { + ref.setEditingKeys(function (prev) { + if (prev.indexOf(record.key) >= 0) { + return prev.filter(function (k) { return k !== record.key; }); + } + return prev.concat([record.key]); + }); + } + + function renderEditDeletePair(onEditClick, opts) { + opts = opts || {}; + var showCopy = opts.showCopy !== false && allowCopy; + var allowEdit = opts.allowEdit !== false; + var allowDelete = + opts.allowDelete !== false && canDeleteRow(record, ref.isSupervisor); + var isEditing = ref.editingKeys.indexOf(record.key) >= 0; + var editTitle = opts.editDisabledTitle && !allowEdit + ? opts.editDisabledTitle + : isEditing + ? '完成编辑' + : '编辑'; + var deleteTitle = opts.deleteDisabledTitle && !allowDelete + ? opts.deleteDisabledTitle + : '删除'; + var deleteBtn = React.createElement( + Popconfirm, + { + title: '确认删除该条维修/保养记录?', + disabled: !allowDelete, + onConfirm: function () { + if (!allowDelete) { + if (record.submitStatus === 'submitted') { + message.info('已提交记录仅运维主管可删除'); + } + return; + } + ref.setAllRows(function (prev) { + return prev.filter(function (r) { return r.key !== record.key; }); + }); + ref.setEditingKeys(function (prev) { + return prev.filter(function (k) { return k !== record.key; }); + }); + message.success('已删除'); + } + }, + renderIconAction( + deleteTitle, + renderDeleteIcon(), + null, + 'maint-action-icon-danger', + !allowDelete + ) + ); + var nodes = [ + renderIconAction( + editTitle, + renderEditIcon(), + allowEdit ? onEditClick : null, + '', + !allowEdit + ) + ]; + if (showCopy) nodes.push(renderCopyButton()); + nodes.push(deleteBtn, renderMoreAction()); + return React.createElement( + 'div', + { style: { display: 'inline-flex', alignItems: 'center', gap: 2, flexWrap: 'nowrap' } }, + nodes + ); + } + + if (submitted) { + if (!canShowSubmittedActions(record, ref.isSupervisor)) { + return renderMoreAction(); + } + return renderEditDeletePair( + function () { + if (!ref.isSupervisor) { + message.info('已提交记录仅运维主管可编辑'); + return; + } + toggleEditKeys(); + }, + { showCopy: false } + ); + } + + if (isSavedDraftRow(record)) { + if (!canShowSavedActions(record, ref.isSupervisor)) return renderMoreAction(); + return renderEditDeletePair(function () { + toggleEditKeys(); + }); + } + + if (ref.isSupervisor || !isOwn) return renderMoreAction(); + + return React.createElement( + 'div', + { style: { display: 'inline-flex', alignItems: 'center', gap: 2, flexWrap: 'nowrap' } }, + renderCopyButton(), + React.createElement( + Popconfirm, + { + title: '确认删除该条维修/保养记录?', + onConfirm: function () { + ref.setAllRows(function (prev) { + return prev.filter(function (r) { return r.key !== record.key; }); + }); + message.success('已删除'); + } + }, + renderIconAction('删除', renderDeleteIcon(), null, 'maint-action-icon-danger') + ), + renderMoreAction() + ); + } + } + ]; + }, [ + canEditRow, + canManageAttachmentsForRow, + editingKeys, + isSupervisor, + rowInvalid, + copyPopoverKey, + copyRowCount, + applyCopyRows, + openChangeLogModal, + setAttachmentModal + ]); + + var changeLogRows = useMemo( + function () { + if (!changeLogModal.rowKey) return []; + return changeLogsByKey[changeLogModal.rowKey] || []; + }, + [changeLogModal.rowKey, changeLogsByKey] + ); + + var attachmentModalRow = useMemo( + function () { + if (!attachmentModal.rowKey) return null; + for (var i = 0; i < allRows.length; i++) { + if (allRows[i].key === attachmentModal.rowKey) return allRows[i]; + } + return null; + }, + [attachmentModal.rowKey, allRows] + ); + + var attachmentModalFiles = (attachmentModalRow && attachmentModalRow.attachments) || []; + + var handleAttachmentBeforeUpload = useCallback( + function (file) { + if (!attachmentModal.rowKey) return Upload.LIST_IGNORE; + if (!isAttachmentAllowed(file)) { + message.error('不支持「' + file.name + '」,请上传照片、Word、Excel 或 PDF 格式'); + return Upload.LIST_IGNORE; + } + var att = buildAttachmentFromFile(file); + updateRowAttachments(attachmentModal.rowKey, attachmentModalFiles.concat([att])); + message.success('已添加:' + file.name); + return false; + }, + [attachmentModal.rowKey, attachmentModalFiles, updateRowAttachments] + ); + + var removeAttachmentItem = useCallback( + function (attId) { + if (!attachmentModal.rowKey || attachmentModal.readOnly) return; + var removed = null; + var next = attachmentModalFiles.filter(function (a) { + if (a.id === attId) { + removed = a; + return false; + } + return true; + }); + if (removed && removed.url) { + try { + URL.revokeObjectURL(removed.url); + } catch (eRevoke) {} + } + updateRowAttachments(attachmentModal.rowKey, next); + message.success('已移除附件'); + }, + [attachmentModal.rowKey, attachmentModal.readOnly, attachmentModalFiles, updateRowAttachments] + ); + + var previewAttachmentItem = useCallback(function (att) { + if (!att) return; + if (att.url) { + window.open(att.url, '_blank', 'noopener,noreferrer'); + return; + } + message.info('原型演示:「' + att.name + '」预览需联调文件服务'); + }, []); + + var closeImportModal = useCallback(function () { + setImportModalOpen(false); + setImportFileList([]); + setImportPreview(null); + }, []); + + var downloadImportTemplate = useCallback(function () { + var ok = downloadWorkbookFile( + '车辆维修明细导入模板.xlsx', + '导入模板', + buildImportTemplateAoa() + ); + if (ok) message.success('已下载导入模板'); + }, []); + + var downloadImportFailedRecords = useCallback(function () { + if (!importPreview || !importPreview.errors || !importPreview.errors.length) return; + var headers = IMPORT_TEMPLATE_HEADERS.concat(['备注']); + var body = importPreview.errors.map(function (err) { + return importParsedRowToLine(err.row).concat([err.reasons.join(';')]); + }); + var ok = downloadWorkbookFile( + '车辆维修明细导入失败记录_' + Date.now() + '.xlsx', + '失败记录', + [headers].concat(body) + ); + if (ok) message.success('已下载失败记录 ' + body.length + ' 条'); + }, [importPreview]); + + var parseImportFile = useCallback(function (file) { + if (!file) return; + var name = String(file.name || '').toLowerCase(); + if (!name.endsWith('.csv') && !name.endsWith('.xls') && !name.endsWith('.xlsx')) { + message.error('仅支持 .csv、.xls、.xlsx 格式'); + return; + } + setImportFileList([file]); + + function finishParse(result) { + if (result.headerError) { + message.error(result.headerError); + setImportPreview(null); + return; + } + if (!result.rows.length) { + message.warning('未解析到有效数据,请检查文件内容'); + setImportPreview(null); + return; + } + var valid = []; + var errors = []; + var previewRows = []; + var ri; + for (ri = 0; ri < result.rows.length; ri++) { + var row = result.rows[ri]; + var rowErrors = validateImportMaintenanceRow(row); + previewRows.push({ + key: 'import-preview-' + row.lineNo, + lineNo: row.lineNo, + repairDate: row.repairDate, + plateNo: row.plateNo, + recordType: row.recordType, + itemDesc: row.itemDesc, + remark: rowErrors.length ? rowErrors.join(';') : '', + isError: rowErrors.length > 0 + }); + if (rowErrors.length) { + errors.push({ + lineNo: row.lineNo, + label: (row.plateNo || '—') + ' · ' + (row.itemDesc || '—'), + reasons: rowErrors, + row: row + }); + } else { + valid.push(row); + } + } + setImportPreview({ valid: valid, errors: errors, previewRows: previewRows, fileName: file.name }); + } + + if (name.endsWith('.csv')) { + var csvReader = new FileReader(); + csvReader.onload = function (ev) { + var text = (ev && ev.target && ev.target.result) || ''; + finishParse(parseImportSheetAoa(csvTextToAoa(text))); + }; + csvReader.onerror = function () { + message.error('文件读取失败'); + }; + csvReader.readAsText(file, 'UTF-8'); + return; + } + + var XLSX = window.XLSX; + if (!XLSX) { + message.error('Excel 解析组件未加载,请使用 CSV 模板或刷新页面'); + setImportPreview(null); + return; + } + var xlsxReader = new FileReader(); + xlsxReader.onload = function (ev) { + try { + var data = new Uint8Array((ev && ev.target && ev.target.result) || []); + var wb = XLSX.read(data, { type: 'array' }); + var sheetName = wb.SheetNames[0]; + var sheet = wb.Sheets[sheetName]; + var aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }); + finishParse(parseImportSheetAoa(aoa)); + } catch (eParse) { + message.error('Excel 文件解析失败,请检查格式'); + setImportPreview(null); + } + }; + xlsxReader.onerror = function () { + message.error('文件读取失败'); + }; + xlsxReader.readAsArrayBuffer(file); + }, []); + + var handleConfirmImport = useCallback( + function () { + if (!importPreview || !importPreview.valid.length) { + message.warning('没有可导入的有效数据'); + return; + } + var imported = importPreview.valid.map(function (row) { + return importRowToMaintenanceRow(row); + }); + imported.forEach(function (row) { + appendChangeLog(row.key, '_create', null, null, row, { + fieldLabel: '批量导入', + beforeText: '—', + afterText: '导入记录', + force: true + }); + }); + setAllRows(function (prev) { + return prev.concat(imported); + }); + var errN = (importPreview.errors || []).length; + if (errN) { + message.success( + '成功导入 ' + imported.length + ' 条(待保存),' + errN + ' 条校验失败已跳过' + ); + } else { + message.success( + '成功导入 ' + imported.length + ' 条,状态为「待保存」,请补传附件后保存并确认提交' + ); + } + closeImportModal(); + }, + [importPreview, appendChangeLog, closeImportModal] + ); + + var handleExport = useCallback( + function () { + if (!visibleRows.length) { + message.warning('当前无数据可导出,请先查询或录入'); + return; + } + var body = visibleRows.map(rowToExportLine); + var stamp = formatDate(nowDayjs()) || 'export'; + var ok = downloadWorkbookFile( + '车辆维修明细导出_' + stamp + '_' + Date.now() + '.xlsx', + '维修明细', + [EXPORT_HEADERS].concat(body) + ); + if (ok) message.success('已导出 ' + visibleRows.length + ' 条(当前筛选结果)'); + }, + [visibleRows] + ); + + var changeLogColumns = useMemo( + function () { + return [ + { + title: '修改时间', + dataIndex: 'at', + key: 'at', + width: 168, + render: function (v) { + return formatDateTime(v) || '—'; + } + }, + { + title: '修改人', + dataIndex: 'userName', + key: 'userName', + width: 88, + align: 'center' + }, + { + title: '修改字段', + dataIndex: 'fieldLabel', + key: 'fieldLabel', + width: 140, + align: 'center' + }, + { + title: '修改前', + dataIndex: 'before', + key: 'before', + ellipsis: true, + render: function (v) { + return React.createElement('span', { style: { color: '#cf1322' } }, v == null || v === '' ? '—' : v); + } + }, + { + title: '修改后', + dataIndex: 'after', + key: 'after', + ellipsis: true, + render: function (v) { + return React.createElement('span', { style: { color: '#389e0d' } }, v == null || v === '' ? '—' : v); + } + } + ]; + }, + [] + ); + + var tableSummary = useCallback(function () { + return React.createElement( + Table.Summary, + null, + React.createElement( + Table.Summary.Row, + null, + React.createElement(Table.Summary.Cell, { index: 0, align: 'center' }, '合计'), + React.createElement(Table.Summary.Cell, { index: 1, colSpan: 7 }), + React.createElement(Table.Summary.Cell, { index: 8, colSpan: 3 }), + React.createElement(Table.Summary.Cell, { index: 11, align: 'left' }, fmtMoney(totals.laborAmount)), + React.createElement(Table.Summary.Cell, { index: 12, colSpan: 3 }), + React.createElement(Table.Summary.Cell, { index: 15, align: 'left' }, fmtMoney(totals.partsAmount)), + React.createElement(Table.Summary.Cell, { index: 16 }), + React.createElement(Table.Summary.Cell, { index: 17, align: 'left' }, fmtMoney(totals.totalCost)), + React.createElement(Table.Summary.Cell, { index: 18 }), + React.createElement(Table.Summary.Cell, { index: 19 }) + ) + ); + }, [totals]); + + return React.createElement( + App, + null, + React.createElement( + 'div', + { className: 'vm-page ldb-page mldb-page', 'data-annotation-id': 'vmaint-header' }, + React.createElement( + 'section', + { className: 'vm-filter-card ldb-filter-card', 'data-annotation-id': 'mldb-filter' }, + React.createElement( + 'header', + { className: 'vm-filter-header' }, + React.createElement('h2', { className: 'vm-filter-title' }, '筛选条件') + ), + React.createElement( + 'div', + { className: 'ldb-filter-body' }, + React.createElement( + 'div', + { className: 'vm-filter-grid ldb-filter-grid ldb-filter-grid--primary' }, + React.createElement( + 'label', + { className: 'vm-filter-field' }, + React.createElement('span', null, '进修日期'), + React.createElement(DateRangeFilterField, { + startDate: dateRangeDraft.startDate, + endDate: dateRangeDraft.endDate, + ariaLabel: '进修日期', + startPlaceholder: '开始日期', + endPlaceholder: '结束日期', + onChange: function (range) { + setDateRangeDraft({ startDate: range.startDate, endDate: range.endDate }); + } + }) + ), + React.createElement( + 'label', + { className: 'vm-filter-field' }, + React.createElement('span', null, '车牌号'), + React.createElement(Select, { + allowClear: true, + showSearch: true, + placeholder: '全部,可输入车牌搜索', + value: plateDraft, + onChange: function (v) { setPlateDraft(v); }, + options: vehiclePlateOptions, + optionFilterProp: 'label', + filterOption: filterPlateOption + }) + ), + React.createElement( + 'label', + { className: 'vm-filter-field' }, + React.createElement('span', null, '类型'), + React.createElement(Select, { + mode: 'multiple', + allowClear: true, + placeholder: '全部', + value: typeDraft, + onChange: function (v) { setTypeDraft(v || []); }, + options: RECORD_TYPE_OPTIONS, + maxTagCount: 2 + }) + ), + React.createElement( + 'label', + { className: 'vm-filter-field' }, + React.createElement('span', null, '提报人'), + React.createElement(Select, { + mode: 'multiple', + allowClear: true, + showSearch: true, + placeholder: '全部', + value: reporterDraft, + onChange: function (v) { setReporterDraft(v || []); }, + options: reporterOptions, + filterOption: filterOption, + maxTagCount: 2 + }) + ) + ) + ), + React.createElement( + 'div', + { className: 'vm-filter-actions ldb-filter-actions' }, + React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', onClick: handleReset }, '重置'), + React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-primary ldb-toolbar-btn', onClick: handleQuery }, '查询') + ) + ), + React.createElement( + 'section', + { className: 'vm-table-section' }, + React.createElement( + 'div', + { className: 'vm-table-toolbar ldb-table-toolbar', 'data-annotation-id': 'mldb-toolbar' }, + React.createElement( + 'div', + { + className: 'mldb-table-hint', + role: 'note', + 'data-annotation-id': 'mldb-table-hint' + }, + React.createElement( + 'svg', + { + className: 'mldb-table-hint__icon', + viewBox: '0 0 24 24', + width: 16, + height: 16, + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true + }, + React.createElement('path', { + d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' + }), + React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }), + React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }) + ), + React.createElement( + 'p', + { className: 'mldb-table-hint__text' }, + '维修保养台账涉及租赁账单,请及时填报并确认提交' + ) + ), + React.createElement( + 'div', + { className: 'vm-table-actions' }, + React.createElement( + 'button', + { + type: 'button', + className: 'vm-btn vm-btn-secondary ldb-toolbar-btn', + onClick: function () { setImportModalOpen(true); } + }, + '批量导入' + ), + React.createElement( + 'button', + { + type: 'button', + className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', + onClick: handleExport + }, + '导出' + ), + React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-ghost ldb-toolbar-btn', onClick: handleSave }, '保存'), + !isSupervisor + ? React.createElement('button', { type: 'button', className: 'vm-btn vm-btn-primary ldb-toolbar-btn', onClick: handleConfirmSubmit }, '确认提交') + : null + ) + ), + React.createElement( + 'div', + { className: 'vm-table-card' }, + React.createElement( + 'div', + { className: 'vm-table-wrap ldb-table-wrap', 'data-annotation-id': 'mldb-table' }, + visibleRows.length + ? React.createElement(Table, { + className: 'mldb-ant-table ldb-table', + size: 'small', + bordered: false, + rowKey: 'key', + columns: columns, + dataSource: pagedVisibleRows, + pagination: false, + rowClassName: function (record, index) { + var tier = record.displayTier != null ? record.displayTier : rowDisplaySortTier(record); + var prev = pagedVisibleRows[index - 1]; + var prevTier = prev ? (prev.displayTier != null ? prev.displayTier : rowDisplaySortTier(prev)) : tier; + var cls = 'maint-row-data maint-row-tier-' + tier; + if (index > 0 && prevTier !== tier) cls += ' maint-row-tier-boundary'; + return cls; + }, + scroll: { x: 'max-content' }, + summary: tableSummary + }) + : React.createElement( + 'div', + { className: 'vm-empty' }, + React.createElement('p', null, '暂无符合条件的维修/保养记录') + ) + ), + !isSupervisor + ? React.createElement( + Button, + { + type: 'dashed', + block: true, + className: 'mldb-add-row-btn', + onClick: addRow + }, + '新增一行' + ) + : null, + React.createElement( + 'div', + { className: 'vm-table-footer ldb-table-footer' }, + React.createElement(TablePagination, { + page: listSafePage, + pageSize: pageSize, + total: visibleRows.length, + onPageChange: setPage, + onPageSizeChange: function (size) { + setPageSize(size); + setPage(1); + } + }) + ) + ) + ), + React.createElement( + Modal, + { + title: '变更日志' + (changeLogModal.rowLabel ? ' · ' + changeLogModal.rowLabel : ''), + open: changeLogModal.open, + onCancel: closeChangeLogModal, + footer: React.createElement(Button, { onClick: closeChangeLogModal }, '关闭'), + width: 880, + destroyOnClose: true + }, + React.createElement(Table, { + size: 'small', + bordered: true, + rowKey: 'id', + pagination: changeLogRows.length > 8 ? { pageSize: 8, showSizeChanger: false } : false, + columns: changeLogColumns, + dataSource: changeLogRows, + locale: { emptyText: '暂无变更记录' }, + scroll: { x: 'max-content', y: 360 } + }) + ), + React.createElement( + Modal, + { + title: + '附件管理' + + (attachmentModalRow && attachmentModalRow.plateNo ? ' · ' + attachmentModalRow.plateNo : ''), + open: attachmentModal.open, + onCancel: closeAttachmentModal, + footer: React.createElement(Button, { onClick: closeAttachmentModal }, '关闭'), + width: 560, + destroyOnClose: true, + className: 'mldb-attach-modal' + }, + React.createElement( + 'div', + { className: 'mldb-attach-modal-body' }, + attachmentModalFiles.length + ? React.createElement( + 'ul', + { className: 'mldb-attach-list' }, + attachmentModalFiles.map(function (att) { + var kind = att.kind || 'other'; + return React.createElement( + 'li', + { key: att.id, className: 'mldb-attach-item' }, + React.createElement( + 'span', + { className: 'mldb-attach-kind mldb-attach-kind--' + kind }, + ATTACHMENT_KIND_LABEL[kind] || ATTACHMENT_KIND_LABEL.other + ), + React.createElement( + 'div', + { className: 'mldb-attach-meta' }, + React.createElement( + 'button', + { + type: 'button', + className: 'mldb-attach-name', + onClick: function () { previewAttachmentItem(att); } + }, + att.name + ), + React.createElement('span', { className: 'mldb-attach-size' }, formatFileSize(att.size)) + ), + attachmentModal.readOnly + ? null + : React.createElement( + 'button', + { + type: 'button', + className: 'mldb-attach-remove', + onClick: function () { removeAttachmentItem(att.id); }, + 'aria-label': '移除 ' + att.name + }, + '移除' + ) + ); + }) + ) + : React.createElement('p', { className: 'mldb-attach-empty' }, '暂无附件'), + attachmentModal.readOnly + ? React.createElement( + 'p', + { className: 'mldb-attach-readonly-hint' }, + '记录已锁定,仅可查看附件;如需修改请联系运维主管。' + ) + : React.createElement( + Upload.Dragger, + { + className: 'mldb-attach-upload', + multiple: true, + showUploadList: false, + accept: ATTACHMENT_ACCEPT, + beforeUpload: handleAttachmentBeforeUpload + }, + React.createElement('p', { className: 'mldb-attach-upload-title' }, '点击或拖拽文件到此处上传'), + React.createElement( + 'p', + { className: 'mldb-attach-upload-hint' }, + '支持照片(JPG/PNG 等)、Word(.doc/.docx)、Excel(.xls/.xlsx/.csv)、PDF;可多选上传' + ) + ) + ) + ), + React.createElement( + Modal, + { + title: '批量导入维修/保养明细', + open: importModalOpen, + onCancel: closeImportModal, + width: 920, + destroyOnClose: true, + okText: '确认导入', + cancelText: '取消', + okButtonProps: { + disabled: !importPreview || !importPreview.valid.length + }, + onOk: handleConfirmImport + }, + React.createElement( + 'div', + { className: 'mldb-import-template-bar' }, + React.createElement( + 'div', + { className: 'mldb-import-template-text' }, + React.createElement('strong', null, '第一步:下载导入模板'), + React.createElement( + 'p', + null, + '模板字段与表格一致;「类型」列下方以 1.维修、2.保养 序列展示可选值,填写须完全匹配,否则导入报错。' + ) + ), + React.createElement( + Button, + { + type: 'primary', + ghost: true, + className: 'mldb-import-template-btn', + onClick: downloadImportTemplate + }, + '下载模板' + ) + ), + React.createElement(Alert, { + type: 'info', + showIcon: true, + className: 'mldb-import-alert', + message: '第二步:填写模板后上传', + description: + '支持 .xlsx、.xls、.csv;上传后系统将校验并预览可导入条数。导入记录标记为「待保存(未保存)」,可补传附件,保存后再点「确认提交」归档。' + }), + React.createElement( + Upload.Dragger, + { + className: 'mldb-import-upload', + accept: '.csv,.xlsx,.xls', + multiple: false, + maxCount: 1, + fileList: importFileList, + beforeUpload: function (file) { + parseImportFile(file); + return false; + }, + onRemove: function () { + setImportFileList([]); + setImportPreview(null); + return true; + } + }, + React.createElement('p', { className: 'mldb-import-upload-title' }, '点击或拖拽文件到此处上传'), + React.createElement('p', { className: 'mldb-import-upload-hint' }, '单次上传一个文件') + ), + importPreview + ? React.createElement( + 'div', + { className: 'mldb-import-preview' }, + React.createElement( + 'div', + { className: 'mldb-import-preview-title' }, + '解析结果 · ' + (importPreview.fileName || '') + ), + React.createElement( + 'div', + { className: 'mldb-import-preview-stats' }, + React.createElement( + 'span', + { className: 'mldb-import-preview-ok' }, + '可导入 ' + importPreview.valid.length + ' 条' + ), + importPreview.errors.length + ? React.createElement( + 'span', + { className: 'mldb-import-preview-err' }, + '失败 ' + importPreview.errors.length + ' 条' + ) + : null + ), + importPreview.errors.length + ? React.createElement( + 'div', + { className: 'mldb-import-fail-actions' }, + React.createElement( + Button, + { + type: 'link', + size: 'small', + className: 'mldb-import-fail-download', + onClick: downloadImportFailedRecords + }, + '下载失败记录' + ) + ) + : null, + importPreview.previewRows && importPreview.previewRows.length + ? React.createElement(Table, { + className: 'mldb-import-preview-table', + size: 'small', + pagination: false, + scroll: { x: 760, y: 220 }, + rowClassName: function (record) { + return record.isError ? 'mldb-import-preview-row--error' : ''; + }, + columns: [ + { + title: '行号', + dataIndex: 'lineNo', + key: 'lineNo', + width: 56, + align: 'center' + }, + { + title: '进修日期', + dataIndex: 'repairDate', + key: 'repairDate', + width: 108, + ellipsis: true + }, + { + title: '车牌号', + dataIndex: 'plateNo', + key: 'plateNo', + width: 100, + ellipsis: true + }, + { + title: '类型', + dataIndex: 'recordType', + key: 'recordType', + width: 72, + align: 'center' + }, + { + title: '项目/材料', + dataIndex: 'itemDesc', + key: 'itemDesc', + width: 140, + ellipsis: true + }, + { + title: '备注', + dataIndex: 'remark', + key: 'remark', + width: 220, + ellipsis: true, + render: function (v, record) { + if (!v) { + return React.createElement( + 'span', + { className: 'mldb-import-preview-remark-ok' }, + '可导入' + ); + } + return React.createElement( + 'span', + { className: 'mldb-import-preview-remark-err', title: v }, + v + ); + } + } + ], + dataSource: importPreview.previewRows + }) + : null + ) + : null + ) + ) + ); +}; + +export default Component; diff --git a/src/prototypes/vehicle-maintenance-ledger/annotation-source.json b/src/prototypes/vehicle-maintenance-ledger/annotation-source.json new file mode 100644 index 0000000..d112c1b --- /dev/null +++ b/src/prototypes/vehicle-maintenance-ledger/annotation-source.json @@ -0,0 +1,406 @@ +{ + "documentVersion": 1, + "format": "axhub-annotation-source", + "data": { + "version": 2, + "prototypeName": "vehicle-maintenance-ledger", + "pageId": "list", + "updatedAt": 1783430400000, + "nodes": [ + { + "id": "vmaint-header", + "index": 1, + "title": "页面标题与模块定位", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"vmaint-header\"]" + ], + "fingerprint": "vmaint-header", + "path": [] + }, + "aiPrompt": "说明台账管理模块下车辆维修明细的定位。", + "annotationText": "# 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动。\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账管理 → 车辆维修明细 |\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV,或表格底部「新增一行」手填 |\n| 导入后状态 | 标记为 **待保存(未保存)**,可继续补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;本人未提交可随时补传;已提交默认只读,主管编辑后可增删 |\n| 数据权限 | **运维人员仅可操作本人记录**;**他人记录仅可查看** |\n| 导入导出 | 批量导入(xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n## 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n## 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角预览:页面 URL 追加 `?role=supervisor`", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1783400000000, + "updatedAt": 1783430400000, + "controls": [] + }, + { + "id": "mldb-filter", + "index": 2, + "title": "筛选条件", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"mldb-filter\"]" + ], + "fingerprint": "mldb-filter", + "path": [] + }, + "aiPrompt": "进修日期、车牌、类型、提报人筛选。", + "annotationText": "## 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作:**重置**、**查询**。", + "hasMarkdown": true, + "color": "#2563eb", + "images": [], + "createdAt": 1783400000000, + "updatedAt": 1783430400000, + "controls": [] + }, + { + "id": "mldb-toolbar", + "index": 3, + "title": "列表工具栏", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"mldb-toolbar\"]" + ], + "fingerprint": "mldb-toolbar", + "path": [] + }, + "aiPrompt": "批量导入、导出、保存、确认提交。", + "annotationText": "## 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 勾选本人未提交记录后标记为已保存 |\n| 确认提交 | 勾选本人已保存记录后归档为已提交 |", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1783400000000, + "updatedAt": 1783430400000, + "controls": [] + }, + { + "id": "mldb-table-hint", + "index": 4, + "title": "列表提示", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"mldb-table-hint\"]" + ], + "fingerprint": "mldb-table-hint", + "path": [] + }, + "aiPrompt": "租赁账单联动提示文案。", + "annotationText": "维修保养台账涉及租赁账单,请及时填报并确认提交。", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1783400000000, + "updatedAt": 1783430400000, + "controls": [] + }, + { + "id": "mldb-table", + "index": 5, + "title": "维修明细列表", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"mldb-table\"]" + ], + "fingerprint": "mldb-table", + "path": [] + }, + "aiPrompt": "宽表字段、列顺序与行操作。", + "annotationText": "## 列表字段(现行)\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费 → 配件费 → 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n- **承担方式列已从列表移除**(2026-07 批注)\n- **提报人、提报时间** 位于操作列前,右侧固定\n- 表头「进修日期」已改为 **进修/保养日期**", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1783400000000, + "updatedAt": 1783430400000, + "controls": [] + } + ], + "pages": [ + { + "id": "list", + "title": "车辆维修明细", + "route": "/list" + } + ], + "directory": { + "title": "原型目录", + "nodes": [ + { + "type": "folder", + "id": "vmaint-doc-root", + "title": "车辆维修明细说明", + "defaultExpanded": true, + "children": [ + { + "type": "markdown", + "id": "vmaint-doc-overview", + "title": "模块总览", + "markdown": "# 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动。\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账管理 → 车辆维修明细 |\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV,或表格底部「新增一行」手填 |\n| 导入后状态 | 标记为 **待保存(未保存)**,可继续补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;本人未提交可随时补传;已提交默认只读,主管编辑后可增删 |\n| 数据权限 | **运维人员仅可操作本人记录**;**他人记录仅可查看** |\n| 导入导出 | 批量导入(xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n## 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n## 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角预览:页面 URL 追加 `?role=supervisor`" + }, + { + "type": "markdown", + "id": "vmaint-doc-prd", + "title": "PRD 全文", + "markdown": "# 车辆维修明细 · 产品需求说明(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.1(对齐原型批注与现行页面) |\n| 模块名称 | 台账管理 — 车辆维修/保养明细 |\n| 交互原型 | `/prototypes/vehicle-maintenance-ledger` |\n| 修订说明 | 同步列表列顺序、列名、承担方式列移除、批量导入校验与失败导出 |\n\n---\n\n## 1. 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动(客户承担 → 应收运维费;我司承担 → 运维费成本)。\n\n| 项 | 说明 |\n|---|---|\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV,或表格底部「新增一行」手填 |\n| 导入后状态 | **待保存(未保存)**,可补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;未提交可补传;已提交默认只读,主管可增删 |\n| 数据权限 | **运维人员仅可操作本人记录**;**他人记录仅可查看** |\n| 导入导出 | 批量导入(xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n### 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n### 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角:URL 追加 `?role=supervisor`\n\n---\n\n## 2. 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作:**重置**、**查询**。\n\n---\n\n## 3. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传文件 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 将勾选且本人未提交记录标记为已保存 |\n| 确认提交 | 将勾选且本人已保存记录归档为已提交 |\n\n列表上方提示:维修保养台账涉及租赁账单,请及时填报并确认提交。\n\n---\n\n## 4. 列表字段(现行)\n\n从左至右:\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费(单位/数量/单价/金额)→ 配件费(单位/数量/单价/金额)→ 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n说明:\n\n- **承担方式列已从列表移除**(2026-07 批注);承担方式仍可在导入模板字段中维护,供下游台账汇总口径使用。\n- **提报人、提报时间** 固定于操作列前(右侧固定列)。\n- 表头「进修日期」已更名为 **进修/保养日期**。\n\n行操作:编辑、复制、删除(权限与状态约束)、更多(变更日志等)。\n\n表格底部:**新增一行**。\n\n---\n\n## 5. 批量导入\n\n### 5.1 下载模板\n\n- 支持 `.xlsx` / `.xls` / `.csv`\n- 「类型」列下方以 **序列** 展示可选值:`1.维修`、`2.保养`\n- 填写须与枚举 **完全匹配**,否则导入校验失败\n\n### 5.2 上传与校验\n\n- 上传后系统解析并区分 **可导入** / **失败** 条数\n- 预览列表最后一列 **备注**:\n - 通过:显示「可导入」\n - 失败:显示具体原因(如类型不匹配、车牌不在清单等)\n- 存在失败记录时可 **下载失败记录** Excel(含原字段 + 备注列)\n\n### 5.3 确认导入\n\n- 仅导入校验通过的行,状态为 **待保存(未保存)**\n- 导入后可补传附件,再执行保存与确认提交\n\n---\n\n## 6. 批注变更记录(2026-07)\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 先去除必填星号,后 **从列表移除承担方式列** |\n| 2 | 提报人 / 提报时间 | 移至 **操作列前** 两列(右侧固定) |\n| 3 | 进修/保养日期 | 列表表头「进修日期」改为 **进修/保养日期** |\n| 4 | 批量导入 | 模板类型枚举序列展示;导入结果预览 **备注** 列;失败记录可下载 |\n\n---\n\n## 7. 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口,挂载页面与 AnnotationViewer |\n| `MaintenanceLedgerPage.jsx` | 筛选、表格、导入导出、附件、状态流转 |\n| `annotation-source.json` | 标注目录与 PRD 节点 |\n| `styles/index.css` | 页面样式 |\n| `globals.ts` | React / antd / dayjs / XLSX 全局注入 |\n\n预览地址:`http://localhost:51722/prototypes/vehicle-maintenance-ledger`\n", + "markdownPath": "src/prototypes/vehicle-maintenance-ledger/.spec/requirements-prd.md" + }, + { + "type": "markdown", + "id": "vmaint-doc-changelog", + "title": "批注变更记录", + "markdown": "## 批注变更记录(2026-07)\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 去除必填星号后,**从列表移除承担方式列** |\n| 2 | 提报人/提报时间 | 移至操作列前最后两列 |\n| 3 | 进修/保养日期 | 列表表头更名 |\n| 4 | 批量导入 | 类型枚举序列、备注列预览、失败记录下载 |" + }, + { + "type": "folder", + "id": "vmaint-doc-page", + "title": "页面说明", + "defaultExpanded": true, + "children": [ + { + "type": "markdown", + "id": "vmaint-doc-filter", + "title": "筛选条件", + "markdown": "## 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作:**重置**、**查询**。" + }, + { + "type": "markdown", + "id": "vmaint-doc-toolbar", + "title": "列表工具栏", + "markdown": "## 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 勾选本人未提交记录后标记为已保存 |\n| 确认提交 | 勾选本人已保存记录后归档为已提交 |" + }, + { + "type": "markdown", + "id": "vmaint-doc-table", + "title": "列表字段", + "markdown": "## 列表字段(现行)\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费 → 配件费 → 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n- **承担方式列已从列表移除**(2026-07 批注)\n- **提报人、提报时间** 位于操作列前,右侧固定\n- 表头「进修日期」已改为 **进修/保养日期**" + }, + { + "type": "markdown", + "id": "vmaint-doc-import", + "title": "批量导入", + "markdown": "## 批量导入\n\n### 模板\n\n- 「类型」列下方序列展示:**1.维修**、**2.保养**\n- 填写须完全匹配,否则校验失败\n\n### 导入结果\n\n- 预览列表最后一列 **备注**:通过显示「可导入」,失败显示原因\n- 可 **下载失败记录** Excel(原字段 + 备注列)\n- 确认导入后状态为 **待保存(未保存)**" + } + ] + }, + { + "type": "markdown", + "id": "vmaint-doc-source", + "title": "源码入口", + "markdown": "## 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口 |\n| `MaintenanceLedgerPage.jsx` | 页面主体 |\n| `annotation-source.json` | 标注与目录 |\n| `.spec/requirements-prd.md` | PRD 全文 |\n\n预览:`/prototypes/vehicle-maintenance-ledger`" + }, + { + "type": "link", + "id": "vmaint-link-self", + "title": "本原型预览", + "href": "/prototypes/vehicle-maintenance-ledger", + "target": "self" + } + ] + } + ] + } + }, + "directory": { + "nodes": [ + { + "type": "folder", + "id": "oneos-project-nav", + "title": "ONE-OS 原型导航", + "defaultExpanded": true, + "children": [ + { + "type": "folder", + "id": "nav-folder-1782874576229-r8atbu", + "title": "OneOS", + "defaultExpanded": true, + "children": [ + { + "type": "link", + "id": "nav-link-oneos-web-workbench", + "title": "工作台", + "href": "/prototypes/oneos-web-workbench", + "target": "self" + } + ] + }, + { + "type": "link", + "id": "nav-link-lease-business-line-overview", + "title": "业务条线说明", + "href": "/prototypes/lease-business-line-overview", + "target": "self" + }, + { + "type": "folder", + "id": "nav-folder-prototypes-oneos-web", + "title": "ONE-OS Web 端", + "defaultExpanded": true, + "children": [ + { + "type": "link", + "id": "nav-link-oneos-web-login", + "title": "登录", + "href": "/prototypes/oneos-web-login", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-vehicle-asset", + "title": "车辆管理", + "href": "/prototypes/oneos-web-vehicle-asset", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-contract-template", + "title": "合同模板管理", + "href": "/prototypes/oneos-web-contract-template", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-help-center", + "title": "帮助中心", + "href": "/prototypes/oneos-web-help-center", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-finance", + "title": "财务管理", + "href": "/prototypes/oneos-web-finance", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-procurement", + "title": "采购管理", + "href": "/prototypes/oneos-web-procurement", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-lease-contract", + "title": "车辆租赁合同", + "href": "/prototypes/oneos-web-lease-contract", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-h2-station", + "title": "加氢站管理", + "href": "/prototypes/oneos-web-h2-station", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-data-analysis", + "title": "数据分析", + "href": "/prototypes/oneos-web-data-analysis", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-ledger-data", + "title": "台账数据", + "href": "/prototypes/oneos-web-ledger-data", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-business", + "title": "业务管理", + "href": "/prototypes/oneos-web-business", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-ops", + "title": "运维管理", + "href": "/prototypes/oneos-web-ops", + "target": "self" + }, + { + "type": "link", + "id": "nav-link-oneos-web-requirements", + "title": "需求说明", + "href": "/prototypes/oneos-web-requirements", + "target": "self" + } + ] + } + ] + }, + { + "type": "folder", + "id": "vmaint-doc-root", + "title": "车辆维修明细说明", + "defaultExpanded": true, + "children": [ + { + "type": "markdown", + "id": "vmaint-doc-overview", + "title": "模块总览", + "markdown": "# 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动。\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账管理 → 车辆维修明细 |\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV,或表格底部「新增一行」手填 |\n| 导入后状态 | 标记为 **待保存(未保存)**,可继续补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;本人未提交可随时补传;已提交默认只读,主管编辑后可增删 |\n| 数据权限 | **运维人员仅可操作本人记录**;**他人记录仅可查看** |\n| 导入导出 | 批量导入(xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n## 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n## 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角预览:页面 URL 追加 `?role=supervisor`" + }, + { + "type": "markdown", + "id": "vmaint-doc-prd", + "title": "PRD 全文", + "markdown": "# 车辆维修明细 · 产品需求说明(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.1(对齐原型批注与现行页面) |\n| 模块名称 | 台账管理 — 车辆维修/保养明细 |\n| 交互原型 | `/prototypes/vehicle-maintenance-ledger` |\n| 修订说明 | 同步列表列顺序、列名、承担方式列移除、批量导入校验与失败导出 |\n\n---\n\n## 1. 模块定位\n\n面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动(客户承担 → 应收运维费;我司承担 → 运维费成本)。\n\n| 项 | 说明 |\n|---|---|\n| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |\n| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |\n| 录入方式 | 工具栏「批量导入」Excel/CSV,或表格底部「新增一行」手填 |\n| 导入后状态 | **待保存(未保存)**,可补全字段、**手动补传附件** |\n| 保存 | 将本人未提交记录标记为 **已保存** |\n| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |\n| 附件 | 照片、Word、Excel、PDF 等多附件;未提交可补传;已提交默认只读,主管可增删 |\n| 数据权限 | **运维人员仅可操作本人记录**;**他人记录仅可查看** |\n| 导入导出 | 批量导入(xlsx/csv 模板)与按当前筛选结果导出 Excel |\n\n### 状态流转\n\n```\n批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)\n```\n\n### 原型验收提示\n\n- 默认以运维人员「张运维」登录演示\n- 主管视角:URL 追加 `?role=supervisor`\n\n---\n\n## 2. 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作:**重置**、**查询**。\n\n---\n\n## 3. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传文件 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 将勾选且本人未提交记录标记为已保存 |\n| 确认提交 | 将勾选且本人已保存记录归档为已提交 |\n\n列表上方提示:维修保养台账涉及租赁账单,请及时填报并确认提交。\n\n---\n\n## 4. 列表字段(现行)\n\n从左至右:\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费(单位/数量/单价/金额)→ 配件费(单位/数量/单价/金额)→ 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n说明:\n\n- **承担方式列已从列表移除**(2026-07 批注);承担方式仍可在导入模板字段中维护,供下游台账汇总口径使用。\n- **提报人、提报时间** 固定于操作列前(右侧固定列)。\n- 表头「进修日期」已更名为 **进修/保养日期**。\n\n行操作:编辑、复制、删除(权限与状态约束)、更多(变更日志等)。\n\n表格底部:**新增一行**。\n\n---\n\n## 5. 批量导入\n\n### 5.1 下载模板\n\n- 支持 `.xlsx` / `.xls` / `.csv`\n- 「类型」列下方以 **序列** 展示可选值:`1.维修`、`2.保养`\n- 填写须与枚举 **完全匹配**,否则导入校验失败\n\n### 5.2 上传与校验\n\n- 上传后系统解析并区分 **可导入** / **失败** 条数\n- 预览列表最后一列 **备注**:\n - 通过:显示「可导入」\n - 失败:显示具体原因(如类型不匹配、车牌不在清单等)\n- 存在失败记录时可 **下载失败记录** Excel(含原字段 + 备注列)\n\n### 5.3 确认导入\n\n- 仅导入校验通过的行,状态为 **待保存(未保存)**\n- 导入后可补传附件,再执行保存与确认提交\n\n---\n\n## 6. 批注变更记录(2026-07)\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 先去除必填星号,后 **从列表移除承担方式列** |\n| 2 | 提报人 / 提报时间 | 移至 **操作列前** 两列(右侧固定) |\n| 3 | 进修/保养日期 | 列表表头「进修日期」改为 **进修/保养日期** |\n| 4 | 批量导入 | 模板类型枚举序列展示;导入结果预览 **备注** 列;失败记录可下载 |\n\n---\n\n## 7. 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口,挂载页面与 AnnotationViewer |\n| `MaintenanceLedgerPage.jsx` | 筛选、表格、导入导出、附件、状态流转 |\n| `annotation-source.json` | 标注目录与 PRD 节点 |\n| `styles/index.css` | 页面样式 |\n| `globals.ts` | React / antd / dayjs / XLSX 全局注入 |\n\n预览地址:`http://localhost:51722/prototypes/vehicle-maintenance-ledger`\n", + "markdownPath": "src/prototypes/vehicle-maintenance-ledger/.spec/requirements-prd.md" + }, + { + "type": "markdown", + "id": "vmaint-doc-changelog", + "title": "批注变更记录", + "markdown": "## 批注变更记录(2026-07)\n\n| 序号 | 变更 | 说明 |\n|---|---|---|\n| 1 | 承担方式 | 去除必填星号后,**从列表移除承担方式列** |\n| 2 | 提报人/提报时间 | 移至操作列前最后两列 |\n| 3 | 进修/保养日期 | 列表表头更名 |\n| 4 | 批量导入 | 类型枚举序列、备注列预览、失败记录下载 |" + }, + { + "type": "folder", + "id": "vmaint-doc-page", + "title": "页面说明", + "defaultExpanded": true, + "children": [ + { + "type": "markdown", + "id": "vmaint-doc-filter", + "title": "筛选条件", + "markdown": "## 筛选区\n\n| 筛选项 | 说明 |\n|---|---|\n| 进修日期 | 日期区间 |\n| 车牌号 | 可搜索下拉,精确匹配 |\n| 类型 | 多选:维修 / 保养 |\n| 提报人 | 多选 |\n\n操作:**重置**、**查询**。" + }, + { + "type": "markdown", + "id": "vmaint-doc-toolbar", + "title": "列表工具栏", + "markdown": "## 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板 → 上传 → 校验预览 → 确认导入 |\n| 导出 | 导出当前筛选结果 Excel |\n| 保存 | 勾选本人未提交记录后标记为已保存 |\n| 确认提交 | 勾选本人已保存记录后归档为已提交 |" + }, + { + "type": "markdown", + "id": "vmaint-doc-table", + "title": "列表字段", + "markdown": "## 列表字段(现行)\n\n序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费 → 配件费 → 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作\n\n- **承担方式列已从列表移除**(2026-07 批注)\n- **提报人、提报时间** 位于操作列前,右侧固定\n- 表头「进修日期」已改为 **进修/保养日期**" + }, + { + "type": "markdown", + "id": "vmaint-doc-import", + "title": "批量导入", + "markdown": "## 批量导入\n\n### 模板\n\n- 「类型」列下方序列展示:**1.维修**、**2.保养**\n- 填写须完全匹配,否则校验失败\n\n### 导入结果\n\n- 预览列表最后一列 **备注**:通过显示「可导入」,失败显示原因\n- 可 **下载失败记录** Excel(原字段 + 备注列)\n- 确认导入后状态为 **待保存(未保存)**" + } + ] + }, + { + "type": "markdown", + "id": "vmaint-doc-source", + "title": "源码入口", + "markdown": "## 源码入口\n\n| 文件 | 说明 |\n|---|---|\n| `index.tsx` | 原型入口 |\n| `MaintenanceLedgerPage.jsx` | 页面主体 |\n| `annotation-source.json` | 标注与目录 |\n| `.spec/requirements-prd.md` | PRD 全文 |\n\n预览:`/prototypes/vehicle-maintenance-ledger`" + }, + { + "type": "link", + "id": "vmaint-link-self", + "title": "本原型预览", + "href": "/prototypes/vehicle-maintenance-ledger", + "target": "self" + } + ] + } + ] + } +} diff --git a/src/prototypes/vehicle-maintenance-ledger/globals.ts b/src/prototypes/vehicle-maintenance-ledger/globals.ts new file mode 100644 index 0000000..8dcd2ec --- /dev/null +++ b/src/prototypes/vehicle-maintenance-ledger/globals.ts @@ -0,0 +1,18 @@ +import React from 'react'; +import * as antd from 'antd'; +import dayjs from 'dayjs'; +import * as XLSX from 'xlsx'; + +declare global { + interface Window { + React: typeof React; + antd: typeof antd; + dayjs: typeof dayjs; + XLSX: typeof XLSX; + } +} + +window.React = React; +window.antd = antd; +window.dayjs = dayjs; +window.XLSX = XLSX; diff --git a/src/prototypes/vehicle-maintenance-ledger/index.tsx b/src/prototypes/vehicle-maintenance-ledger/index.tsx new file mode 100644 index 0000000..e4267e7 --- /dev/null +++ b/src/prototypes/vehicle-maintenance-ledger/index.tsx @@ -0,0 +1,41 @@ +/** + * @name 车辆维修明细 + * 设计规范:ledger-shared/DESIGN.md · vm-page + ldb-page(与租赁业务台账对齐) + */ + +import React, { useMemo } from 'react'; +import { + AnnotationViewer, + type AnnotationSourceDocument, + type AnnotationViewerOptions, +} from '@axhub/annotation'; +import '../vehicle-management/style.css'; +import './styles/index.css'; +import './globals'; +import 'antd/dist/reset.css'; +import MaintenanceLedgerPage from './MaintenanceLedgerPage.jsx'; +import annotationSourceDocument from './annotation-source.json'; + +export default function App() { + const annotationOptions = useMemo( + () => ({ + showToolbar: true, + showThemeToggle: true, + showColorFilter: true, + emptyWhenNoData: false, + toolbarEdge: 'right', + currentPageId: 'list', + }), + [], + ); + + return ( + <> + + + + ); +} diff --git a/src/prototypes/vehicle-maintenance-ledger/styles/index.css b/src/prototypes/vehicle-maintenance-ledger/styles/index.css new file mode 100644 index 0000000..6a4235f --- /dev/null +++ b/src/prototypes/vehicle-maintenance-ledger/styles/index.css @@ -0,0 +1,640 @@ +/* 车辆维修明细 — 台账管理 · 对齐 lease-business-ledger / ledger-shared DESIGN.md */ + +@import '../../lease-business-ledger/styles/index.css'; + +.mldb-page .vm-filter-field .ant-picker, +.mldb-page .vm-filter-field .ant-select { + width: 100%; +} + +.mldb-page .ldb-page-header { + margin-bottom: 16px; +} + +.mldb-page .ldb-page-header h1 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--ln-ink); + letter-spacing: -0.02em; +} + +.mldb-page .vm-filter-field .ant-picker, +.mldb-page .vm-filter-field .ant-select .ant-select-selector { + min-height: var(--vm-control-height); + border-radius: var(--ln-radius-control); + border-color: var(--ln-hairline); + font-size: 0.875rem; +} + +.mldb-page .vm-filter-field .ant-picker:hover, +.mldb-page .vm-filter-field .ant-select:not(.ant-select-disabled):hover .ant-select-selector { + border-color: var(--ln-primary); +} + +.mldb-page .vm-filter-field .ant-picker-focused, +.mldb-page .vm-filter-field .ant-select-focused .ant-select-selector { + border-color: var(--ln-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary) 18%, transparent); +} + +.mldb-page .vm-table-card > .ldb-table-wrap { + overflow: auto; + scrollbar-gutter: stable; + max-height: calc(100vh - 640px); + min-width: 0; +} + +/* Ant Design Table → 租赁业务台账 ldb-table 视觉规范 */ +.mldb-page .mldb-ant-table.ldb-table { + min-width: 100%; +} + +.mldb-page .mldb-ant-table .ant-table { + font-size: 0.8125rem; + background: transparent; +} + +.mldb-page .mldb-ant-table .ant-table-container, +.mldb-page .mldb-ant-table .ant-table-content, +.mldb-page .mldb-ant-table table { + border-radius: 0; +} + +.mldb-page .mldb-ant-table .ant-table-thead > tr > th, +.mldb-page .mldb-ant-table .ant-table-thead .ant-table-cell { + padding: 10px 12px !important; + height: auto !important; + white-space: nowrap; + color: var(--ln-muted) !important; + font-weight: 500 !important; + font-size: 0.75rem !important; + letter-spacing: 0; + text-transform: none; + background: var(--ln-canvas-soft) !important; + border-bottom: 1px solid var(--ln-hairline) !important; + border-inline-end: none !important; + text-align: left; + vertical-align: middle; + transition: background-color 0.18s ease; +} + +.mldb-page .mldb-ant-table .ant-table-thead > tr > th.ant-table-cell-fix-left, +.mldb-page .mldb-ant-table .ant-table-thead > tr > th.ant-table-cell-fix-right, +.mldb-page .mldb-ant-table .ant-table-thead > tr > th.sticky-col-left, +.mldb-page .mldb-ant-table .ant-table-thead > tr > th.sticky-col-right { + z-index: 3; + background: var(--ln-canvas-soft) !important; +} + +.mldb-page .mldb-ant-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td { + padding: 10px 12px !important; + vertical-align: middle !important; + font-size: 0.8125rem; + color: var(--ln-ink); + border-bottom: 1px solid var(--ln-hairline) !important; + border-inline-end: none !important; + background: var(--ln-surface-card); + transition: background-color 0.18s ease; +} + +.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td { + background: var(--ln-canvas-soft) !important; +} + +.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.ant-table-cell-fix-left, +.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.ant-table-cell-fix-right, +.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.sticky-col-left, +.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-data:hover > td.sticky-col-right { + background: var(--ln-canvas-soft) !important; +} + +.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.ant-table-cell-fix-left, +.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.ant-table-cell-fix-right, +.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.sticky-col-left, +.mldb-page .mldb-ant-table .ant-table-tbody > tr > td.sticky-col-right { + z-index: 2; + background: var(--ln-surface-card); +} + +.mldb-page .mldb-ant-table .ant-table-cell-fix-left-last::after, +.mldb-page .mldb-ant-table .ant-table-cell-fix-right-first::after { + box-shadow: none !important; +} + +.mldb-page .mldb-ant-table .ant-table-summary > tr > td { + font-weight: 700; + background: var(--ln-surface-muted, #f8fafc) !important; + color: var(--ln-ink) !important; + border-top: 2px solid var(--ln-hairline) !important; + border-bottom: none !important; + padding: 10px 12px !important; +} + +.mldb-page .mldb-ant-table .ant-table-summary .ldb-col-money { + text-align: left; +} + +.mldb-page .ldb-cell-readonly { + color: var(--ln-muted); + background: var(--ln-surface-muted, #f8fafc); + border-radius: var(--ln-radius-xs); + padding: 4px 8px; +} + +.mldb-page .maint-inline-input { + width: 100%; +} + +.mldb-page .mldb-col-status { + white-space: nowrap; +} + +.mldb-page .mldb-status-tag { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + max-width: 100%; +} + +.mldb-page .mldb-table-hint { + display: inline-flex; + align-items: flex-start; + gap: 8px; + margin: 0; + flex: 0 1 auto; + width: fit-content; + max-width: 100%; + padding: 8px 12px; + border-radius: var(--ln-radius-control); + border: 1px solid #fcd34d; + background: #fffbeb; + color: #92400e; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.45; +} + +.mldb-page .mldb-table-hint__icon { + flex-shrink: 0; + margin-top: 2px; + color: #d97706; +} + +.mldb-page .mldb-table-hint__text { + margin: 0; + min-width: 0; + white-space: nowrap; +} + +@media (max-width: 960px) { + .mldb-page .mldb-table-hint { + width: auto; + } + + .mldb-page .mldb-table-hint__text { + white-space: normal; + } +} + +.mldb-page .vm-table-toolbar.ldb-table-toolbar { + justify-content: space-between; + gap: 12px; +} + +.mldb-page .maint-repair-date-picker, +.mldb-page .maint-repair-date-picker .ant-picker-input { + width: 100%; + min-width: 132px; +} + +.mldb-page .maint-repair-date-picker .ant-picker-input > input { + min-width: 118px; +} + +.mldb-page .maint-row-tier-0 > td { + background: var(--ln-surface-muted, #f8fafc) !important; +} + +.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-tier-0:hover > td { + background: color-mix(in srgb, var(--ln-canvas-soft) 85%, var(--ln-surface-muted, #f8fafc)) !important; +} + +.mldb-page .maint-row-tier-1 > td { + background: var(--ln-surface-card) !important; +} + +.mldb-page .maint-row-tier-2 > td { + background: #fffbeb !important; +} + +.mldb-page .mldb-ant-table .ant-table-tbody > tr.maint-row-tier-2:hover > td { + background: color-mix(in srgb, var(--ln-canvas-soft) 70%, #fffbeb) !important; +} + +.mldb-page .maint-row-tier-boundary > td { + border-top: 2px solid color-mix(in srgb, var(--ln-primary) 35%, var(--ln-hairline)) !important; +} + +.mldb-page .maint-action-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--ln-radius-control); + cursor: pointer; + color: var(--ln-muted); + transition: background 0.15s ease, color 0.15s ease; +} + +.mldb-page .maint-action-icon-btn:hover { + background: color-mix(in srgb, var(--ln-primary) 10%, transparent); + color: var(--ln-primary); +} + +.mldb-page .maint-action-icon-btn.maint-action-icon-danger:hover { + background: color-mix(in srgb, var(--ln-error) 10%, transparent); + color: var(--ln-error); +} + +.mldb-page .maint-action-icon-btn.is-disabled { + color: var(--ln-muted-soft); + cursor: not-allowed; + pointer-events: none; +} + +.mldb-page .maint-row-more-btn.maint-action-icon-btn:hover { + background: var(--ln-surface-muted, #f5f5f5); + color: var(--ln-ink); +} + +.mldb-page .mldb-add-row-btn { + width: 100%; + margin-top: 12px; +} + +.mldb-page .mldb-add-row-btn.ant-btn-dashed { + border-color: var(--ln-hairline); + color: var(--ln-muted); + border-radius: var(--ln-radius-control); + min-height: var(--vm-control-height); +} + +.mldb-page .mldb-add-row-btn.ant-btn-dashed:hover { + border-color: var(--ln-primary); + color: var(--ln-primary); +} + +.mldb-page .mldb-bear-by-tag { + display: inline-block; + padding: 2px 8px; + border-radius: var(--ln-radius-pill); + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; +} + +.mldb-page .mldb-bear-by-tag--customer { + color: #0369a1; + background: #e0f2fe; +} + +.mldb-page .mldb-bear-by-tag--company { + color: #b45309; + background: #fef3c7; +} + +.mldb-page .mldb-attach-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 44px; + min-height: 32px; + padding: 4px 8px; + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-surface-card); + color: var(--ln-muted); + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; +} + +.mldb-page .mldb-attach-btn:hover { + border-color: var(--ln-primary); + color: var(--ln-primary); + background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card)); +} + +.mldb-page .mldb-attach-btn:focus-visible { + outline: 2px solid var(--ln-primary); + outline-offset: 2px; +} + +.mldb-page .mldb-attach-btn.has-files { + color: var(--ln-primary); + border-color: color-mix(in srgb, var(--ln-primary) 35%, var(--ln-hairline)); + background: color-mix(in srgb, var(--ln-primary) 8%, var(--ln-surface-card)); +} + +.mldb-page .mldb-attach-count { + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: var(--ln-radius-pill); + background: var(--ln-primary); + color: #fff; + font-size: 0.6875rem; + font-weight: 600; + line-height: 16px; + text-align: center; +} + +.mldb-attach-modal .mldb-attach-modal-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mldb-attach-modal .mldb-attach-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 240px; + overflow: auto; +} + +.mldb-attach-modal .mldb-attach-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-surface-muted, #f8fafc); +} + +.mldb-attach-modal .mldb-attach-kind { + flex-shrink: 0; + padding: 2px 8px; + border-radius: var(--ln-radius-pill); + font-size: 0.6875rem; + font-weight: 600; + white-space: nowrap; +} + +.mldb-attach-modal .mldb-attach-kind--image { + color: #0369a1; + background: #e0f2fe; +} + +.mldb-attach-modal .mldb-attach-kind--word { + color: #1d4ed8; + background: #dbeafe; +} + +.mldb-attach-modal .mldb-attach-kind--excel { + color: #047857; + background: #d1fae5; +} + +.mldb-attach-modal .mldb-attach-kind--pdf { + color: #b91c1c; + background: #fee2e2; +} + +.mldb-attach-modal .mldb-attach-kind--other { + color: #475569; + background: #e2e8f0; +} + +.mldb-attach-modal .mldb-attach-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.mldb-attach-modal .mldb-attach-name { + border: none; + background: none; + padding: 0; + text-align: left; + color: var(--ln-ink); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mldb-attach-modal .mldb-attach-name:hover { + color: var(--ln-primary); + text-decoration: underline; +} + +.mldb-attach-modal .mldb-attach-name:focus-visible { + outline: 2px solid var(--ln-primary); + outline-offset: 2px; +} + +.mldb-attach-modal .mldb-attach-size { + font-size: 0.75rem; + color: var(--ln-muted); +} + +.mldb-attach-modal .mldb-attach-remove { + flex-shrink: 0; + border: none; + background: none; + color: var(--ln-error, #dc2626); + font-size: 0.75rem; + cursor: pointer; + padding: 4px 6px; + border-radius: var(--ln-radius-control); +} + +.mldb-attach-modal .mldb-attach-remove:hover { + background: color-mix(in srgb, var(--ln-error, #dc2626) 10%, transparent); +} + +.mldb-attach-modal .mldb-attach-empty { + margin: 0; + padding: 12px; + text-align: center; + color: var(--ln-muted); + font-size: 0.875rem; + background: var(--ln-surface-muted, #f8fafc); + border-radius: var(--ln-radius-control); +} + +.mldb-attach-modal .mldb-attach-readonly-hint { + margin: 0; + font-size: 0.8125rem; + color: var(--ln-muted); +} + +.mldb-attach-modal .mldb-attach-upload.ant-upload-wrapper .ant-upload-drag { + border-radius: var(--ln-radius-control) !important; + border-color: var(--ln-hairline) !important; + background: var(--ln-surface-card) !important; +} + +.mldb-attach-modal .mldb-attach-upload.ant-upload-wrapper .ant-upload-drag:hover { + border-color: var(--ln-primary) !important; +} + +.mldb-attach-modal .mldb-attach-upload-title { + margin: 0 0 4px; + font-size: 0.875rem; + font-weight: 500; + color: var(--ln-ink); +} + +.mldb-attach-modal .mldb-attach-upload-hint { + margin: 0; + font-size: 0.75rem; + color: var(--ln-muted); + line-height: 1.5; +} + +.mldb-import-template-bar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + margin-bottom: 12px; + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-surface-muted, #f8fafc); +} + +.mldb-import-template-text strong { + display: block; + margin-bottom: 4px; + color: var(--ln-ink); + font-size: 0.875rem; +} + +.mldb-import-template-text p { + margin: 0; + font-size: 0.8125rem; + color: var(--ln-muted); + line-height: 1.5; +} + +.mldb-import-template-btn { + flex-shrink: 0; + border-radius: var(--ln-radius-control) !important; + font-weight: 600 !important; +} + +.mldb-import-alert { + margin-bottom: 12px !important; + border-radius: var(--ln-radius-control) !important; +} + +.mldb-import-upload.ant-upload-wrapper .ant-upload-drag { + border-radius: var(--ln-radius-control) !important; + border-color: var(--ln-hairline) !important; + background: var(--ln-surface-card) !important; +} + +.mldb-import-upload-title { + margin: 0 0 4px; + font-size: 0.875rem; + font-weight: 500; + color: var(--ln-ink); +} + +.mldb-import-upload-hint { + margin: 0; + font-size: 0.75rem; + color: var(--ln-muted); +} + +.mldb-import-preview { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-surface-muted, #f8fafc); +} + +.mldb-import-preview-title { + font-weight: 600; + margin-bottom: 6px; + color: var(--ln-ink); + font-size: 0.8125rem; +} + +.mldb-import-preview-stats { + margin-bottom: 8px; + font-size: 0.8125rem; +} + +.mldb-import-preview-ok { + color: #047857; + font-weight: 600; +} + +.mldb-import-preview-err { + margin-left: 12px; + color: #dc2626; + font-weight: 600; +} + +.mldb-import-error-list { + margin: 0; + padding-left: 18px; + font-size: 0.75rem; + color: #b91c1c; + line-height: 1.6; + max-height: 160px; + overflow: auto; +} + +.mldb-import-fail-actions { + margin-bottom: 8px; +} + +.mldb-import-fail-download { + padding-left: 0 !important; + min-height: 44px; + font-weight: 600; +} + +.mldb-import-preview-table { + margin-top: 4px; +} + +.mldb-import-preview-table .ant-table { + font-size: 0.75rem; +} + +.mldb-import-preview-row--error > td { + background: #fef2f2 !important; +} + +.mldb-import-preview-remark-ok { + color: #047857; + font-weight: 600; +} + +.mldb-import-preview-remark-err { + color: #b91c1c; +} + +@media (prefers-reduced-motion: reduce) { + .mldb-page .maint-action-icon-btn { + transition: none; + } +} diff --git a/src/prototypes/vehicle-management/annotation-source.json b/src/prototypes/vehicle-management/annotation-source.json index f50fc66..6a2b1eb 100644 --- a/src/prototypes/vehicle-management/annotation-source.json +++ b/src/prototypes/vehicle-management/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "vehicle-management", "pageId": "list", - "updatedAt": 1783328981491, + "updatedAt": 1783352657928, "nodes": [ { "id": "vm-doc-toolbar", diff --git a/src/prototypes/vehicle-management/components/DateRangeFilterField.tsx b/src/prototypes/vehicle-management/components/DateRangeFilterField.tsx index 6aac584..df9eabd 100644 --- a/src/prototypes/vehicle-management/components/DateRangeFilterField.tsx +++ b/src/prototypes/vehicle-management/components/DateRangeFilterField.tsx @@ -21,9 +21,9 @@ function formatDisplayText( startPlaceholder: string, endPlaceholder: string, ): string { - if (startDate && endDate) return `${startDate} ~ ${endDate}`; - if (startDate) return `${startDate} ~ ${endPlaceholder}`; - if (endDate) return `${startPlaceholder} ~ ${endDate}`; + if (startDate && endDate) return `${startDate} 至 ${endDate}`; + if (startDate) return `${startDate} 至 ${endPlaceholder}`; + if (endDate) return `${startPlaceholder} 至 ${endDate}`; return ''; } @@ -39,7 +39,7 @@ export function DateRangeFilterField({ const fieldRef = useRef(null); const hasValue = Boolean(startDate || endDate); const displayText = formatDisplayText(startDate, endDate, startPlaceholder, endPlaceholder); - const placeholder = `${startPlaceholder} ~ ${endPlaceholder}`; + const placeholder = `${startPlaceholder} 至 ${endPlaceholder}`; const dismiss = useCallback(() => { setOpen(false); diff --git a/src/prototypes/vehicle-management/components/DetailAccidentRecordsTab.tsx b/src/prototypes/vehicle-management/components/DetailAccidentRecordsTab.tsx index beff70a..2d18d57 100644 --- a/src/prototypes/vehicle-management/components/DetailAccidentRecordsTab.tsx +++ b/src/prototypes/vehicle-management/components/DetailAccidentRecordsTab.tsx @@ -189,17 +189,14 @@ export function DetailAccidentRecordsTab({
-
- 共 {filtered.length} 条 - {filtered.length > 0 && ( - - )} +
+
diff --git a/src/prototypes/vehicle-management/components/DetailAnnualReviewRecordsTab.tsx b/src/prototypes/vehicle-management/components/DetailAnnualReviewRecordsTab.tsx index 6ccb0de..7e056d3 100644 --- a/src/prototypes/vehicle-management/components/DetailAnnualReviewRecordsTab.tsx +++ b/src/prototypes/vehicle-management/components/DetailAnnualReviewRecordsTab.tsx @@ -180,17 +180,14 @@ export function DetailAnnualReviewRecordsTab({
-
- 共 {filtered.length} 条 - {filtered.length > 0 && ( - - )} +
+
diff --git a/src/prototypes/vehicle-management/components/DetailFaultRecordsTab.tsx b/src/prototypes/vehicle-management/components/DetailFaultRecordsTab.tsx index 55a1488..4dd58f8 100644 --- a/src/prototypes/vehicle-management/components/DetailFaultRecordsTab.tsx +++ b/src/prototypes/vehicle-management/components/DetailFaultRecordsTab.tsx @@ -249,17 +249,14 @@ export function DetailFaultRecordsTab({
-
- 共 {filtered.length} 条 - {filtered.length > 0 && ( - - )} +
+
diff --git a/src/prototypes/vehicle-management/components/DetailLeaseRecordsTab.tsx b/src/prototypes/vehicle-management/components/DetailLeaseRecordsTab.tsx index c155198..523ef3c 100644 --- a/src/prototypes/vehicle-management/components/DetailLeaseRecordsTab.tsx +++ b/src/prototypes/vehicle-management/components/DetailLeaseRecordsTab.tsx @@ -232,17 +232,14 @@ export function DetailLeaseRecordsTab({
-
- 共 {filtered.length} 条 - {filtered.length > 0 && ( - - )} +
+
diff --git a/src/prototypes/vehicle-management/components/DetailMovementRecordsTab.tsx b/src/prototypes/vehicle-management/components/DetailMovementRecordsTab.tsx index d613947..0840e90 100644 --- a/src/prototypes/vehicle-management/components/DetailMovementRecordsTab.tsx +++ b/src/prototypes/vehicle-management/components/DetailMovementRecordsTab.tsx @@ -183,17 +183,14 @@ export function DetailMovementRecordsTab({ -
- 共 {filtered.length} 条 - {filtered.length > 0 && ( - - )} +
+
diff --git a/src/prototypes/vehicle-management/components/DetailTransferRecordsTab.tsx b/src/prototypes/vehicle-management/components/DetailTransferRecordsTab.tsx index 4c15c12..3aa02e0 100644 --- a/src/prototypes/vehicle-management/components/DetailTransferRecordsTab.tsx +++ b/src/prototypes/vehicle-management/components/DetailTransferRecordsTab.tsx @@ -166,17 +166,14 @@ export function DetailTransferRecordsTab({ -
- 共 {filtered.length} 条 - {filtered.length > 0 && ( - - )} +
+
diff --git a/src/prototypes/vehicle-management/components/DetailViolationRecordsTab.tsx b/src/prototypes/vehicle-management/components/DetailViolationRecordsTab.tsx index 324817f..616b800 100644 --- a/src/prototypes/vehicle-management/components/DetailViolationRecordsTab.tsx +++ b/src/prototypes/vehicle-management/components/DetailViolationRecordsTab.tsx @@ -188,17 +188,14 @@ export function DetailViolationRecordsTab({ -
- 共 {filtered.length} 条 - {filtered.length > 0 && ( - - )} +
+
diff --git a/src/prototypes/vehicle-management/components/FilterPickerField.tsx b/src/prototypes/vehicle-management/components/FilterPickerField.tsx index 98c7c00..29cb0b4 100644 --- a/src/prototypes/vehicle-management/components/FilterPickerField.tsx +++ b/src/prototypes/vehicle-management/components/FilterPickerField.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { ChevronDown, X } from 'lucide-react'; import { useDismissOnFocusOutside } from './useDismissOnFocusOutside'; @@ -8,6 +9,21 @@ interface FilterPickerFieldProps { placeholder?: string; ariaLabel?: string; onChange: (value: string) => void; + /** 输入时大小写不敏感模糊匹配 */ + caseInsensitive?: boolean; + /** 输入框始终可编辑,聚焦即展开下拉(未输入时展示全部选项) */ + alwaysSearchable?: boolean; +} + +interface PopoverPosition { + top: number; + left: number; + width: number; +} + +function normalizeForMatch(text: string, caseInsensitive: boolean): string { + const trimmed = text.trim(); + return caseInsensitive ? trimmed.toUpperCase() : trimmed; } export function FilterPickerField({ @@ -16,16 +32,22 @@ export function FilterPickerField({ placeholder = '请选择', ariaLabel = '筛选选择', onChange, + caseInsensitive = false, + alwaysSearchable = false, }: FilterPickerFieldProps) { const [open, setOpen] = useState(false); const [editing, setEditing] = useState(false); const [query, setQuery] = useState(''); - const fieldRef = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0, width: 0 }); + const anchorRef = useRef(null); + const popoverRef = useRef(null); const inputRef = useRef(null); useEffect(() => { - if (!editing) setQuery(value); - }, [value, editing]); + if (open) return; + if (!alwaysSearchable && editing) return; + setQuery(value); + }, [value, editing, open, alwaysSearchable]); const dismiss = useCallback(() => { setOpen(false); @@ -33,13 +55,36 @@ export function FilterPickerField({ setQuery(value); }, [value]); - useDismissOnFocusOutside(open, fieldRef, dismiss); + useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss); + + const updatePosition = useCallback(() => { + const anchor = anchorRef.current; + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + setPosition({ + top: rect.bottom + 6, + left: rect.left, + width: rect.width, + }); + }, []); + + useEffect(() => { + if (!open) return; + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [open, updatePosition, options.length, query]); const filtered = useMemo(() => { const q = query.trim(); if (!q) return options; - return options.filter((item) => item.includes(q)); - }, [options, query]); + const nq = normalizeForMatch(q, caseInsensitive); + return options.filter((item) => normalizeForMatch(item, caseInsensitive).includes(nq)); + }, [options, query, caseInsensitive]); const pick = (next: string) => { onChange(next); @@ -56,37 +101,98 @@ export function FilterPickerField({ setQuery(''); setOpen(false); setEditing(false); + window.requestAnimationFrame(() => { + updatePosition(); + inputRef.current?.focus(); + }); + }; + + const openPicker = () => { + setOpen(true); + setQuery(value); + if (!alwaysSearchable) { + setEditing(true); + window.requestAnimationFrame(() => inputRef.current?.select()); + } + window.requestAnimationFrame(updatePosition); + }; + + const togglePicker = () => { + if (open) { + dismiss(); + return; + } + openPicker(); inputRef.current?.focus(); }; - const startEdit = () => { - setEditing(true); - setOpen(true); - setQuery(value); - window.requestAnimationFrame(() => inputRef.current?.select()); - }; + const inputValue = alwaysSearchable || editing ? query : value; + const inputReadOnly = !alwaysSearchable && !editing; + + const popover = open && typeof document !== 'undefined' + ? createPortal( +
+
+ {filtered.length === 0 ? ( +

暂无匹配项

+ ) : ( + filtered.map((item) => ( + + )) + )} +
+
, + document.body, + ) + : null; return ( -
+
{ setQuery(event.target.value); setOpen(true); + if (!alwaysSearchable) setEditing(true); + window.requestAnimationFrame(updatePosition); }} onKeyDown={(event) => { if (event.key === 'Escape') dismiss(); + if (event.key === 'Enter' && filtered.length === 1) { + event.preventDefault(); + pick(filtered[0]); + } }} /> {value ? ( @@ -100,32 +206,19 @@ export function FilterPickerField({ ) : null} - +
- - {open && ( -
-
- {filtered.length === 0 ? ( -

暂无匹配项

- ) : ( - filtered.map((item) => ( - - )) - )} -
-
- )} + {popover}
); } diff --git a/src/prototypes/vehicle-management/components/MultiPlateFilterField.tsx b/src/prototypes/vehicle-management/components/MultiPlateFilterField.tsx new file mode 100644 index 0000000..b07eb4c --- /dev/null +++ b/src/prototypes/vehicle-management/components/MultiPlateFilterField.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { parseMultiPlates } from '../utils/vehicle'; +import { useDismissOnFocusOutside } from './useDismissOnFocusOutside'; + +interface MultiPlateFilterFieldProps { + value: string[]; + onChange: (plateNos: string[]) => void; + placeholder?: string; + ariaLabel: string; + hint?: string; +} + +export function MultiPlateFilterField({ + value, + onChange, + placeholder = '点击输入多个车牌', + ariaLabel, + hint = '每行一个车牌,或同一行内用逗号分隔', +}: MultiPlateFilterFieldProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(''); + const fieldRef = useRef(null); + + const dismiss = useCallback(() => { + setOpen(false); + }, []); + + useDismissOnFocusOutside(open, fieldRef, dismiss); + + const summary = useMemo(() => { + if (value.length <= 0) return ''; + if (value.length <= 2) return value.join('、'); + return `已选 ${value.length} 个车牌`; + }, [value]); + + const openPopover = () => { + setDraft(value.join('\n')); + setOpen(true); + }; + + const handleConfirm = () => { + onChange(parseMultiPlates(draft)); + setOpen(false); + }; + + return ( +
+ + {open && ( +
+ {hint ?
{hint}
: null} +