迭代 ONE-OS 多原型:租赁台账增强、合同模板 Word 导入,统一全局分页与筛选规范。
- 租赁业务台账:运维费汇总、明细钻取弹层、其他收入/减免批量导入 - 合同模板:Word 导入与 docx 预览,模板编辑增强 - 全局规范:TablePagination 统一为「共 x 条 → 页码 → 每页 N 条」,vm-checkbox 与筛选面板 - 新增车辆维修明细、还车应结款原型;车辆管理、收款记录、工作台、加氢站等同步对齐 - 更新侧栏、Legacy Shell、标注源与 to-prd 技能 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
75
.agents/skills/to-prd/SKILL.md
Normal file
75
.agents/skills/to-prd/SKILL.md
Normal file
@@ -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.
|
||||
|
||||
<prd-template>
|
||||
|
||||
## 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 <actor>, I want a <feature>, so that <benefit>
|
||||
|
||||
<user-story-example>
|
||||
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
|
||||
</user-story-example>
|
||||
|
||||
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.
|
||||
|
||||
</prd-template>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
71
.local/extract-annotation-source/directory-tree.txt
Normal file
71
.local/extract-annotation-source/directory-tree.txt
Normal file
@@ -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] 备注
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
235
package-lock.json
generated
235
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {}
|
||||
"skills": {
|
||||
"to-prd": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/to-prd/SKILL.md",
|
||||
"computedHash": "5a733e19d825f22e83de52e68b8840c9fd2fa7501282277a122c1aa28a922059"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number | 'ellipsis'> {
|
||||
@@ -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 (
|
||||
<div className="vm-pagination" role="navigation" aria-label="表格分页">
|
||||
<label className="vm-pagination-size">
|
||||
<span className="vm-pagination-size-label">每页</span>
|
||||
<select
|
||||
className="vm-pagination-select"
|
||||
value={pageSize}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
aria-label="每页显示条数"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="vm-pagination-size-label">条</span>
|
||||
</label>
|
||||
<span className="vm-pagination-total">
|
||||
共 <span className="vm-pagination-total-num tabular-nums">{total}</span> 条
|
||||
</span>
|
||||
|
||||
<div className="vm-pagination-pages">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-pagination-nav"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden />
|
||||
</button>
|
||||
|
||||
{pageItems.map((item, index) => (
|
||||
item === 'ellipsis' ? (
|
||||
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden>…</span>
|
||||
) : (
|
||||
{total > 0 && (
|
||||
<>
|
||||
<div className="vm-pagination-pages">
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
|
||||
onClick={() => onPageChange(item)}
|
||||
aria-current={page === item ? 'page' : undefined}
|
||||
className="vm-pagination-nav"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
{item}
|
||||
<ChevronLeft size={16} aria-hidden />
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="vm-pagination-nav"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{pageItems.map((item, index) => (
|
||||
item === 'ellipsis' ? (
|
||||
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden>…</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
|
||||
onClick={() => onPageChange(item)}
|
||||
aria-current={page === item ? 'page' : undefined}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="vm-pagination-nav"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="vm-pagination-size">
|
||||
<span className="vm-pagination-size-label">每页</span>
|
||||
<select
|
||||
className="vm-pagination-select"
|
||||
value={pageSize}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
aria-label="每页显示条数"
|
||||
>
|
||||
{sizeOptions.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="vm-pagination-size-label">条</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<ConfigProvider theme={oneosTheme}>
|
||||
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
|
||||
{showMenu ? (
|
||||
<Layout.Sider
|
||||
width={220}
|
||||
theme="light"
|
||||
style={{ borderRight: '1px solid #e5e7eb' }}
|
||||
>
|
||||
<div style={{ padding: '16px 16px 8px', fontWeight: 600 }}>{moduleTitle}</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activePageId]}
|
||||
items={pages.map((item) => ({
|
||||
key: item.id,
|
||||
label: item.title,
|
||||
onClick: () => onSelectPage(item.id),
|
||||
}))}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
) : null}
|
||||
<Layout.Content style={{ minHeight: '100vh', overflow: 'auto' }}>
|
||||
{children}
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function OneosWebLegacySinglePageShell({ pages }: OneosWebLegacyShellProps) {
|
||||
const active = pages[0];
|
||||
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<Typography.Text type="secondary">
|
||||
未配置可预览页面
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const ActivePage = active.component;
|
||||
|
||||
return (
|
||||
<OneosWebLegacyPageLayout moduleTitle="" pages={pages}>
|
||||
<ActivePage />
|
||||
</OneosWebLegacyPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ConfigProvider theme={oneosTheme}>
|
||||
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
|
||||
{showMenu ? (
|
||||
<Layout.Sider
|
||||
width={220}
|
||||
theme="light"
|
||||
style={{ borderRight: '1px solid #e5e7eb' }}
|
||||
>
|
||||
<div style={{ padding: '16px 16px 8px', fontWeight: 600 }}>{moduleTitle}</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[active.id]}
|
||||
items={pages.map((item) => ({
|
||||
key: item.id,
|
||||
label: item.title,
|
||||
onClick: () => setPage(item.id),
|
||||
}))}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
) : null}
|
||||
<Layout.Content style={{ minHeight: '100vh', overflow: 'auto' }}>
|
||||
<ActivePage />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
<OneosWebLegacyPageLayout
|
||||
moduleTitle={moduleTitle}
|
||||
pages={pages}
|
||||
activePageId={active.id}
|
||||
onSelectPage={setPage}
|
||||
>
|
||||
<ActivePage />
|
||||
</OneosWebLegacyPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function OneosWebLegacyShell(props: OneosWebLegacyShellProps) {
|
||||
ensureOneosWebLegacyGlobals();
|
||||
|
||||
if (props.pages.length <= 1) {
|
||||
return <OneosWebLegacySinglePageShell {...props} />;
|
||||
}
|
||||
|
||||
return <OneosWebLegacyMultiPageShell {...props} />;
|
||||
}
|
||||
|
||||
67
src/common/vehicle-brand-model-catalog.js
Normal file
67
src/common/vehicle-brand-model-catalog.js
Normal file
@@ -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;
|
||||
}
|
||||
118
src/common/vm-checkbox.css
Normal file
118
src/common/vm-checkbox.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
29
src/common/vm-filter-panel.ts
Normal file
29
src/common/vm-filter-panel.ts
Normal file
@@ -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<T>(
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` 且阻止离开编辑页 |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
27
src/prototypes/contract-template-management/contract-template-docx-fidelity.js
vendored
Normal file
27
src/prototypes/contract-template-management/contract-template-docx-fidelity.js
vendored
Normal file
@@ -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 || {})
|
||||
);
|
||||
}
|
||||
173
src/prototypes/contract-template-management/contract-template-section-headings.js
vendored
Normal file
173
src/prototypes/contract-template-management/contract-template-section-headings.js
vendored
Normal file
@@ -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(/<style[^>]*>[\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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
341
src/prototypes/contract-template-management/contract-template-word-import.js
vendored
Normal file
341
src/prototypes/contract-template-management/contract-template-word-import.js
vendored
Normal file
@@ -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(/<style[^>]*>([\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(/<w:docDefaults[\s\S]*?<\/w:docDefaults>/);
|
||||
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:sz[^>]*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 '<div class="ct-word-doc ct-word-doc--imported">' + body + '</div>';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.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(/<style[^>]*>[\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*<p/gi, '</p><p')
|
||||
.replace(/<\/table>\s*n\s*<p/gi, '</table><p')
|
||||
.replace(/<\/table>\s*n\s*<table/gi, '</table><table');
|
||||
if (content.indexOf('ct-word-doc--imported') < 0 && content.indexOf('ct-word-doc') >= 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 '<p class="ct-word-section-title">' + escapeHtml(line) + '</p>';
|
||||
}
|
||||
return '<p class="ct-word-body">' + escapeHtml(line) + '</p>';
|
||||
}).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';
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "customer-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981485,
|
||||
"updatedAt": 1783352657922,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "cm-list-filter",
|
||||
|
||||
@@ -175,7 +175,7 @@ export function FilterPanel({
|
||||
return (
|
||||
<section className="vm-filter-card cm-filter-card" data-annotation-id="cm-list-filter">
|
||||
<header className="vm-filter-header">
|
||||
<h1 className="cm-page-title">客户管理</h1>
|
||||
<h2 className="vm-filter-title">筛选条件</h2>
|
||||
</header>
|
||||
<div className="vm-filter-grid">
|
||||
{visibleFields.map((field) => (
|
||||
|
||||
@@ -68,6 +68,7 @@ export function MultiSelectField({
|
||||
<label key={option} className="cm-multi-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="vm-checkbox"
|
||||
checked={values.includes(option)}
|
||||
onChange={() => toggle(option)}
|
||||
/>
|
||||
|
||||
@@ -414,7 +414,6 @@ export default function CustomerManagementApp() {
|
||||
onManageTags={setTagDrawerRecord}
|
||||
/>
|
||||
<div className="vm-table-footer">
|
||||
<div className="cm-table-total">共 {filtered.length} 条</div>
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "insurance-procurement",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981486,
|
||||
"updatedAt": 1783352657923,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ipc-header",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783329264666,
|
||||
"updatedAt": 1783352657924,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lbl-filter",
|
||||
@@ -1158,6 +1158,135 @@
|
||||
"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": "lbl-doc-root",
|
||||
@@ -1174,7 +1303,7 @@
|
||||
"type": "link",
|
||||
"id": "lbl-link-self",
|
||||
"title": "租赁业务台账原型",
|
||||
"href": "/prototypes/lease-business-ledger",
|
||||
"href": "./index.html",
|
||||
"target": "self"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
|
||||
'lbl-col-customer': '客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。',
|
||||
'lbl-col-value-added-service':
|
||||
'签订合同时勾选的增值服务项目,按车牌自动匹配合同数据;多项服务各占一行展示。',
|
||||
'lbl-col-exemption-policy':
|
||||
'手选享免政策:帕力安18T里程优惠政策、帕力安4.5T里程优惠政策;未归档/草稿状态可编辑。',
|
||||
'lbl-col-pickup-date': '运维实际交车日期。',
|
||||
'lbl-col-contract-start-date': '等于提车日期,即租赁合同生效日期,系统自动反写,只读。',
|
||||
'lbl-col-contract-end-date':
|
||||
@@ -30,7 +32,7 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
|
||||
'lbl-col-maintenance-income': '合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。',
|
||||
'lbl-col-insurance-surcharge': '事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。',
|
||||
'lbl-col-ops-income':
|
||||
'收入侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「客户承担」的金额;最新一期账单可关联记录。',
|
||||
'收入侧运维费:汇总还车应结款中「未结算保养」「未结算维修」「清洗费」等 10 项运维费用,按账单月内该车牌求和。',
|
||||
'lbl-col-other-income': '支持多条明细录入(事由、金额、备注);合计自动计入应收与月度收入。',
|
||||
'lbl-col-mileage-deduction':
|
||||
'里程减免金额 = GPS 里程 × 里程减免单价;有 GPS 里程时默认单价 0.5 元/km,无 GPS 时为 0。',
|
||||
@@ -48,7 +50,7 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
|
||||
'lbl-col-hydrogen-fee':
|
||||
'仅汇总该账单月内、氢费明细中「我司承担」的氢费(含未对账/已对账);合同为客户承担时不计入。',
|
||||
'lbl-col-ops-cost':
|
||||
'成本侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「我司承担」的金额。',
|
||||
'成本侧运维费:汇总「维修保养明细」中费用总计(含税),按账单月内该车牌求和。',
|
||||
'lbl-col-brokerage-fee': '手动输入。',
|
||||
'lbl-col-other-cost': '前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。',
|
||||
'lbl-col-gps-mileage': '从 BI 里程取账单时间段的数据,自动展示。',
|
||||
|
||||
@@ -89,6 +89,7 @@ export function AccidentLinkModal({
|
||||
<td className="ldb-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="vm-checkbox"
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
aria-label={`选择 ${item.accidentNo}`}
|
||||
|
||||
@@ -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: <FilterPickerField value={filters.year} options={yearOptions} placeholder="全部年份" ariaLabel="年份" onChange={(year) => onChange({ year })} /> },
|
||||
{ label: '月份', control: <FilterPickerField value={filters.month} options={monthOptions} placeholder="全部月份" ariaLabel="月份" onChange={(month) => onChange({ month })} /> },
|
||||
{ label: '业务部门', control: <FilterPickerField value={filters.dept} options={deptOptions} placeholder="全部部门" ariaLabel="业务部门" onChange={(dept) => onChange({ dept })} /> },
|
||||
{ label: '业务员', control: <FilterPickerField value={filters.salesman} options={salesmanOptions} placeholder="全部业务员" ariaLabel="业务员" onChange={(salesman) => onChange({ salesman })} /> },
|
||||
{ label: '车牌号码', control: <FilterPickerField value={filters.plateNo} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNo) => onChange({ plateNo })} /> },
|
||||
{ label: '客户名称', control: <input className="vm-input" value={filters.customerName} placeholder="输入客户名称" aria-label="客户名称" onChange={(e) => onChange({ customerName: e.target.value })} /> },
|
||||
{ label: '明细状态', control: <FilterPickerField value={filters.status} options={STATUS_OPTIONS} placeholder="全部状态" ariaLabel="明细状态" onChange={(status) => onChange({ status })} /> },
|
||||
{ label: '应付款日', control: (
|
||||
const fieldMap = useMemo<Record<FieldKey, FilterFieldDef>>(() => ({
|
||||
year: {
|
||||
key: 'year',
|
||||
label: '年份',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.year}
|
||||
options={yearOptions}
|
||||
placeholder="全部年份"
|
||||
ariaLabel="年份"
|
||||
onChange={(year) => onChange({ year })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
month: {
|
||||
key: 'month',
|
||||
label: '月份',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.month}
|
||||
options={monthOptions}
|
||||
placeholder="全部月份"
|
||||
ariaLabel="月份"
|
||||
onChange={(month) => onChange({ month })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
dept: {
|
||||
key: 'dept',
|
||||
label: '业务部门',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.dept}
|
||||
options={deptOptions}
|
||||
placeholder="全部部门"
|
||||
ariaLabel="业务部门"
|
||||
onChange={(dept) => onChange({ dept })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
salesman: {
|
||||
key: 'salesman',
|
||||
label: '业务员',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.salesman}
|
||||
options={salesmanOptions}
|
||||
placeholder="全部业务员"
|
||||
ariaLabel="业务员"
|
||||
onChange={(salesman) => onChange({ salesman })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
plateNos: {
|
||||
key: 'plateNos',
|
||||
label: '车牌号码',
|
||||
control: (
|
||||
<MultiPlateFilterField
|
||||
value={filters.plateNos}
|
||||
placeholder="点击输入多个车牌"
|
||||
ariaLabel="车牌号码"
|
||||
onChange={(plateNos) => onChange({ plateNos })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
customerName: {
|
||||
key: 'customerName',
|
||||
label: '客户名称',
|
||||
control: (
|
||||
<input
|
||||
className="vm-input"
|
||||
value={filters.customerName}
|
||||
placeholder="输入客户名称(模糊匹配)"
|
||||
aria-label="客户名称"
|
||||
onChange={(e) => onChange({ customerName: e.target.value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
status: {
|
||||
key: 'status',
|
||||
label: '明细状态',
|
||||
control: (
|
||||
<FilterPickerField
|
||||
value={filters.status}
|
||||
options={STATUS_OPTIONS}
|
||||
placeholder="全部状态"
|
||||
ariaLabel="明细状态"
|
||||
onChange={(status) => onChange({ status })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
paymentDue: {
|
||||
key: 'paymentDue',
|
||||
label: '应付款日',
|
||||
control: (
|
||||
<DateRangeFilterField
|
||||
startDate={filters.paymentFrom}
|
||||
endDate={filters.paymentTo}
|
||||
@@ -44,46 +158,78 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}), [
|
||||
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) => (
|
||||
<label key={field.key} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card ldb-filter-card" data-annotation-id="lbl-filter" aria-label="筛选条件">
|
||||
<section
|
||||
className="vm-filter-card ldb-filter-card"
|
||||
data-annotation-id="lbl-filter"
|
||||
aria-label="筛选条件"
|
||||
>
|
||||
<header className="vm-filter-header">
|
||||
<h1 className="ldb-page-title">租赁业务台账</h1>
|
||||
<h2 className="vm-filter-title">筛选条件</h2>
|
||||
</header>
|
||||
<div className="vm-filter-grid">
|
||||
{primaryFields.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{extraFields.length > 0 && (
|
||||
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}>
|
||||
|
||||
<div className="ldb-filter-body">
|
||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
|
||||
{primaryFields.map(renderField)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}
|
||||
aria-hidden={!expanded}
|
||||
id="ldb-filter-expand-panel"
|
||||
>
|
||||
<div className="ldb-filter-expand-inner">
|
||||
<div className="vm-filter-grid ldb-filter-expand-grid">
|
||||
{extraFields.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))}
|
||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
|
||||
{expanded ? extraFields.map(renderField) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="vm-filter-actions">
|
||||
{extraFields.length > 0 && (
|
||||
<button type="button" className="vm-btn vm-btn-link ldb-filter-toggle" onClick={() => setExpanded(!expanded)} aria-expanded={expanded}>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}>重置</button>
|
||||
</div>
|
||||
|
||||
<div className="vm-filter-actions ldb-filter-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link ldb-filter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="ldb-filter-expand-panel"
|
||||
>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
{extraActiveCount > 0 && !expanded ? (
|
||||
<span className="ldb-filter-toggle-badge" aria-label={`已设置 ${extraActiveCount} 项扩展筛选`}>
|
||||
{extraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Download, Upload, X } from 'lucide-react';
|
||||
|
||||
interface LeaseLedgerImportModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDownloadTemplate: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function LeaseLedgerImportModal({
|
||||
open,
|
||||
onClose,
|
||||
onDownloadTemplate,
|
||||
onImport,
|
||||
}: LeaseLedgerImportModalProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-lg ldb-import-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ldb-import-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="ldb-import-title" className="vm-modal-title">批量导入租赁业务台账</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-import-body">
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">1</span>
|
||||
<span>下载导入模板</span>
|
||||
</div>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate}>
|
||||
<Download size={16} aria-hidden />
|
||||
下载台账导入模板
|
||||
</button>
|
||||
</section>
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">2</span>
|
||||
<span>上传填写好的文件</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-upload-zone"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload size={28} aria-hidden />
|
||||
<p>点击选择文件上传</p>
|
||||
<p className="hint">支持 .csv、.xls、.xlsx</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,.xls,.xlsx"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onImport(file);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>;
|
||||
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<string, number>) {
|
||||
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<string, number> = {};
|
||||
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<typeof buildLeftStickyLayout>): 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<typeof buildLeftStickyLayout>,
|
||||
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<string, number> = {
|
||||
status: 88,
|
||||
billingYearMonth: 88,
|
||||
@@ -58,7 +102,7 @@ const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
|
||||
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)}
|
||||
</span>
|
||||
);
|
||||
case 'exemption-policy':
|
||||
if (!ctx.canEdit) {
|
||||
return <span title={row.exemptionPolicy}>{displayText(row.exemptionPolicy)}</span>;
|
||||
}
|
||||
return (
|
||||
<FilterPickerField
|
||||
value={row.exemptionPolicy}
|
||||
options={[...EXEMPTION_POLICY_OPTIONS]}
|
||||
placeholder="请选择享免政策"
|
||||
ariaLabel={`${row.plateNo} 享免政策`}
|
||||
onChange={(value) => ctx.onExemptionPolicyChange(row.id, value)}
|
||||
/>
|
||||
);
|
||||
case 'stack-dept':
|
||||
return (
|
||||
<div className="ldb-cell-stack">
|
||||
@@ -370,25 +429,25 @@ function renderCell(
|
||||
<span className="ldb-computed-val">{formatMoney(row.insuranceSurcharge)}</span>
|
||||
);
|
||||
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 (
|
||||
<LedgerDetailPopover
|
||||
label={formatMoney(row.opsFeeIncome)}
|
||||
title="客户承担维修保养明细"
|
||||
title="还车应结款运维费明细"
|
||||
triggerClassName="tabular-nums"
|
||||
>
|
||||
<LedgerDetailTable
|
||||
columns={[
|
||||
{ key: 'date', title: '日期' },
|
||||
{ key: 'plateNo', title: '车牌号' },
|
||||
{ key: 'type', title: '类型' },
|
||||
{ key: 'amount', title: '费用总计', align: 'right' },
|
||||
{ key: 'feeItem', title: '费用项' },
|
||||
{ key: 'amount', title: '金额', align: 'right' },
|
||||
]}
|
||||
rows={tableRows}
|
||||
/>
|
||||
@@ -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 (
|
||||
<LedgerDetailPopover
|
||||
label={formatMoney(row.opsFeeCost)}
|
||||
title="我司承担维修保养明细"
|
||||
title="维修保养明细"
|
||||
triggerClassName="tabular-nums"
|
||||
>
|
||||
<LedgerDetailTable
|
||||
@@ -414,7 +473,7 @@ function renderCell(
|
||||
{ key: 'date', title: '日期' },
|
||||
{ key: 'plateNo', title: '车牌号' },
|
||||
{ key: 'type', title: '类型' },
|
||||
{ key: 'amount', title: '费用总计', align: 'right' },
|
||||
{ key: 'amount', title: '费用总计(含税)', align: 'right' },
|
||||
]}
|
||||
rows={tableRows}
|
||||
/>
|
||||
@@ -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<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const [historyRow, setHistoryRow] = useState<LeaseLedgerRow | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...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<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (headerCheckRef.current) {
|
||||
headerCheckRef.current.indeterminate = someSelected;
|
||||
}
|
||||
}, [someSelected]);
|
||||
|
||||
const handleSort = useCallback((key: string) => {
|
||||
setSortKey((prev) => {
|
||||
@@ -738,8 +811,19 @@ export function LeaseLedgerTable({
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ldb-col-check sticky-col-left" data-annotation-id="lbl-col-check">
|
||||
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
||||
<th
|
||||
className="ldb-col-check sticky-col-left"
|
||||
style={CHECK_STICKY_HEAD_STYLE}
|
||||
data-annotation-id="lbl-col-check"
|
||||
>
|
||||
<input
|
||||
ref={headerCheckRef}
|
||||
type="checkbox"
|
||||
className="vm-checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) => onToggleAll(e.target.checked)}
|
||||
aria-label="全选"
|
||||
/>
|
||||
</th>
|
||||
{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}
|
||||
>
|
||||
<div className="ldb-th-inner">
|
||||
@@ -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 (
|
||||
<tr key={row.id}>
|
||||
<td className="ldb-col-check sticky-col-left">
|
||||
<td className="ldb-col-check sticky-col-left" style={CHECK_STICKY_BODY_STYLE}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="vm-checkbox"
|
||||
checked={selectedIds.has(row.id)}
|
||||
onChange={(e) => 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)}
|
||||
</td>
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="ldb-detail-popover-wrap" ref={wrapRef}>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`ldb-detail-popover-trigger ldb-cell-clickable cursor-pointer ${triggerClassName}`.trim()}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-controls={open ? dialogId : undefined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div id={panelId} className="ldb-detail-popover-card" role="dialog" aria-label={title ?? label}>
|
||||
{title && <div className="ldb-detail-popover-title">{title}</div>}
|
||||
<div className="ldb-detail-popover-body">{children}</div>
|
||||
{footer && <div className="ldb-detail-popover-footer">{footer}</div>}
|
||||
</div>
|
||||
{open && createPortal(
|
||||
<div className="vm-modal-backdrop ldb-detail-card-backdrop" role="presentation" onClick={close}>
|
||||
<div
|
||||
id={dialogId}
|
||||
className="vm-modal ldb-detail-card-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header ldb-detail-card-header">
|
||||
<h2 id={titleId} className="vm-modal-title">{title ?? label}</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={close} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-modal-body ldb-detail-card-body">
|
||||
<div className="ldb-detail-card-summary" aria-label="汇总">
|
||||
<span className="ldb-detail-card-summary-label">合计金额</span>
|
||||
<span className="ldb-detail-card-summary-value tabular-nums">{label}</span>
|
||||
</div>
|
||||
<div className="ldb-detail-card-content">{children}</div>
|
||||
</div>
|
||||
{footer && <footer className="vm-modal-footer ldb-detail-card-footer">{footer}</footer>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,27 +92,38 @@ export function LedgerDetailTable({ columns, rows, emptyText = '暂无明细' }:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ldb-detail-popover-table-wrap">
|
||||
<table className="ldb-detail-popover-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className={col.align === 'right' ? 'text-right' : ''}>{col.title}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index}>
|
||||
<>
|
||||
<p className="ldb-detail-card-meta">共 {rows.length} 条明细记录</p>
|
||||
<div className="ldb-detail-card-table-wrap">
|
||||
<table className="ldb-detail-popover-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={col.align === 'right' ? 'text-right tabular-nums' : ''}>
|
||||
{row[col.key] ?? ''}
|
||||
</td>
|
||||
<th
|
||||
key={col.key}
|
||||
className={col.align === 'right' ? 'ldb-detail-col-amount' : undefined}
|
||||
>
|
||||
{col.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={col.align === 'right' ? 'ldb-detail-col-amount tabular-nums' : undefined}
|
||||
>
|
||||
{row[col.key] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
): 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 (
|
||||
<>
|
||||
<p className="ldb-detail-card-meta">
|
||||
{rowCount > 0 ? `共 ${rowCount} 条明细记录` : '暂无明细,请添加记录'}
|
||||
</p>
|
||||
<div className="ldb-detail-card-table-wrap ldb-line-items-table-wrap">
|
||||
<table className="ldb-detail-popover-table ldb-line-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{showDetailColumns && <th>{reasonLabel}</th>}
|
||||
<th className="ldb-detail-col-amount">金额</th>
|
||||
{showDetailColumns && <th>备注</th>}
|
||||
<th className="ldb-line-items-col-action" aria-label="操作">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowCount === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={showDetailColumns ? 4 : 2} className="ldb-line-items-empty-cell">暂无明细</td>
|
||||
</tr>
|
||||
) : items.map((item, index) => (
|
||||
<tr key={index}>
|
||||
{showDetailColumns && (
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="vm-input ldb-line-items-input"
|
||||
value={item.reason}
|
||||
placeholder={reasonLabel}
|
||||
aria-label={`${ariaLabel} ${reasonLabel}`}
|
||||
onChange={(event) => onChange(updateItem(items, index, { reason: event.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="ldb-detail-col-amount">
|
||||
<input
|
||||
type="number"
|
||||
className="vm-input ldb-line-items-input ldb-line-items-input--amount tabular-nums"
|
||||
value={item.amount || ''}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
aria-label={`${ariaLabel} 金额`}
|
||||
onChange={(event) => onChange(updateItem(items, index, { amount: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
</td>
|
||||
{showDetailColumns && (
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="vm-input ldb-line-items-input"
|
||||
value={item.remark}
|
||||
placeholder="备注"
|
||||
aria-label={`${ariaLabel} 备注`}
|
||||
onChange={(event) => onChange(updateItem(items, index, { remark: event.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="ldb-line-items-col-action">
|
||||
<button
|
||||
type="button"
|
||||
className="ldb-line-items-row-delete"
|
||||
aria-label={`删除第 ${index + 1} 行`}
|
||||
onClick={() => onChange(items.filter((_, rowIndex) => rowIndex !== index))}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="ldb-line-items-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-line-items-add"
|
||||
onClick={() => onChange([...items, { ...EMPTY_ITEM }])}
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
添加一行
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<div className="ldb-line-items-editor">
|
||||
{displayItems.map((item, index) => (
|
||||
<div key={index} className="ldb-line-items-row">
|
||||
<input
|
||||
type="text"
|
||||
className="vm-input ldb-line-items-input"
|
||||
value={item.reason}
|
||||
placeholder={reasonLabel}
|
||||
aria-label={`${ariaLabel} ${reasonLabel}`}
|
||||
onChange={(e) => {
|
||||
const next = [...displayItems];
|
||||
next[index] = { ...next[index], reason: e.target.value };
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="vm-input ldb-line-items-input tabular-nums"
|
||||
value={item.amount || ''}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="金额"
|
||||
aria-label={`${ariaLabel} 金额`}
|
||||
onChange={(e) => {
|
||||
const next = [...displayItems];
|
||||
next[index] = { ...next[index], amount: Number(e.target.value) || 0 };
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="vm-input ldb-line-items-input"
|
||||
value={item.remark}
|
||||
placeholder="备注"
|
||||
aria-label={`${ariaLabel} 备注`}
|
||||
onChange={(e) => {
|
||||
const next = [...displayItems];
|
||||
next[index] = { ...next[index], remark: e.target.value };
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-ghost ldb-line-items-add"
|
||||
onClick={() => onChange([...displayItems, { ...EMPTY_ITEM }])}
|
||||
>
|
||||
添加一行
|
||||
</button>
|
||||
</div>
|
||||
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 ? (
|
||||
<LedgerLineItemsEditor
|
||||
items={displayItems}
|
||||
reasonLabel={reasonLabel}
|
||||
ariaLabel={ariaLabel}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : (
|
||||
<LedgerDetailTable
|
||||
columns={[
|
||||
{ key: 'reason', title: reasonLabel },
|
||||
{ key: 'amount', title: '金额', align: 'right' },
|
||||
{ key: 'remark', title: '备注' },
|
||||
]}
|
||||
columns={readColumns}
|
||||
rows={tableRows}
|
||||
emptyText="暂无明细"
|
||||
/>
|
||||
@@ -106,7 +179,7 @@ export function LedgerLineItemsCell({
|
||||
title={title}
|
||||
triggerClassName={canEdit ? 'ldb-line-items-trigger--editable' : ''}
|
||||
>
|
||||
{editor}
|
||||
{content}
|
||||
</LedgerDetailPopover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export function MaintenanceLinkModal({
|
||||
<td className="ldb-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="vm-checkbox"
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
aria-label={`选择 ${item.orderNo}`}
|
||||
|
||||
@@ -127,6 +127,7 @@ export function ReceiptLinkModal({
|
||||
<td className="ldb-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="vm-checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(item.id, item.amount)}
|
||||
aria-label={`选择到账记录 ${item.arrivalTime}`}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="lbl-toolbar">
|
||||
<label className="ldb-toolbar-unpaid-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnpaidOnly}
|
||||
onChange={(e) => {
|
||||
setShowUnpaidOnly(e.target.checked);
|
||||
setListViewMode(e.target.checked ? 'unpaid' : 'all');
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
仅显示未收款数据
|
||||
</label>
|
||||
<div className="ldb-toolbar-unpaid-group">
|
||||
<label
|
||||
className={`ldb-toolbar-unpaid-filter${showUnpaidOnly ? ' is-active' : ''}`}
|
||||
aria-label="仅显示未收款数据"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="vm-checkbox"
|
||||
checked={showUnpaidOnly}
|
||||
onChange={(e) => {
|
||||
setShowUnpaidOnly(e.target.checked);
|
||||
setListViewMode(e.target.checked ? 'unpaid' : 'all');
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<span>仅显示未收款数据</span>
|
||||
</label>
|
||||
<span
|
||||
className="ldb-toolbar-data-count"
|
||||
aria-label={showUnpaidOnly ? `未收款数据 ${toolbarListCount} 条` : `总数据 ${toolbarListCount} 条`}
|
||||
>
|
||||
共 <span className="ldb-toolbar-data-count-num tabular-nums">{toolbarListCount}</span> 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="vm-table-actions">
|
||||
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => showToast('批量导入(原型演示)')}>
|
||||
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => setImportOpen(true)}>
|
||||
<FileUp size={16} aria-hidden />
|
||||
批量导入
|
||||
</button>
|
||||
@@ -341,7 +410,8 @@ export default function LeaseBusinessLedgerApp() {
|
||||
<LeaseLedgerTable
|
||||
records={pagedRecords}
|
||||
loading={tableLoading}
|
||||
maintenanceOrders={MAINTENANCE_ORDERS}
|
||||
returnSettlementFees={RETURN_SETTLEMENT_FEES}
|
||||
maintenanceLedgerRecords={MAINTENANCE_LEDGER_RECORDS}
|
||||
receipts={receipts}
|
||||
hydrogenFees={HYDROGEN_FEES}
|
||||
selectedIds={selectedIds}
|
||||
@@ -399,21 +469,19 @@ export default function LeaseBusinessLedgerApp() {
|
||||
onBrokerageFeeChange={handleBrokerageFeeChange}
|
||||
onRemarkChange={handleRemarkChange}
|
||||
onDeductionDetailChange={handleDeductionDetailChange}
|
||||
onExemptionPolicyChange={handleExemptionPolicyChange}
|
||||
/>
|
||||
<div className="vm-table-footer ldb-table-footer">
|
||||
<span className="ldb-table-total">共 {listFiltered.length} 条</span>
|
||||
<div className="ldb-table-footer-actions">
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
total={listFiltered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
total={listFiltered.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -429,6 +497,12 @@ export default function LeaseBusinessLedgerApp() {
|
||||
onClose={() => setReceiptRow(null)}
|
||||
onConfirm={handleReceiptConfirm}
|
||||
/>
|
||||
<LeaseLedgerImportModal
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onDownloadTemplate={downloadLedgerBatchImportTemplate}
|
||||
onImport={handleBatchImport}
|
||||
/>
|
||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
src/prototypes/lease-business-ledger/utils/constants.ts
Normal file
23
src/prototypes/lease-business-ledger/utils/constants.ts
Normal file
@@ -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];
|
||||
@@ -0,0 +1,385 @@
|
||||
import type { BillArchiveStatus, LeaseLedgerRow } from '../types';
|
||||
|
||||
export interface LedgerImportRow {
|
||||
year: number;
|
||||
month: number;
|
||||
plateNo: string;
|
||||
patch: Partial<LeaseLedgerRow>;
|
||||
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<string, string> = {
|
||||
年份: '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<string, number>();
|
||||
|
||||
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<LeaseLedgerRow> = {};
|
||||
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<string, unknown>)[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, '');
|
||||
}
|
||||
@@ -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<string, { brand: string; model: string }> = {
|
||||
@@ -376,6 +379,48 @@ export function sumMaintenanceForBill(
|
||||
);
|
||||
}
|
||||
|
||||
export function getReturnSettlementFeesForBill(
|
||||
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||
records: ReturnSettlementFeeRecord[],
|
||||
): ReturnSettlementFeeRecord[] {
|
||||
const allowedItems = new Set<string>(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<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||
records: ReturnSettlementFeeRecord[],
|
||||
): number {
|
||||
return roundMoney(
|
||||
getReturnSettlementFeesForBill(row, records)
|
||||
.reduce((sum, item) => sum + item.amount, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMaintenanceLedgerRecordsForBill(
|
||||
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||
records: MaintenanceLedgerRecord[],
|
||||
): MaintenanceLedgerRecord[] {
|
||||
return records.filter(
|
||||
(item) => item.plateNo === row.plateNo
|
||||
&& isInBillMonth(item.repairDate, row.year, row.month),
|
||||
);
|
||||
}
|
||||
|
||||
export function sumMaintenanceLedgerOpsFeeCostForBill(
|
||||
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||
records: MaintenanceLedgerRecord[],
|
||||
): number {
|
||||
return roundMoney(
|
||||
getMaintenanceLedgerRecordsForBill(row, records)
|
||||
.reduce((sum, item) => sum + item.totalCost, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getHydrogenFeesForBill(
|
||||
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||
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;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-business-line-overview",
|
||||
"pageId": "overview",
|
||||
"updatedAt": 1783328981487,
|
||||
"updatedAt": 1783352657924,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lblo-system-intro",
|
||||
|
||||
@@ -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: '打开车辆维修明细',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "lease-contract-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981487,
|
||||
"updatedAt": 1783352657925,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "lc-list-filter",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
<OneosWebLegacyShell
|
||||
moduleTitle="财务管理"
|
||||
pages={pages}
|
||||
defaultPageId="u8d22u52a1u7ba1u7406-u8fd8u8f66u5e94u7ed3u6b3e-u"
|
||||
defaultPageId="u8d22u52a1u7ba1u7406-u63d0u8f66u5e94u6536u6b3e"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
@@ -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。已欠费站点不计入预警。
|
||||
|
||||
**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 字体
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
## KPI 口径(已确认)
|
||||
|
||||
全部 / 签约·普通 / **高频(≥3 次)** / **低频(1–2 次)** / **无加氢(0 次)**,与《站点管理使用说明书》及页面内嵌 PRD 一致。
|
||||
全部 / 签约·普通 / **预付余额预警** / **已欠费** / **无加氢(0 次)**,与 legacy《站点信息-产品需求说明》一致。
|
||||
|
||||
预警/欠费 KPI 点击打开站点列表弹窗,支持一键发起充值单。详见 `.spec/requirements-prd-kpi-balance-alert.md`。
|
||||
|
||||
## 角色演示
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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<AddPaymentFormValues>(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
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>客户名称 <em className="pr-required">*</em></span>
|
||||
<span>汇款账户名 <em className="pr-required">*</em></span>
|
||||
<Input
|
||||
value={form.customerName}
|
||||
placeholder="请输入客户名称"
|
||||
aria-label="客户名称"
|
||||
placeholder="请输入汇款账户名"
|
||||
aria-label="汇款账户名"
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, customerName: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>公司主体</span>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请选择公司主体"
|
||||
aria-label="公司主体"
|
||||
value={form.companyEntity || undefined}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, companyEntity: value || '' }))}
|
||||
allowClear
|
||||
showSearch
|
||||
options={companyEntityOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>银行</span>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请选择收款银行"
|
||||
aria-label="银行"
|
||||
value={form.bank || undefined}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, bank: value || '' }))}
|
||||
allowClear
|
||||
showSearch
|
||||
options={bankOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>金额(元) <em className="pr-required">*</em></span>
|
||||
<InputNumber
|
||||
|
||||
@@ -126,7 +126,7 @@ export function BillLinkModal({ open, record, billPool, onClose, onConfirm }: Bi
|
||||
title: '未收金额',
|
||||
key: 'unreceived',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
align: 'left',
|
||||
className: 'tabular-nums',
|
||||
render: (_, bill) => formatMoney(bill.unreceived),
|
||||
},
|
||||
@@ -134,7 +134,7 @@ export function BillLinkModal({ open, record, billPool, onClose, onConfirm }: Bi
|
||||
title: '本次认领',
|
||||
key: 'alloc',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
align: 'left',
|
||||
render: (_, bill) => {
|
||||
const key = `${bill.type}:${bill.id}`;
|
||||
const checked = selected.has(key);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import { DatePicker, Select } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
shouldShowFilterExpand,
|
||||
splitFilterFields,
|
||||
VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
||||
} from '../../../common/vm-filter-panel';
|
||||
import type { PaymentRecord, PaymentRecordFilters } from '../types';
|
||||
import { buildCustomerOptions, buildOperatorOptions } from '../utils/payment';
|
||||
import { buildBankOptions, buildCompanyEntityOptions, buildCustomerOptions, buildOperatorOptions } from '../utils/payment';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -15,6 +21,12 @@ const UNCLAIMED_OPTIONS = [
|
||||
{ value: 'no', label: '已全部关联' },
|
||||
];
|
||||
|
||||
interface FilterFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
control: React.ReactNode;
|
||||
}
|
||||
|
||||
interface FilterPanelProps {
|
||||
records: PaymentRecord[];
|
||||
filters: PaymentRecordFilters;
|
||||
@@ -23,8 +35,22 @@ interface FilterPanelProps {
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
function hasExtraFiltersActive(filters: PaymentRecordFilters): boolean {
|
||||
return Boolean(filters.operatorName.trim() || filters.unclaimed);
|
||||
}
|
||||
|
||||
export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) {
|
||||
const [expanded, setExpanded] = useState(() => hasExtraFiltersActive(filters));
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExtraFiltersActive(filters)) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [filters.operatorName, filters.unclaimed]);
|
||||
|
||||
const customerOptions = useMemo(() => buildCustomerOptions(records), [records]);
|
||||
const companyEntityOptions = useMemo(() => buildCompanyEntityOptions(records), [records]);
|
||||
const bankOptions = useMemo(() => buildBankOptions(records), [records]);
|
||||
const operatorOptions = useMemo(
|
||||
() => buildOperatorOptions(records).map((name) => ({ value: name, label: name })),
|
||||
[records],
|
||||
@@ -38,91 +64,182 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
||||
return null;
|
||||
}, [filters.paymentFrom, filters.paymentTo]);
|
||||
|
||||
const fields = useMemo<FilterFieldDef[]>(() => [
|
||||
{
|
||||
key: 'customerName',
|
||||
label: '汇款账户名',
|
||||
control: (
|
||||
<Select
|
||||
placeholder="请选择或输入汇款账户名"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.customerName || undefined}
|
||||
onChange={(value) => onChange({ customerName: value || '' })}
|
||||
allowClear
|
||||
showSearch
|
||||
options={customerOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyEntity',
|
||||
label: '公司主体',
|
||||
control: (
|
||||
<Select
|
||||
placeholder="请选择公司主体"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.companyEntity || undefined}
|
||||
onChange={(value) => onChange({ companyEntity: value || '' })}
|
||||
allowClear
|
||||
showSearch
|
||||
options={companyEntityOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bank',
|
||||
label: '银行',
|
||||
control: (
|
||||
<Select
|
||||
placeholder="请选择收款银行"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.bank || undefined}
|
||||
onChange={(value) => onChange({ bank: value || '' })}
|
||||
allowClear
|
||||
showSearch
|
||||
options={bankOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'paymentTime',
|
||||
label: '收款时间',
|
||||
control: (
|
||||
<RangePicker
|
||||
className="lc-filter-select pr-payment-range"
|
||||
style={{ width: '100%' }}
|
||||
picker="date"
|
||||
showTime={false}
|
||||
format={DATE_FORMAT}
|
||||
separator="至"
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
allowClear
|
||||
inputReadOnly
|
||||
value={dateRangeValue}
|
||||
onChange={(values) => {
|
||||
onChange({
|
||||
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
|
||||
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operatorName',
|
||||
label: '操作人',
|
||||
control: (
|
||||
<Select
|
||||
placeholder="全部操作人"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.operatorName || undefined}
|
||||
onChange={(value) => onChange({ operatorName: value || '' })}
|
||||
allowClear
|
||||
showSearch
|
||||
options={operatorOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'unclaimed',
|
||||
label: '未关联',
|
||||
control: (
|
||||
<Select
|
||||
placeholder="全部"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.unclaimed || undefined}
|
||||
onChange={(value) => onChange({ unclaimed: (value ?? '') as PaymentRecordFilters['unclaimed'] })}
|
||||
allowClear
|
||||
options={UNCLAIMED_OPTIONS.filter((o) => o.value !== '')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
], [
|
||||
bankOptions,
|
||||
companyEntityOptions,
|
||||
customerOptions,
|
||||
dateRangeValue,
|
||||
filters,
|
||||
onChange,
|
||||
operatorOptions,
|
||||
]);
|
||||
|
||||
const { primary, extra } = splitFilterFields(fields, VM_FILTER_PRIMARY_VISIBLE_COUNT);
|
||||
const showExpand = shouldShowFilterExpand(fields.length);
|
||||
const extraActiveCount = [filters.operatorName.trim(), filters.unclaimed].filter(Boolean).length;
|
||||
const expandPanelId = 'pr-filter-expand-panel';
|
||||
|
||||
const renderField = (field: FilterFieldDef) => (
|
||||
<label key={field.key} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="vm-filter-card" data-annotation-id="pr-filter" aria-label="筛选条件">
|
||||
<header className="vm-filter-header">
|
||||
<h2 className="vm-filter-title">筛选条件</h2>
|
||||
</header>
|
||||
<div className="vm-filter-grid">
|
||||
<label className="vm-filter-field">
|
||||
<span>客户名称</span>
|
||||
<Select
|
||||
placeholder="请选择或输入客户名称"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.customerName || undefined}
|
||||
onChange={(value) => onChange({ customerName: value || '' })}
|
||||
allowClear
|
||||
showSearch
|
||||
options={customerOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>收款时间</span>
|
||||
<RangePicker
|
||||
className="lc-filter-select pr-payment-range"
|
||||
style={{ width: '100%' }}
|
||||
picker="date"
|
||||
showTime={false}
|
||||
format={DATE_FORMAT}
|
||||
separator="至"
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
allowClear
|
||||
inputReadOnly
|
||||
value={dateRangeValue}
|
||||
onChange={(values) => {
|
||||
onChange({
|
||||
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
|
||||
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>操作人</span>
|
||||
<Select
|
||||
placeholder="全部操作人"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.operatorName || undefined}
|
||||
onChange={(value) => onChange({ operatorName: value || '' })}
|
||||
allowClear
|
||||
showSearch
|
||||
options={operatorOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</label>
|
||||
<label className="vm-filter-field">
|
||||
<span>未关联</span>
|
||||
<Select
|
||||
placeholder="全部"
|
||||
style={{ width: '100%' }}
|
||||
value={filters.unclaimed || undefined}
|
||||
onChange={(value) => onChange({ unclaimed: (value ?? '') as PaymentRecordFilters['unclaimed'] })}
|
||||
allowClear
|
||||
options={UNCLAIMED_OPTIONS.filter((o) => o.value !== '')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="vm-filter-body">
|
||||
<div className="vm-filter-grid vm-filter-grid--primary" data-filter-tier="primary">
|
||||
{primary.map(renderField)}
|
||||
</div>
|
||||
|
||||
{showExpand ? (
|
||||
<div
|
||||
className={`vm-filter-expand ${expanded ? 'is-expanded' : ''}`}
|
||||
aria-hidden={!expanded}
|
||||
id={expandPanelId}
|
||||
>
|
||||
<div className="vm-filter-expand-inner">
|
||||
<div className="vm-filter-grid vm-filter-expand-grid" data-filter-tier="extra">
|
||||
{expanded ? extra.map(renderField) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="vm-filter-actions">
|
||||
{showExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link vm-filter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expandPanelId}
|
||||
>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
{extraActiveCount > 0 && !expanded ? (
|
||||
<span className="vm-filter-toggle-badge" aria-label={`已设置 ${extraActiveCount} 项扩展筛选`}>
|
||||
{extraActiveCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown size={14} aria-hidden className={`vm-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
|
||||
<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
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<Search size={16} aria-hidden />
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -99,24 +99,24 @@ function ExportIcon() {
|
||||
const RECEIVED_COLUMNS: ColumnsType<PaymentReceivedDetailRow> = [
|
||||
{ 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<PaymentLinkedDetailRow> = [
|
||||
{ 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<PaymentUnlinkedDetailRow> = [
|
||||
{ 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) => (
|
||||
<span className={v > 0 ? 'pr-unlinked--pending' : undefined}>{formatMoney(v)}</span>
|
||||
) },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 140, ellipsis: true },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<AddPaymentModal
|
||||
open={addOpen}
|
||||
records={records}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onConfirm={handleAdd}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "self-operated-business-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981489,
|
||||
"updatedAt": 1783352657926,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sob-filter",
|
||||
|
||||
@@ -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: (
|
||||
<div className="ldb-date-range">
|
||||
<input type="date" className="vm-input" value={filters.dispatchFrom} aria-label="出车开始日期" onChange={(e) => onChange({ dispatchFrom: e.target.value })} />
|
||||
<span className="ldb-date-range-sep" aria-hidden>至</span>
|
||||
<input type="date" className="vm-input" value={filters.dispatchTo} aria-label="出车结束日期" onChange={(e) => onChange({ dispatchTo: e.target.value })} />
|
||||
</div>
|
||||
<DateRangeFilterField
|
||||
startDate={filters.dispatchFrom}
|
||||
endDate={filters.dispatchTo}
|
||||
ariaLabel="出车日期"
|
||||
startPlaceholder="开始日期"
|
||||
endPlaceholder="结束日期"
|
||||
onChange={({ startDate, endDate }) => onChange({ dispatchFrom: startDate, dispatchTo: endDate })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ label: '业务名称', control: <FilterPickerField value={filters.businessName} options={businessOptions} placeholder="请选择业务名称" ariaLabel="业务名称" onChange={(businessName) => onChange({ businessName })} /> },
|
||||
@@ -91,33 +95,45 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
||||
return (
|
||||
<section className="vm-filter-card ldb-filter-card" data-annotation-id="sob-filter" aria-label="筛选条件">
|
||||
<header className="vm-filter-header">
|
||||
<h1 className="ldb-page-title">自营业务台账</h1>
|
||||
<h2 className="vm-filter-title">筛选条件</h2>
|
||||
</header>
|
||||
<div className="vm-filter-grid">
|
||||
{primaryFields.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{extraFields.length > 0 && (
|
||||
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}>
|
||||
<div className="ldb-filter-expand-inner">
|
||||
<div className="vm-filter-grid ldb-filter-expand-grid">
|
||||
{extraFields.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="ldb-filter-body">
|
||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
|
||||
{primaryFields.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`} aria-hidden={!expanded}>
|
||||
<div className="ldb-filter-expand-inner">
|
||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
|
||||
{expanded
|
||||
? extraFields.map((field) => (
|
||||
<label key={field.label} className="vm-filter-field">
|
||||
<span>{field.label}</span>
|
||||
{field.control}
|
||||
</label>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="vm-filter-actions">
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="vm-filter-actions ldb-filter-actions">
|
||||
{extraFields.length > 0 && (
|
||||
<button type="button" className="vm-btn vm-btn-link ldb-filter-toggle" onClick={() => setExpanded(!expanded)} aria-expanded={expanded}>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link ldb-filter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '收起' : '更多筛选'}
|
||||
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||
</button>
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (headerCheckRef.current) {
|
||||
headerCheckRef.current.indeterminate = someSelected;
|
||||
}
|
||||
}, [someSelected]);
|
||||
|
||||
const handleSort = useCallback((key: string) => {
|
||||
setSortKey((prev) => {
|
||||
@@ -168,7 +176,13 @@ export function SelfLedgerTable({
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ldb-col-check sticky-col-left">
|
||||
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
||||
<input
|
||||
ref={headerCheckRef}
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) => onToggleAll(e.target.checked)}
|
||||
aria-label="全选"
|
||||
/>
|
||||
</th>
|
||||
{SELF_TABLE_COLUMNS.map((col) => (
|
||||
<th
|
||||
|
||||
@@ -146,7 +146,6 @@ export default function SelfOperatedBusinessLedgerApp() {
|
||||
onDelete={(row) => setDeleteTarget(row)}
|
||||
/>
|
||||
<div className="vm-table-footer ldb-table-footer">
|
||||
<span className="ldb-table-total">共 {filtered.length} 条</span>
|
||||
<div className="ldb-table-footer-actions">
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "supplier-management",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981489,
|
||||
"updatedAt": 1783352657926,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sm-list-filter",
|
||||
|
||||
@@ -223,7 +223,6 @@ export default function SupplierManagementApp() {
|
||||
onDelete={(record) => showToast(`删除:${record.supplierName}(原型演示)`)}
|
||||
/>
|
||||
<div className="vm-table-footer sm-table-footer">
|
||||
<div className="sm-table-total">共 {filtered.length} 条</div>
|
||||
<TablePagination
|
||||
page={Math.min(page, totalPages)}
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
.sm-table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "vehicle-h2-fee-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783328981490,
|
||||
"updatedAt": 1783352657927,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "vh2-header",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# 车辆维修明细 · 产品需求说明(PRD)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文档版本 | v1.1(对齐原型批注与现行页面) |
|
||||
| 模块名称 | 台账管理 — 车辆维修/保养明细 |
|
||||
| 交互原型 | `/prototypes/vehicle-maintenance-ledger` |
|
||||
| 修订说明 | 同步列表列顺序、列名、承担方式列移除、批量导入校验与失败导出 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
面向运维部人员手动录入车辆维修/保养明细,记录费用,并与租赁业务台账联动(客户承担 → 应收运维费;我司承担 → 运维费成本)。
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 主要用户 | 运维人员:批量导入、新增一行、填写费用、保存、确认提交 |
|
||||
| 运维主管 | 仅对已提交(归档)记录拥有编辑与删除权限 |
|
||||
| 录入方式 | 工具栏「批量导入」Excel/CSV,或表格底部「新增一行」手填 |
|
||||
| 导入后状态 | **待保存(未保存)**,可补全字段、**手动补传附件** |
|
||||
| 保存 | 将本人未提交记录标记为 **已保存** |
|
||||
| 确认提交 | 将本人 **已保存** 记录批量归档为 **已提交**;提交后运维人员不可再改 |
|
||||
| 附件 | 照片、Word、Excel、PDF 等多附件;未提交可补传;已提交默认只读,主管可增删 |
|
||||
| 数据权限 | **运维人员仅可操作本人记录**;**他人记录仅可查看** |
|
||||
| 导入导出 | 批量导入(xlsx/csv 模板)与按当前筛选结果导出 Excel |
|
||||
|
||||
### 状态流转
|
||||
|
||||
```
|
||||
批量导入 / 新增一行 → 待保存(未保存)→ 保存 → 已保存 → 确认提交 → 已提交(归档)
|
||||
```
|
||||
|
||||
### 原型验收提示
|
||||
|
||||
- 默认以运维人员「张运维」登录演示
|
||||
- 主管视角:URL 追加 `?role=supervisor`
|
||||
|
||||
---
|
||||
|
||||
## 2. 筛选区
|
||||
|
||||
| 筛选项 | 说明 |
|
||||
|---|---|
|
||||
| 进修日期 | 日期区间 |
|
||||
| 车牌号 | 可搜索下拉,精确匹配 |
|
||||
| 类型 | 多选:维修 / 保养 |
|
||||
| 提报人 | 多选 |
|
||||
|
||||
操作:**重置**、**查询**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 列表工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 批量导入 | 下载模板 → 上传文件 → 校验预览 → 确认导入 |
|
||||
| 导出 | 导出当前筛选结果 Excel |
|
||||
| 保存 | 将勾选且本人未提交记录标记为已保存 |
|
||||
| 确认提交 | 将勾选且本人已保存记录归档为已提交 |
|
||||
|
||||
列表上方提示:维修保养台账涉及租赁账单,请及时填报并确认提交。
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表字段(现行)
|
||||
|
||||
从左至右:
|
||||
|
||||
序号 → 状态 → **进修/保养日期** → 车牌号 → 类型 → 项目/材料 → 人工费(单位/数量/单价/金额)→ 配件费(单位/数量/单价/金额)→ 费用总计(含税)→ 附件 → **提报人** → **提报时间** → 操作
|
||||
|
||||
说明:
|
||||
|
||||
- **承担方式列已从列表移除**(2026-07 批注);承担方式仍可在导入模板字段中维护,供下游台账汇总口径使用。
|
||||
- **提报人、提报时间** 固定于操作列前(右侧固定列)。
|
||||
- 表头「进修日期」已更名为 **进修/保养日期**。
|
||||
|
||||
行操作:编辑、复制、删除(权限与状态约束)、更多(变更日志等)。
|
||||
|
||||
表格底部:**新增一行**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 批量导入
|
||||
|
||||
### 5.1 下载模板
|
||||
|
||||
- 支持 `.xlsx` / `.xls` / `.csv`
|
||||
- 「类型」列下方以 **序列** 展示可选值:`1.维修`、`2.保养`
|
||||
- 填写须与枚举 **完全匹配**,否则导入校验失败
|
||||
|
||||
### 5.2 上传与校验
|
||||
|
||||
- 上传后系统解析并区分 **可导入** / **失败** 条数
|
||||
- 预览列表最后一列 **备注**:
|
||||
- 通过:显示「可导入」
|
||||
- 失败:显示具体原因(如类型不匹配、车牌不在清单等)
|
||||
- 存在失败记录时可 **下载失败记录** Excel(含原字段 + 备注列)
|
||||
|
||||
### 5.3 确认导入
|
||||
|
||||
- 仅导入校验通过的行,状态为 **待保存(未保存)**
|
||||
- 导入后可补传附件,再执行保存与确认提交
|
||||
|
||||
---
|
||||
|
||||
## 6. 批注变更记录(2026-07)
|
||||
|
||||
| 序号 | 变更 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | 承担方式 | 先去除必填星号,后 **从列表移除承担方式列** |
|
||||
| 2 | 提报人 / 提报时间 | 移至 **操作列前** 两列(右侧固定) |
|
||||
| 3 | 进修/保养日期 | 列表表头「进修日期」改为 **进修/保养日期** |
|
||||
| 4 | 批量导入 | 模板类型枚举序列展示;导入结果预览 **备注** 列;失败记录可下载 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 源码入口
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `index.tsx` | 原型入口,挂载页面与 AnnotationViewer |
|
||||
| `MaintenanceLedgerPage.jsx` | 筛选、表格、导入导出、附件、状态流转 |
|
||||
| `annotation-source.json` | 标注目录与 PRD 节点 |
|
||||
| `styles/index.css` | 页面样式 |
|
||||
| `globals.ts` | React / antd / dayjs / XLSX 全局注入 |
|
||||
|
||||
预览地址:`http://localhost:51722/prototypes/vehicle-maintenance-ledger`
|
||||
2941
src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx
Normal file
2941
src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
406
src/prototypes/vehicle-maintenance-ledger/annotation-source.json
Normal file
406
src/prototypes/vehicle-maintenance-ledger/annotation-source.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
18
src/prototypes/vehicle-maintenance-ledger/globals.ts
Normal file
18
src/prototypes/vehicle-maintenance-ledger/globals.ts
Normal file
@@ -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;
|
||||
41
src/prototypes/vehicle-maintenance-ledger/index.tsx
Normal file
41
src/prototypes/vehicle-maintenance-ledger/index.tsx
Normal file
@@ -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<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'list',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaintenanceLedgerPage />
|
||||
<AnnotationViewer
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user