From 47c223a66666bd17fc9bc9a1f70c698b420e2b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=86=95?= Date: Thu, 16 Jul 2026 09:00:13 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E8=BF=AD=E4=BB=A3=20ONE-OS=20=E5=A4=9A?= =?UTF-8?q?=E5=8E=9F=E5=9E=8B=EF=BC=9A=E5=8A=A0=E6=B0=A2=E8=AE=B0=E5=BD=95?= =?UTF-8?q?/=E8=AE=A2=E5=8D=95=E5=90=8C=E6=BA=90=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=E3=80=81=E7=A7=9F=E8=B5=81=E6=98=8E=E7=BB=86=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E4=B8=8E=E6=9C=88=E5=BA=A6=E6=8D=9F=E7=9B=8A=E3=80=81=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E4=B8=8E=E5=8F=B0=E8=B4=A6=E5=A2=9E=E5=BC=BA=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=A2=E6=88=B7=E5=9B=9E=E6=AC=BE=E4=B8=8E?= =?UTF-8?q?=E5=8A=A0=E6=B0=A2=E7=AB=99=E7=BB=9F=E8=AE=A1=EF=BC=9B=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91=E4=B8=8E=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E5=AD=98=E5=82=A8=E5=8F=91=E5=B8=83=E8=A7=84=E8=8C=83?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=AD=A5=E5=8E=9F=E5=9E=8B=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .axhub/make/sidebar-tree.json | 42 +- .../rules/business-logic-documentation.mdc | 37 + .cursor/rules/cloud-publish-url.mdc | 31 + AGENTS.md | 7 + CLAUDE.md | 6 + rules/business-logic-documentation-guide.md | 135 + rules/cloud-publish-url-guide.md | 86 + rules/global-design-spec.md | 4 + rules/prototype-development-guide.md | 13 + rules/requirements-alignment-guide.md | 1 + scripts/publish-all-to-s3.mjs | 20 +- scripts/sync-oneos-web-prototypes.mjs | 4 +- src/common/OperationActions.tsx | 6 + src/common/h2VehicleLedgerBridge.js | 58 +- src/common/ln-numeric.css | 3 + src/common/vm-filter-panel.ts | 42 +- .../business-dept-ledger/styles/index.css | 873 ++- .../customer-payment-collection/index.tsx | 28 + .../pages/客户回款情况.jsx | 847 +++ .../InsuranceProcurement.jsx | 346 +- .../annotation-source.json | 4 +- .../insurance-procurement/styles/index.css | 65 + .../.spec/field-checks.md | 153 + .../.spec/prototype-review.md | 100 +- .../.spec/requirements-prd.md | 62 +- .../annotation-source.json | 45 +- .../lease-business-detail/columnHeaderTips.ts | 2 +- .../components/DetailEditModal.tsx | 18 +- .../components/DetailImportModal.tsx | 70 +- .../components/DetailMainTabs.tsx | 35 + .../components/DetailTable.tsx | 39 +- .../components/FilterPanel.tsx | 101 +- .../components/MonthlyPlTab.tsx | 194 + .../components/tableColumns.ts | 2 +- .../data/change-log-seed.json | 22 +- .../lease-business-detail/index.tsx | 143 +- .../scripts/sync-annotation-directory.mjs | 110 +- .../lease-business-detail/styles/index.css | 128 +- src/prototypes/lease-business-detail/types.ts | 6 - .../lease-business-detail/utils/batch-io.ts | 20 +- .../utils/calc-detail.ts | 2 - .../lease-business-detail/utils/change-log.ts | 18 - .../lease-business-detail/utils/detail.ts | 9 +- .../utils/import-merge.ts | 103 + .../lease-business-detail/utils/monthly-pl.ts | 146 + .../lease-business-detail/utils/row-key.ts | 8 + .../utils/validate-detail.ts | 28 + .../lease-business-line-overview/lines.ts | 6 +- src/prototypes/ledger-shared/DESIGN.md | 2 + .../.spec/2026-07-14-implementation-plan.md | 53 + .../.spec/2026-07-14-requirements-design.md | 113 + .../.spec/reconcile-linkage.md | 51 + .../.spec/requirements-prd.md | 57 + .../oneos-h5-h2-order/annotation-source.json | 93 + .../components/CreateWizard.tsx | 311 + .../oneos-h5-h2-order/components/Icons.tsx | 145 + .../components/OrderCard.tsx | 48 + .../components/OrderDetail.tsx | 88 + .../components/PhoneShell.tsx | 31 + src/prototypes/oneos-h5-h2-order/index.tsx | 308 + .../oneos-h5-h2-order/styles/index.css | 876 +++ src/prototypes/oneos-h5-h2-order/types.ts | 40 + .../oneos-h5-h2-order/utils/format.ts | 32 + .../oneos-h5-h2-order/utils/ocr-mock.ts | 39 + .../oneos-h5-h2-order/utils/orders.ts | 134 + .../oneos-prototype-nav/nav-data.ts | 6 +- .../oneos-prototype-nav/nav-menu.json | 53 +- .../prototype-registry.json | 5348 ++++++++++++++--- .../oneos-prototype-nav/xll-nav-menu.json | 2 +- .../oneos-web-data-analysis/index.tsx | 2 +- .../pages/05-业务部台账.jsx | 1954 ++++-- .../.spec/requirements-prd-list.md | 3 +- .../.spec/requirements-prd.md | 4 +- .../.spec/requirements.md | 5 +- .../.spec/requirements-prd.md | 49 + .../annotation-source.json | 122 + .../components/FilterPanel.tsx | 145 + .../components/StationDetailTable.tsx | 132 + .../components/StatsCharts.tsx | 353 ++ .../components/StatsKpiRow.tsx | 84 + .../components/SummaryInsight.tsx | 53 + .../data/stations.json | 938 +++ .../oneos-web-h2-station-stats/index.tsx | 198 + .../styles/index.css | 1111 ++++ .../oneos-web-h2-station-stats/types.ts | 65 + .../utils/export.ts | 142 + .../oneos-web-h2-station-stats/utils/stats.ts | 156 + .../oneos-web-h2-station-stats/utils/week.ts | 82 + .../.spec/record-data-model.md | 64 + .../.spec/requirements-prd.md | 48 + .../annotation-source.json | 87 + src/prototypes/oneos-web-h2-station/index.tsx | 12 +- .../pages/02-加氢记录.jsx | 639 +- .../pages/03-站点信息.jsx | 9 +- .../.spec/requirements-prd.md | 42 +- .../annotation-source.json | 18 +- .../components/CalcFormulaTip.tsx | 18 + .../components/FilterPanel.tsx | 5 +- .../components/PlateMultiSelectField.tsx | 153 + .../components/SelfLedgerTable.tsx | 15 +- .../scripts/build-annotation-source.mjs | 85 +- .../styles/index.css | 129 + .../utils/ledger.ts | 6 + .../.spec/requirements-prd-detail.md | 2 +- .../.spec/requirements-prd-list.md | 10 +- .../vehicle-management/annotation-source.json | 214 +- .../components/FilterPanel.tsx | 17 +- .../components/KpiCards.tsx | 8 +- .../components/OnLeaseFleetSection.tsx | 141 +- .../components/VehicleTable.tsx | 2 +- .../vehicle-management/data/vehicles.json | 30 +- src/prototypes/vehicle-management/index.tsx | 15 +- .../scripts/build-annotation-source.mjs | 21 +- src/prototypes/vehicle-management/style.css | 46 +- src/prototypes/vehicle-management/types.ts | 10 +- .../vehicle-management/utils/filters.ts | 2 + .../vehicle-management/utils/leaseRecords.ts | 4 +- .../vehicle-management/utils/onLeaseFleet.ts | 54 + .../vehicle-management/utils/testCasesData.ts | 8 +- .../vehicle-management/utils/vehicle.ts | 25 +- .../VehicleReturnSettlementV2App.jsx | 31 - .../VehicleReturnSettlementV2Detail.jsx | 370 -- .../VehicleReturnSettlementV2List.jsx | 371 -- .../vehicle-return-settlement-v2/index.tsx | 68 - .../styles/index.css | 431 -- .../vehicle-return-settlement-v2/vrsV2Data.js | 476 -- src/resources/design-system/DESIGN.md | 377 ++ src/resources/design-system/README.md | 50 + .../design-system/ai-prompt-template.md | 85 + src/resources/design-system/tokens.json | 114 + 130 files changed, 17507 insertions(+), 4461 deletions(-) create mode 100644 .cursor/rules/business-logic-documentation.mdc create mode 100644 .cursor/rules/cloud-publish-url.mdc create mode 100644 rules/business-logic-documentation-guide.md create mode 100644 rules/cloud-publish-url-guide.md create mode 100644 src/prototypes/customer-payment-collection/index.tsx create mode 100644 src/prototypes/customer-payment-collection/pages/客户回款情况.jsx create mode 100644 src/prototypes/lease-business-detail/.spec/field-checks.md create mode 100644 src/prototypes/lease-business-detail/components/DetailMainTabs.tsx create mode 100644 src/prototypes/lease-business-detail/components/MonthlyPlTab.tsx create mode 100644 src/prototypes/lease-business-detail/utils/import-merge.ts create mode 100644 src/prototypes/lease-business-detail/utils/monthly-pl.ts create mode 100644 src/prototypes/lease-business-detail/utils/row-key.ts create mode 100644 src/prototypes/lease-business-detail/utils/validate-detail.ts create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/2026-07-14-implementation-plan.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/2026-07-14-requirements-design.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md create mode 100644 src/prototypes/oneos-h5-h2-order/annotation-source.json create mode 100644 src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/components/Icons.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/components/PhoneShell.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/index.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/styles/index.css create mode 100644 src/prototypes/oneos-h5-h2-order/types.ts create mode 100644 src/prototypes/oneos-h5-h2-order/utils/format.ts create mode 100644 src/prototypes/oneos-h5-h2-order/utils/ocr-mock.ts create mode 100644 src/prototypes/oneos-h5-h2-order/utils/orders.ts create mode 100644 src/prototypes/oneos-web-h2-station-stats/.spec/requirements-prd.md create mode 100644 src/prototypes/oneos-web-h2-station-stats/annotation-source.json create mode 100644 src/prototypes/oneos-web-h2-station-stats/components/FilterPanel.tsx create mode 100644 src/prototypes/oneos-web-h2-station-stats/components/StationDetailTable.tsx create mode 100644 src/prototypes/oneos-web-h2-station-stats/components/StatsCharts.tsx create mode 100644 src/prototypes/oneos-web-h2-station-stats/components/StatsKpiRow.tsx create mode 100644 src/prototypes/oneos-web-h2-station-stats/components/SummaryInsight.tsx create mode 100644 src/prototypes/oneos-web-h2-station-stats/data/stations.json create mode 100644 src/prototypes/oneos-web-h2-station-stats/index.tsx create mode 100644 src/prototypes/oneos-web-h2-station-stats/styles/index.css create mode 100644 src/prototypes/oneos-web-h2-station-stats/types.ts create mode 100644 src/prototypes/oneos-web-h2-station-stats/utils/export.ts create mode 100644 src/prototypes/oneos-web-h2-station-stats/utils/stats.ts create mode 100644 src/prototypes/oneos-web-h2-station-stats/utils/week.ts create mode 100644 src/prototypes/oneos-web-h2-station/.spec/record-data-model.md create mode 100644 src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md create mode 100644 src/prototypes/oneos-web-h2-station/annotation-source.json create mode 100644 src/prototypes/self-operated-business-ledger/components/CalcFormulaTip.tsx create mode 100644 src/prototypes/self-operated-business-ledger/components/PlateMultiSelectField.tsx create mode 100644 src/prototypes/vehicle-management/utils/onLeaseFleet.ts delete mode 100644 src/prototypes/vehicle-return-settlement-v2/VehicleReturnSettlementV2App.jsx delete mode 100644 src/prototypes/vehicle-return-settlement-v2/VehicleReturnSettlementV2Detail.jsx delete mode 100644 src/prototypes/vehicle-return-settlement-v2/VehicleReturnSettlementV2List.jsx delete mode 100644 src/prototypes/vehicle-return-settlement-v2/index.tsx delete mode 100644 src/prototypes/vehicle-return-settlement-v2/styles/index.css delete mode 100644 src/prototypes/vehicle-return-settlement-v2/vrsV2Data.js create mode 100644 src/resources/design-system/DESIGN.md create mode 100644 src/resources/design-system/README.md create mode 100644 src/resources/design-system/ai-prompt-template.md create mode 100644 src/resources/design-system/tokens.json diff --git a/.axhub/make/sidebar-tree.json b/.axhub/make/sidebar-tree.json index b933e97..6385483 100644 --- a/.axhub/make/sidebar-tree.json +++ b/.axhub/make/sidebar-tree.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-07-12T17:06:54.186Z", + "updatedAt": "2026-07-14T15:10:00.000Z", "prototypes": [ { "id": "folder-1783875936186-27raza", @@ -31,12 +31,6 @@ "title": "财务管理(合包)", "itemKey": "prototypes/oneos-web-finance" }, - { - "id": "item:prototypes:oneos-web-h2-station", - "kind": "item", - "title": "加氢站管理", - "itemKey": "prototypes/oneos-web-h2-station" - }, { "id": "item:prototypes:oneos-web-help-center", "kind": "item", @@ -89,7 +83,7 @@ { "id": "folder-1782874599304-roj8fd", "kind": "folder", - "title": "运维管理", + "title": "车辆资产", "children": [ { "id": "item:prototypes:vehicle-management", @@ -153,6 +147,18 @@ "kind": "item", "title": "站点信息", "itemKey": "prototypes/oneos-web-h2-station-site" + }, + { + "id": "item:prototypes:oneos-h5-h2-order", + "kind": "item", + "title": "加氢订单", + "itemKey": "prototypes/oneos-h5-h2-order" + }, + { + "id": "item:prototypes:oneos-web-h2-station", + "kind": "item", + "title": "加氢记录", + "itemKey": "prototypes/oneos-web-h2-station" } ] }, @@ -164,8 +170,20 @@ { "id": "item:prototypes:business-dept-ledger", "kind": "item", - "title": "业务部台账", + "title": "项目盈亏情况", "itemKey": "prototypes/business-dept-ledger" + }, + { + "id": "item:prototypes:oneos-web-h2-station-stats", + "kind": "item", + "title": "加氢站数量统计", + "itemKey": "prototypes/oneos-web-h2-station-stats" + }, + { + "id": "item:prototypes:customer-payment-collection", + "kind": "item", + "title": "客户回款情况", + "itemKey": "prototypes/customer-payment-collection" } ] }, @@ -228,12 +246,6 @@ "kind": "item", "title": "还车应结款", "itemKey": "prototypes/vehicle-return-settlement" - }, - { - "id": "item:prototypes:vehicle-return-settlement-v2", - "kind": "item", - "title": "还车应结款-新", - "itemKey": "prototypes/vehicle-return-settlement-v2" } ] }, diff --git a/.cursor/rules/business-logic-documentation.mdc b/.cursor/rules/business-logic-documentation.mdc new file mode 100644 index 0000000..3eeacad --- /dev/null +++ b/.cursor/rules/business-logic-documentation.mdc @@ -0,0 +1,37 @@ +--- +description: 复杂业务/判断逻辑必须同步写入 PRD 与 .spec Markdown,并同步标注目录 +alwaysApply: true +--- + +# 业务逻辑文档化(全局强制) + +实现或修改**复杂判断逻辑**时,代码与文档**同一轮交付**。完整指南:`rules/business-logic-documentation-guide.md`。 + +## 触发条件(满足任一即必须文档化) + +多步判定链、跨模块校验、状态/公式推导、导入保存规则、权限分支、一键修复建议值。 + +## 必须更新的文件 + +1. **`src/prototypes//.spec/.md`** — 完整规则(判定顺序、数据源、边界、代码路径) +2. **`src/prototypes//.spec/requirements-prd.md`** — 摘要表 + §验收项 + 链接到规格全文 +3. **`annotation-source.json`** — 运行 `sync-annotation-directory.mjs` 同步目录/字段标注 +4. 字段级:`columnHeaderTips.ts` 或对应 `annotationText`(如有) + +## 规格文最低要求 + +- 判定**优先级顺序**(表格) +- **前置条件**(何时不校验) +- **数据源**(种子 JSON / 哪几个模块) +- 用户可见结果(通过/警告文案、是否可替换) +- 标明原型本地种子 vs 未接真实 API + +## 禁止 + +- 只改 `utils/*.ts` 不更新 PRD / `.spec` +- 口头解释替代 Markdown +- 验收通过时 PRD 与页面行为不一致 + +## 范例 + +`lease-business-detail`:`.spec/field-checks.md` + PRD §5 单元格系统校验 + 目录「单元格系统校验」 diff --git a/.cursor/rules/cloud-publish-url.mdc b/.cursor/rules/cloud-publish-url.mdc new file mode 100644 index 0000000..00e13ec --- /dev/null +++ b/.cursor/rules/cloud-publish-url.mdc @@ -0,0 +1,31 @@ +--- +description: 对象存储发布 URL 必须与 Make 工具一致,禁止改后缀或加 prototypes/ 前缀 +alwaysApply: true +--- + +# 对象存储发布 URL(全局强制) + +完整规范:`rules/cloud-publish-url-guide.md`。配置:`.axhub/make/axhub.config.json` → `cloudPublishing.s3`。 + +## 标准形态 + +```text +{baseUrl}/{prototype-id}/index.html +``` + +示例:`https://prototype.lnoneos.com/lease-business-detail/index.html` + +## 禁止(Agent 发布或写链接时) + +- 不得改为 `/prototypes/{id}/index.html` +- 不得去掉 `/index.html` 后缀 +- 不得自改 `baseUrl` 域名 +- 不得把本地 dev 路径 `/prototypes/...` 当作已发布链接写入 PRD/导航/registry + +## 脚本发布 + +使用 `scripts/publish-all-to-s3.mjs`;`resolveObjectPrefix` 已与 Make 对齐为扁平 `{prototype-id}`。 + +## 回报用户 + +发布成功后给出的 URL 须与 Make「已发布到对象存储」弹窗**完全一致**。 diff --git a/AGENTS.md b/AGENTS.md index 0c82f22..f9798eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ | 主题 | `src/themes//` | `rules/theme-guide.md` | | 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` | | 画布 | `src/prototypes//canvas.excalidraw`、`canvas-assets/` | 原型画布和画布素材 | +| 业务逻辑规格 | `src/prototypes//.spec/.md` | `rules/business-logic-documentation-guide.md` | +| 对象存储发布 URL | `.axhub/make/axhub.config.json` → `cloudPublishing.s3` | `rules/cloud-publish-url-guide.md` | | 原型标注布局 | `src/common/prototype-annotation-host.tsx` | `rules/prototype-annotation-layout-guide.md` | ## ⚠️ 重要原则 @@ -41,6 +43,11 @@ 6. **讲人话,用户不懂技术** - 用用户能理解的方式说明取舍、风险和结果;用户无法执行 CLI 命令,不得省略验收流程 - 向用户请求反馈或验收时,提醒用户尽量提供截图、预览链接、页面路径或具体问题位置,便于准确定位和复现 +7. **复杂业务逻辑必须文档化** + - 多步判定、跨模块校验、状态/公式推导、导入规则等,除代码外必须写入 `.spec/*.md` 与 `requirements-prd.md`,并同步标注目录(见 `rules/business-logic-documentation-guide.md`) + - 不得默认「先实现、后补文档」;逻辑变更与 PRD/标注更新同一轮交付 +8. **对象存储发布 URL 与 Make 工具一致** + - 发布或写链接时格式为 `{baseUrl}/{prototype-id}/index.html`;禁止加 `prototypes/` 前缀或去掉 `index.html`(见 `rules/cloud-publish-url-guide.md`) ## 项目结构 diff --git a/CLAUDE.md b/CLAUDE.md index d42b985..56bcfa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ | 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` | | UI Review 结论 | `src/prototypes//.spec/ui-review.md` | `rules/ui-review-guide.md` | | 原型 Review 结论 | `src/prototypes//.spec/prototype-review.md` | `rules/prototype-review-guide.md` | +| 业务逻辑规格 | `src/prototypes//.spec/.md` | `rules/business-logic-documentation-guide.md` | +| 对象存储发布 URL | `.axhub/make/axhub.config.json` → `cloudPublishing.s3` | `rules/cloud-publish-url-guide.md` | | ACP 对话缓存 | `src/prototypes//.spec/acp/` | 本地私有运行数据,不提交、不导出、不发布 | ## ⚠️ 重要原则 @@ -43,6 +45,10 @@ - 产品需求、设计方案或原型应尽早交给用户确认,不要等到全部完成后才暴露方向问题 6. **讲人话,用户不懂技术** - 用用户能理解的方式说明取舍、风险和结果;用户无法执行 CLI 命令,不得省略验收流程 +7. **复杂业务逻辑必须文档化** + - 多步判定、跨模块校验、状态/公式推导、导入规则等,除代码外必须写入 `.spec/*.md` 与 `requirements-prd.md`,并同步标注目录(见 `rules/business-logic-documentation-guide.md`) +8. **对象存储发布 URL 与 Make 工具一致** + - `{baseUrl}/{prototype-id}/index.html`;禁止擅自改路径后缀(见 `rules/cloud-publish-url-guide.md`) ## 项目结构 diff --git a/rules/business-logic-documentation-guide.md b/rules/business-logic-documentation-guide.md new file mode 100644 index 0000000..4c2aa28 --- /dev/null +++ b/rules/business-logic-documentation-guide.md @@ -0,0 +1,135 @@ +# 业务逻辑文档化指南(全局) + +凡原型或页面实现**复杂判断逻辑、跨模块校验、状态/公式推导、导入导出规则**时,除代码外**必须**同步写入 Markdown 与 PRD,供评审、标注目录与后续 Agent 读取。 + +参考范例:`src/prototypes/lease-business-detail/.spec/field-checks.md` + `requirements-prd.md` §5 单元格系统校验。 + +--- + +## 何时必须文档化 + +满足以下**任一**条件,即视为「复杂业务逻辑」,不得只留在代码或口头说明: + +| 类型 | 示例 | +|------|------| +| 多步判定 / 优先级链 | 客户名称 5 步校验、收款状态三态 | +| 跨模块对照 | 对照客户管理、车辆管理、合同台账 | +| 公式 / 聚合口径 | KPI、表尾 SUBTOTAL、应收租金 = 租金 × 月数 | +| 状态机 | 未收款 → 部分收款 → 已结清 | +| 导入 / 保存拦截 | 重复键策略、实收 ≤ 应收 | +| 权限 / 角色分支 | 主管可改、数据范围(若实现) | +| 一键修复 / 建议值 | 校验警告 + 替换动作 | + +纯样式、文案、布局微调**不**触发本指南;但若改动会改变上述判定结果,必须更新文档。 + +--- + +## 文档落点(三层) + +```text +src/prototypes// +├── .spec/requirements-prd.md # ① PRD:摘要 + 验收项(产品可读) +├── .spec/.md # ② 规格:完整判定顺序、数据源、边界(开发/评审) +├── annotation-source.json # ③ 标注:目录节点 + 页面/字段标注(经同步脚本) +└── columnHeaderTips.ts 等 # 可选:表头/字段级一句话提示 +``` + +### ① PRD(`requirements-prd.md`) + +在对应功能章节增加**摘要**,至少包含: + +- 业务目的(一句话) +- 判定顺序或状态表(表格优先) +- 数据源 / 对照模块 +- 用户可见结果(图标、文案、是否可一键修复) +- 链接到 `.spec/.md` 全文 + +在 **§验收重点** 增加可勾选项。 + +### ② 规格 Markdown(`.spec/.md`) + +完整规则文档,建议结构: + +```markdown +# <模块> · <逻辑名称> + +> 实现:`utils/xxx.ts`;UI:`components/xxx.tsx` + +## 对照数据源 +## 交互与状态(ok / warn / none) +## <字段或场景 A> 判定顺序(表格) +## <字段或场景 B> … +## 启用范围 / 与代码映射 +``` + +要求: + +- **判定顺序**写清优先级(命中即返回) +- **前置条件**写清何时不校验 +- **弱校验 / 强校验**区分标注 +- 标明原型种子 vs 真实 API(若未接后端) + +### ③ 标注目录(`annotation-source.json`) + +通过 `scripts/sync-annotation-directory.mjs`(或等价脚本)同步: + +| 同步目标 | 内容 | +|----------|------| +| `directory` → 规格说明 | 全文或 `markdownPath` 指向 `.spec/*.md` | +| 页面标注节点 | 功能区摘要(如列表工具栏、明细表) | +| 字段标注节点 | 单字段判定摘要(如客户名称) | +| `markdownMap` | 与节点 `annotationText` 一致 | + +**禁止**:代码已改、PRD/标注仍写旧逻辑。 + +--- + +## 实施顺序(与开发绑定) + +```text +实现 / 修改业务逻辑代码 + ↓ +编写或更新 .spec/.md + ↓ +更新 requirements-prd.md 摘要 + 验收项 + ↓ +更新表头提示 / 字段标注文案(如有) + ↓ +运行 sync-annotation-directory.mjs + ↓ +验收:目录与 PRD 可读、与页面行为一致 +``` + +逻辑变更与文档变更**同一轮交付**;不得「先合代码、后补文档」作为默认流程。 + +--- + +## 最小检查清单 + +- [ ] 复杂逻辑有独立 `.spec/*.md` 或 PRD 内等价完整章节 +- [ ] PRD 摘要含判定顺序表 / 状态表 +- [ ] PRD §验收 含可验证条目 +- [ ] `annotation-source.json` 目录已同步 +- [ ] 关键字段在「字段标注」或表头提示有一句业务说明 +- [ ] 文档标明数据来源(种子 / API / 手工列) +- [ ] 代码路径在 PRD §源码入口 或规格文首注明 + +--- + +## 反例 + +| ❌ 不做 | ✅ 应做 | +|--------|--------| +| 只在 `utils/field-checks.ts` 里写 5 层 if | 同步 `field-checks.md` + PRD §5 摘要 | +| 评审时口头解释客户名校验 | 目录「单元格系统校验」可点开全文 | +| 改判定顺序不更新 PRD | 同 PR 更新 `.spec` + 跑 sync 脚本 | +| 表头提示与真实逻辑矛盾 | 以 `.spec` 为单一真相,提示引用摘要 | + +--- + +## 关联规则 + +- 产品需求对齐:`rules/requirements-alignment-guide.md` +- 原型开发验收:`rules/prototype-development-guide.md` +- 标注布局:`rules/prototype-annotation-layout-guide.md` +- Agent 门禁:根目录 `AGENTS.md` diff --git a/rules/cloud-publish-url-guide.md b/rules/cloud-publish-url-guide.md new file mode 100644 index 0000000..3477242 --- /dev/null +++ b/rules/cloud-publish-url-guide.md @@ -0,0 +1,86 @@ +# 对象存储发布 URL 规范(全局) + +Make 客户端「发布到对象存储」与脚本/Agent 批量发布**必须使用同一套公网路径**,避免维护两套链接。 + +配置来源:`.axhub/make/axhub.config.json` → `cloudPublishing.s3` + +--- + +## 标准 URL 形态 + +```text +{baseUrl}/{objectPrefix}/index.html +``` + +| 项 | 默认值 | 示例 | +|---|---|---| +| `baseUrl` | `https://prototype.lnoneos.com` | — | +| 原型页 `objectPrefix` | `{prototype-id}` | `lease-business-detail` | +| 主题页 `objectPrefix` | `themes/{theme-id}` | `themes/claude` | +| 入口文件名 | **固定** `index.html` | 不得改为 `/`、`.html` 省略或 `index.htm` | + +**租赁业务明细(范例)** + +```text +https://prototype.lnoneos.com/lease-business-detail/index.html +``` + +--- + +## 禁止的改写 + +| ❌ 禁止 | 说明 | +|--------|------| +| 加 `prototypes/` 前缀 | 不得发布为 `/prototypes/lease-business-detail/index.html` | +| 去掉 `index.html` | 不得改为目录根 `/lease-business-detail/` | +| 改 `baseUrl` 域名 | 以 `axhub.config.json` 的 `s3.baseUrl` 为准 | +| 自造路径别名 | 仅 `pathAliases` 中已声明的 id 可例外 | + +--- + +## pathAliases(例外) + +仅在 `axhub.config.json` → `cloudPublishing.s3.pathAliases` 中显式配置时覆盖默认前缀,例如: + +```json +"pathAliases": { + "oneos-prototype-nav": "oneos-prototype-nav" +} +``` + +新增别名须**同时**更新: + +1. `axhub.config.json` → `pathAliases` +2. `src/prototypes/oneos-prototype-nav/nav-data.ts` → `CLOUD_PUBLISH_PATH_ALIASES` +3. `prototype-registry.json` 变更说明(如有) + +--- + +## 实现对照 + +| 场景 | 路径解析 | +|------|----------| +| Make 客户端发布 | `/{prototype-id}/index.html` | +| `scripts/publish-all-to-s3.mjs` | `resolveObjectPrefix()` → 同上 | +| 导航页 `resolvePrototypeHref()` | 云主机上 `/{segment}/index.html` | +| 本地开发 | `/prototypes/{prototype-id}`(仅 dev,不是发布 URL) | + +--- + +## Agent / AI 发布检查清单 + +发布或向用户回报链接前: + +- [ ] 使用 `axhub.config.json` 中的 `baseUrl` +- [ ] 原型链接以 `/index.html` 结尾 +- [ ] 未擅自添加 `prototypes/` 前缀 +- [ ] 与 Make 工具弹窗中的 URL 逐字一致 +- [ ] 未将 dev 路径(`/prototypes/...`)当作对象存储链接写入 PRD/导航 + +--- + +## 关联文件 + +- `scripts/publish-all-to-s3.mjs` — `resolveObjectPrefix`、`resolvePublicUrl` +- `src/prototypes/oneos-prototype-nav/nav-data.ts` — 静态站跳转 +- `.cursor/rules/cloud-publish-url.mdc` — Agent 强制规则 diff --git a/rules/global-design-spec.md b/rules/global-design-spec.md index 9c19dfc..923424d 100644 --- a/rules/global-design-spec.md +++ b/rules/global-design-spec.md @@ -1,5 +1,7 @@ # ONE-OS 全局设计规范(开发版) +> **对外分享请使用**:[`src/resources/design-system/`](../src/resources/design-system/README.md)(含 `DESIGN.md`、`tokens.json`、AI 提示模板)。本文件为项目内 Agent 规则副本。 + | 项 | 说明 | |---|---| | 文档版本 | v1.0 | @@ -160,6 +162,8 @@ import '../vehicle-management/style.css'; **折叠筛选** - 超过 4 项时其余放入 `vm-filter-expand`(见 `src/common/vm-filter-panel.ts`)。 +- 筛选项数组经 `splitFilterFields()` 拆为首行 + 展开区;首行固定最多 4 项,**禁止**首行放 5 项导致第二行孤零零 1 项。 +- 展开区项数不得为 4n+1(避免展开后末行仅 1 项);由 `splitFilterFields` 自动从首行借调平衡。 - 切换按钮:`vm-filter-toggle` + 角标 `vm-filter-toggle-badge`。 ### 4.2 输入框 `.vm-input` diff --git a/rules/prototype-development-guide.md b/rules/prototype-development-guide.md index 692bce3..378067f 100644 --- a/rules/prototype-development-guide.md +++ b/rules/prototype-development-guide.md @@ -105,6 +105,18 @@ export default function MyApp() { 参考:`vehicle-management/index.tsx`、`lease-business-ledger/index.tsx`。 +## 业务逻辑文档化(强制) + +实现**复杂判断逻辑**(多步判定、跨模块校验、状态/公式推导、导入保存规则等)时,必须与代码**同一轮**更新文档。详见 `rules/business-logic-documentation-guide.md`。 + +```text +.spec/.md # 完整规则(判定顺序、数据源、代码路径) +.spec/requirements-prd.md # 摘要 + 验收项 +annotation-source.json # sync-annotation-directory.mjs 同步目录/字段标注 +``` + +范例:`lease-business-detail/.spec/field-checks.md`。 + ## 验收流程 运行原型验收脚本: @@ -132,3 +144,4 @@ node scripts/check-app-ready.mjs /prototypes/[原型目录] - [ ] 占位原型已更新为有意义的目录名和显示名。 - [ ] 新增依赖已写入 `package.json`。 - [ ] `check-app-ready.mjs` 原型验收通过。 +- [ ] 若含复杂业务逻辑:`.spec` 规格文 + PRD 摘要 + 标注目录已同步(见 `business-logic-documentation-guide.md`)。 diff --git a/rules/requirements-alignment-guide.md b/rules/requirements-alignment-guide.md index c281407..e7027e7 100644 --- a/rules/requirements-alignment-guide.md +++ b/rules/requirements-alignment-guide.md @@ -46,6 +46,7 @@ - 本次范围、功能清单和不做什么。 - 页面或资源的核心内容、数据来源和素材来源。 - 关键状态、核心路径和必要交互。 +- **复杂判定逻辑**(校验链、状态机、公式口径、导入规则):在 PRD 中写摘要,并规划 `.spec/.md` 全文(见 `rules/business-logic-documentation-guide.md`)。 - 用户最终如何判断结果可用。 只问会影响范围、成本或验收的问题。能从上下文推断的内容直接记为假设继续;如果缺失信息会导致不同功能范围、不同页面结构或不同验收标准,必须先问。 diff --git a/scripts/publish-all-to-s3.mjs b/scripts/publish-all-to-s3.mjs index 57dcddd..2d92b5c 100644 --- a/scripts/publish-all-to-s3.mjs +++ b/scripts/publish-all-to-s3.mjs @@ -7,6 +7,7 @@ * node scripts/publish-all-to-s3.mjs * node scripts/publish-all-to-s3.mjs --dry-run * node scripts/publish-all-to-s3.mjs --group prototypes + * node scripts/publish-all-to-s3.mjs --ids vehicle-management,oneos-prototype-nav */ import fs from 'node:fs'; import os from 'node:os'; @@ -26,6 +27,7 @@ function parseArgs(argv) { adminOrigin: '', dryRun: false, group: 'all', + ids: [], includeSource: true, }; for (let i = 0; i < argv.length; i += 1) { @@ -33,6 +35,9 @@ function parseArgs(argv) { if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim(); else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim(); else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim(); + else if (arg === '--ids' && argv[i + 1]) { + args.ids = argv[++i].split(',').map((id) => id.trim()).filter(Boolean); + } else if (arg === '--dry-run') args.dryRun = true; else if (arg === '--no-source') args.includeSource = false; } @@ -69,8 +74,9 @@ function resolveObjectPrefix(entry, pathAliases = {}) { if (alias) return String(alias).replace(/^\/+|\/+$/gu, ''); const normalized = entry.path.replace(/^\/+|\/+$/gu, ''); - const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u); - if (prototypeMatch?.[1]) return `prototypes/${prototypeMatch[1]}`; + const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/([^/]+)$/u); + // 与 Make 客户端「发布到对象存储」一致:/{prototype-id}/index.html(不加 prototypes/ 前缀) + if (prototypeMatch?.[1]) return prototypeMatch[1]; const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u); if (themeMatch?.[1]) return `themes/${themeMatch[1]}`; return entry.id; @@ -227,7 +233,15 @@ async function main() { const adminOrigin = resolveAdminOrigin(args.adminOrigin); const s3Config = readS3Config(); const includeSource = args.includeSource && s3Config.includeSource; - const entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases); + let entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases); + if (args.ids.length > 0) { + const idSet = new Set(args.ids); + entries = entries.filter((entry) => idSet.has(entry.id)); + const missing = args.ids.filter((id) => !entries.some((entry) => entry.id === id)); + if (missing.length > 0) { + throw new Error(`未找到原型: ${missing.join(', ')}`); + } + } console.log(`Admin: ${adminOrigin}`); console.log(`Bucket: ${s3Config.bucket}`); diff --git a/scripts/sync-oneos-web-prototypes.mjs b/scripts/sync-oneos-web-prototypes.mjs index ce65f60..31edda0 100644 --- a/scripts/sync-oneos-web-prototypes.mjs +++ b/scripts/sync-oneos-web-prototypes.mjs @@ -23,8 +23,10 @@ const XLL_MODULES = [ /** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */ const STANDALONE_MODULES = [ { slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true }, + { slug: 'customer-payment-collection', title: '客户回款情况', skipCodegen: true }, { slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true }, { slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true }, + { slug: 'oneos-web-h2-station-stats', title: '加氢站数量统计', skipCodegen: true }, { slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true }, { slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true }, ]; @@ -55,7 +57,7 @@ const MODULES = [ { 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-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx', '客户回款情况.jsx'] }, { 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: '运维管理' }, diff --git a/src/common/OperationActions.tsx b/src/common/OperationActions.tsx index 7ece30b..04e7a45 100644 --- a/src/common/OperationActions.tsx +++ b/src/common/OperationActions.tsx @@ -5,12 +5,14 @@ import { ArrowRightLeft, Car, CircleDollarSign, + Download, Eye, FileText, MoreHorizontal, OctagonX, Pencil, RefreshCw, + Settings2, Trash2, Undo2, Upload, @@ -31,6 +33,7 @@ export type OperationActionItem = { const MORE_ACTION_ICONS: Record = { edit: Pencil, del: Trash2, + delete: Trash2, addVehicle: Car, renew: RefreshCw, authorized: FileText, @@ -41,6 +44,9 @@ const MORE_ACTION_ICONS: Record = { toFormal: ArrowRightLeft, stampSupplement: Upload, uploadStamped: Upload, + manage: Settings2, + preview: Eye, + download: Download, }; function renderMenuItemLabel(item: OperationActionItem) { diff --git a/src/common/h2VehicleLedgerBridge.js b/src/common/h2VehicleLedgerBridge.js index 06cb1ec..07eecda 100644 --- a/src/common/h2VehicleLedgerBridge.js +++ b/src/common/h2VehicleLedgerBridge.js @@ -61,11 +61,13 @@ function h2BridgeApplyStatementPatches(rows, patches) { if (!patch) return r; return Object.assign({}, r, { statementPatched: true, + reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: patch.reconcileDate, receiptDate: patch.receiptDate, stationPaymentStatus: patch.paymentStatus || 'paid', paymentStatus: patch.paymentStatus || 'paid', - reconciledAt: patch.reconcileDate + reconciledAt: patch.reconcileDate, + statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId }); }); } @@ -101,6 +103,40 @@ function h2BridgeCreateStore() { if (!patches || !patches.length) return; state.rows = h2BridgeApplyStatementPatches(state.rows, patches); notify(); + }, + upsertRow: function (row) { + if (!row) return null; + var next = Object.assign({}, row); + if (!next.id && !next.key) { + next.id = 'rf-h5-' + Date.now() + '-' + Math.floor(Math.random() * 1000); + next.key = next.id; + } else { + next.id = next.id || next.key; + next.key = next.key || next.id; + } + if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING; + var found = false; + state.rows = state.rows.map(function (r) { + if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) { + found = true; + return Object.assign({}, r, next); + } + return r; + }); + if (!found) state.rows = [next].concat(state.rows); + notify(); + return Object.assign({}, next); + }, + removeRow: function (id) { + var target = String(id || ''); + if (!target) return false; + var before = state.rows.length; + state.rows = state.rows.filter(function (r) { + return String(r.id || r.key || '') !== target; + }); + var changed = state.rows.length !== before; + if (changed) notify(); + return changed; } }; } @@ -154,17 +190,29 @@ function h2BridgeMapToStationLedgerRow(row, index) { function h2BridgeMapToHrRecord(row, index, fillerNames) { var fillers = fillerNames || ['张三', '李四', '王静', '赵敏', '陈浩']; + var costTotal = row.costTotal != null ? row.costTotal : row.costAmount; + var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice; + var totalAmount = costTotal != null ? costTotal : row.customerAmount; + var reconcileTime = row.reconcileDate || row.reconciledAt || ''; + var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(reconcileTime); return { id: row.id || row.key || ('hr-' + (index + 1)), hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime), plateNo: row.plateNo, hydrogenKg: row.hydrogenKg, - customerAmount: row.customerAmount, + unitPrice: unitPrice, + totalAmount: totalAmount, + /* 兼容旧列名:金额同站端口径 */ + customerAmount: totalAmount, + costUnitPrice: unitPrice, + costTotal: totalAmount, mileageKm: row.mileageKm, fillerName: row.creatorName || fillers[index % fillers.length], settlementStatus: row.settlementStatus, stationName: row.stationName, - stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '' + stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '', + reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING, + reconcileTime: isReconciled ? h2BridgeFormatDateTime(reconcileTime) : '' }; } @@ -263,6 +311,8 @@ function h2BridgeInit() { getHrRecords: h2BridgeGetHrRecords, getOrderRecords: h2BridgeGetOrderRecords, getStationList: h2BridgeGetStationList, + upsertRow: function (row) { return store.upsertRow(row); }, + removeRow: function (id) { return store.removeRow(id); }, subscribe: function (fn) { return store.subscribe(fn); } }; } @@ -274,6 +324,8 @@ h2BridgeInit(); export { H2_CANONICAL_LEDGER_SEED, H2_STATION_CODE_MAP, + H2_RECONCILE_RECONCILED, + H2_RECONCILE_PENDING, h2BridgeInit, h2BridgeGetStore, h2BridgeGetAllRefuelRecords, diff --git a/src/common/ln-numeric.css b/src/common/ln-numeric.css index 08a2b86..b7ff33e 100644 --- a/src/common/ln-numeric.css +++ b/src/common/ln-numeric.css @@ -42,6 +42,9 @@ } .vm-kpi-val, +.lbd-kpi-val, +.bdl-kpi-val, +.sob-kpi-val, .vm-mileage, .vm-expire-date, .vm-handover-situation-line--mile, diff --git a/src/common/vm-filter-panel.ts b/src/common/vm-filter-panel.ts index 979b35f..e5ef05d 100644 --- a/src/common/vm-filter-panel.ts +++ b/src/common/vm-filter-panel.ts @@ -1,7 +1,13 @@ /** * 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致) * - * 规则:筛选项超过 1 行(默认 4 项)时,其余收入折叠区,通过「更多筛选」展开。 + * ## 布局约束 + * 1. 收起时:首行(primary)最多展示 `VM_FILTER_COLUMNS_PER_ROW`(默认 4)项,占满 1 行。 + * 2. 展开时:其余项放入 `vm-filter-expand` / `ldb-filter-expand`;primary 与 extra 各自使用 4 列栅格。 + * 3. 禁止「末行仅 1 项」:若展开区项数为 4n+1,从首行尾部借调 1 项到展开区(首行变为 3 项)。 + * 典型:总项数 5 → 首行 3 + 展开 2;总项数 6 → 首行 4 + 展开 2。 + * + * 实现参考:`payment-records/components/FilterPanel.tsx`、`lease-business-ledger/components/FilterPanel.tsx` */ export const VM_FILTER_COLUMNS_PER_ROW = 4; @@ -12,13 +18,37 @@ export function splitFilterFields( fields: T[], primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT, ): { primary: T[]; extra: T[] } { - if (fields.length <= primaryCount) { + const columnsPerRow = primaryCount; + if (fields.length <= columnsPerRow) { return { primary: fields, extra: [] }; } - return { - primary: fields.slice(0, primaryCount), - extra: fields.slice(primaryCount), - }; + + const primary = fields.slice(0, columnsPerRow); + const extra = fields.slice(columnsPerRow); + + return rebalanceFilterSplit(primary, extra, columnsPerRow); +} + +/** 避免展开区出现 4n+1 项导致末行孤零零 1 项 */ +function rebalanceFilterSplit( + primary: T[], + extra: T[], + columnsPerRow: number, +): { primary: T[]; extra: T[] } { + const nextPrimary = [...primary]; + const nextExtra = [...extra]; + + while ( + nextExtra.length > 0 + && nextExtra.length % columnsPerRow === 1 + && nextPrimary.length > 0 + ) { + const moved = nextPrimary.pop(); + if (moved === undefined) break; + nextExtra.unshift(moved); + } + + return { primary: nextPrimary, extra: nextExtra }; } export function shouldShowFilterExpand( diff --git a/src/prototypes/business-dept-ledger/styles/index.css b/src/prototypes/business-dept-ledger/styles/index.css index 0a4b8fe..cd2fc41 100644 --- a/src/prototypes/business-dept-ledger/styles/index.css +++ b/src/prototypes/business-dept-ledger/styles/index.css @@ -7,6 +7,16 @@ padding-bottom: 48px; } +.vm-page.bdl-page.bdl-page--list-dense { + padding-left: 20px; + padding-right: 20px; +} + +.vm-page.bdl-page.bdl-page--list-dense .vm-table-section { + min-width: 0; + max-width: 100%; +} + .vm-page.bdl-page.bdl-page--list-dense .vm-filter-card { padding: 16px 20px 20px; margin-bottom: 16px; @@ -20,6 +30,301 @@ font-size: 1rem; } +/* —— 汇总 KPI 卡片(对齐租赁/物流业务明细 lbd-kpi / sob-kpi) —— */ +.vm-page.bdl-page .bdl-kpi-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-kpi-row { + margin-bottom: 12px; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-table-toolbar { + padding-bottom: 12px; +} + +@media (max-width: 900px) { + .vm-page.bdl-page .bdl-kpi-row { + grid-template-columns: 1fr; + } +} + +.vm-page.bdl-page .bdl-kpi-card { + --bdl-kpi-accent: var(--ln-primary); + --bdl-kpi-accent-soft: color-mix(in srgb, var(--bdl-kpi-accent) 12%, var(--ln-surface-card)); + --bdl-kpi-accent-border: color-mix(in srgb, var(--bdl-kpi-accent) 22%, var(--ln-hairline)); + --bdl-kpi-icon-bg: color-mix(in srgb, var(--bdl-kpi-accent) 14%, var(--ln-surface-card)); + --bdl-kpi-icon-color: var(--bdl-kpi-accent); + + position: relative; + display: flex; + min-height: 0; + border-radius: var(--ln-radius-control); + border: 1px solid var(--bdl-kpi-accent-border); + background: + linear-gradient( + 145deg, + var(--bdl-kpi-accent-soft) 0%, + var(--ln-surface-card) 58% + ); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + overflow: visible; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.vm-page.bdl-page .bdl-kpi-card:hover { + border-color: color-mix(in srgb, var(--bdl-kpi-accent) 38%, var(--ln-hairline)); + box-shadow: + 0 2px 6px rgba(15, 23, 42, 0.05), + 0 10px 24px rgba(15, 23, 42, 0.07); + transform: translateY(-1px); +} + +.vm-page.bdl-page .bdl-kpi-card-accent { + flex-shrink: 0; + width: 3px; + background: linear-gradient( + 180deg, + var(--bdl-kpi-accent) 0%, + color-mix(in srgb, var(--bdl-kpi-accent) 55%, transparent) 100% + ); +} + +.vm-page.bdl-page .bdl-kpi-card-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 6px 8px 6px 8px; +} + +.vm-page.bdl-page .bdl-kpi-card-foot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.vm-page.bdl-page .bdl-kpi-card-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.vm-page.bdl-page .bdl-kpi-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: var(--ln-radius-xs); + background: var(--bdl-kpi-icon-bg); + color: var(--bdl-kpi-icon-color); +} + +.vm-page.bdl-page .bdl-kpi-icon svg { + width: 14px; + height: 14px; +} + +.vm-page.bdl-page .bdl-kpi-label { + flex: 1; + min-width: 0; + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0; + font-size: 0.6875rem; + font-weight: 600; + color: var(--ln-muted); + line-height: 1.25; + letter-spacing: 0.01em; +} + +.vm-page.bdl-page .bdl-kpi-period { + font-weight: 500; + font-size: 0.625rem; + font-variant-numeric: tabular-nums; + color: color-mix(in srgb, var(--ln-muted) 88%, var(--ln-ink)); +} + +/* KPI、月份列、金额列:统一等宽数字字体(与 ldb-col-money 一致) */ +.vm-page.bdl-page .bdl-kpi-val, +.vm-page.bdl-page .bdl-kpi-period, +.vm-page.bdl-page .bdl-kpi-spark-tooltip-value, +.vm-page.bdl-page .bdl-list-table .bdl-col-month, +.vm-page.bdl-page .bdl-list-table .ldb-col-money { + font-family: var( + --vm-font-mono, + ui-monospace, + "JetBrains Mono", + SFMono-Regular, + Menlo, + Consolas, + monospace + ); + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + letter-spacing: 0; +} + +.vm-page.bdl-page .bdl-kpi-val { + margin: 0; + flex: 1 1 auto; + padding-left: 28px; + min-width: 0; + font-size: 1rem; + font-weight: 700; + color: var(--ln-ink); + line-height: 1.15; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-kpi-spark-wrap { + position: relative; + flex: 0 0 96px; + width: 96px; + height: 28px; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 4; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 108px; + max-width: 168px; + padding: 6px 8px; + border-radius: var(--ln-radius-xs); + background: var(--ln-ink); + color: #fff; + font-size: 0.625rem; + line-height: 1.35; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 4px 12px color-mix(in srgb, var(--ln-ink) 22%, transparent); + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease, visibility 0.12s ease; +} + +.vm-page.bdl-page .bdl-kpi-spark-wrap--active .bdl-kpi-spark-tooltip { + opacity: 1; + visibility: visible; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-scope { + display: block; + font-size: 0.5625rem; + font-weight: 500; + color: color-mix(in srgb, #fff 72%, transparent); + overflow: hidden; + text-overflow: ellipsis; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-row { + display: flex; + align-items: baseline; + gap: 4px; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-month { + font-weight: 600; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-metric { + color: color-mix(in srgb, #fff 78%, transparent); +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-value { + margin-left: auto; + font-weight: 700; +} + +.vm-page.bdl-page .bdl-kpi-sparkline { + display: block; + width: 100%; + height: 28px; + overflow: visible; + color: color-mix(in srgb, var(--ln-muted) 55%, transparent); +} + +.vm-page.bdl-page .bdl-kpi-spark-zero { + opacity: 0.35; +} + +.vm-page.bdl-page .bdl-kpi-spark-marker { + pointer-events: none; +} + +.vm-page.bdl-page .bdl-kpi-spark-hit { + cursor: crosshair; + outline: none; +} + +.vm-page.bdl-page .bdl-kpi-spark-hit:focus-visible { + outline: 1px solid var(--ln-focus, #2563eb); + outline-offset: -1px; +} + +@media (prefers-reduced-motion: reduce) { + .vm-page.bdl-page .bdl-kpi-spark-tooltip { + transition: none; + } +} + +.vm-page.bdl-page .bdl-kpi-val--profit { + color: var(--ln-error, #dc2626); +} + +.vm-page.bdl-page .bdl-kpi-val--loss { + color: var(--ln-success, #16a34a); +} + +.vm-page.bdl-page .bdl-kpi-card--income { + --bdl-kpi-accent: #2563eb; +} + +.vm-page.bdl-page .bdl-kpi-card--cost { + --bdl-kpi-accent: #7c3aed; +} + +.vm-page.bdl-page .bdl-kpi-card--profit { + --bdl-kpi-accent: #059669; +} + +.vm-page.bdl-page .bdl-kpi-card--profit.bdl-kpi-card--warn { + --bdl-kpi-accent: var(--ln-error, #dc2626); + --bdl-kpi-accent-soft: color-mix(in srgb, var(--ln-error, #dc2626) 10%, var(--ln-surface-card)); +} + +@media (prefers-reduced-motion: reduce) { + .vm-page.bdl-page .bdl-kpi-card { + transition: none; + } + + .vm-page.bdl-page .bdl-kpi-card:hover { + transform: none; + } +} + .vm-page.bdl-page .vm-filter-card.ldb-filter-card { margin-bottom: 16px; overflow: visible; @@ -84,6 +389,60 @@ padding: 0 0 16px; } +.vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar--with-view { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; +} + +@media (max-width: 900px) { + .vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar--with-view { + grid-template-columns: 1fr; + align-items: flex-start; + } +} + +.vm-page.bdl-page .bdl-list-view-switch { + flex-shrink: 0; +} + +.vm-page.bdl-page .bdl-view-tabs.ant-radio-group { + display: inline-flex; + width: auto; + gap: 0; + padding: 3px; + background: var(--ln-surface-strong); + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); +} + +.vm-page.bdl-page .bdl-view-tabs .ant-radio-button-wrapper { + flex: 0 0 auto; + min-width: 88px; + height: calc(var(--vm-control-height) - 6px); + line-height: calc(var(--vm-control-height) - 8px); + padding-inline: 14px; + text-align: center; + font-size: 13px; + font-weight: 500; + border: none !important; + border-radius: calc(var(--ln-radius-control) - 2px) !important; + background: transparent; + color: var(--ln-muted); + box-shadow: none !important; +} + +.vm-page.bdl-page .bdl-view-tabs .ant-radio-button-wrapper:not(:first-child)::before { + display: none; +} + +.vm-page.bdl-page .bdl-view-tabs .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { + font-weight: 600; + color: var(--ln-ink); + background: var(--ln-surface-card) !important; + box-shadow: var(--ln-shadow-soft) !important; +} + .vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar .vm-table-actions { margin-left: auto; } @@ -129,6 +488,12 @@ border: 1px solid var(--ln-hairline); border-radius: var(--ln-radius-card); background: var(--ln-surface-card); + box-shadow: var(--ln-shadow-soft); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-header { + box-shadow: 0 1px 0 var(--ln-hairline), 0 6px 16px -10px rgba(15, 23, 42, 0.14); + z-index: 5; } .vm-page.bdl-page .bdl-list-table.ant-table-wrapper, @@ -152,12 +517,30 @@ .vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td { white-space: nowrap; font-variant-numeric: tabular-nums; - padding: 8px 12px !important; + padding: 9px 12px !important; font-size: 13px; + line-height: 1.45; border-color: var(--ln-hairline) !important; box-sizing: border-box; overflow: visible; text-overflow: clip; + transition: background-color 0.15s ease; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr > th, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-tbody > tr:not(.ant-table-measure-row) > td, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-summary > tr > td { + padding: 7px 8px !important; + font-size: 12px; + line-height: 1.4; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:first-child > th { + font-size: 12px !important; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:nth-child(2) > th { + font-size: 11px !important; } .vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-thead > tr > th, @@ -165,6 +548,17 @@ .vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-summary > tr > td { border-inline-end: 1px solid var(--ln-hairline) !important; border-bottom: 1px solid var(--ln-hairline) !important; + border-top: none !important; + border-left: none !important; +} + +/* 分组表头:统一 1px 网格线,避免粗细分隔 */ +.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr > th { + border-color: var(--ln-hairline) !important; + border-inline-end-width: 1px !important; + border-bottom-width: 1px !important; + border-top-width: 0 !important; + border-left-width: 0 !important; } /* 分组表:第一行业务板块 */ @@ -174,7 +568,59 @@ font-size: 13px !important; font-weight: 600 !important; text-align: center; - border-bottom: 2px solid var(--ln-hairline-strong) !important; + border-bottom: 1px solid var(--ln-hairline) !important; + vertical-align: middle; +} + +.vm-page.bdl-page .bdl-sector-head-inner { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: center; + gap: 4px; + max-width: 100%; + line-height: 1.35; +} + +.vm-page.bdl-page .bdl-sector-head-label { + font-weight: 600; + color: var(--ln-ink); +} + +.vm-page.bdl-page .bdl-sector-head-source { + font-size: 11px; + font-weight: 400; + color: var(--ln-muted-soft); + white-space: nowrap; + padding: 1px 6px; + border-radius: var(--ln-radius-pill); + background: rgba(24, 24, 27, 0.04); +} + +.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:first-child > th.bdl-sector-group-head { + padding-top: 10px !important; + padding-bottom: 10px !important; + overflow: hidden; +} + +.vm-page.bdl-page .bdl-list-table--compact-cols .bdl-sector-head-source { + display: none; +} + +.vm-page.bdl-page .bdl-list-table--compact-cols .bdl-sector-head-inner { + justify-content: center; +} + +/* 业务板块分组:沿用统一 1px 网格,不再叠加加粗竖线 */ +.vm-page.bdl-page .bdl-list-table .bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:nth-child(2) > th.bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr > td.bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td.bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .bdl-summary-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:nth-child(2) > th.bdl-summary-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td.bdl-summary-edge { + border-left: none !important; + border-inline-start: none !important; } /* 分组表:第二行 业绩/成本/盈亏 */ @@ -199,16 +645,53 @@ background: var(--ln-surface-card); } +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td { + background: var(--ln-divider-soft); +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td { background: var(--ln-canvas-soft) !important; } +.vm-page.bdl-page .bdl-list-table .bdl-col-month, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.bdl-col-month { + font-weight: 600; + color: var(--ln-ink); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month > td.bdl-col-month { + font-weight: 500; + color: var(--ln-muted-80); +} + +.vm-page.bdl-page .bdl-month-total-label { + font-weight: 700; + color: var(--ln-ink); +} + +.vm-page.bdl-page .bdl-list-table .bdl-customer-name-col { + color: var(--ln-ink); + font-weight: 500; + max-width: 168px; +} + +.vm-page.bdl-page .bdl-list-table .bdl-division-name-col { + color: var(--ln-ink); + font-weight: 500; + max-width: 148px; +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td, .vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td { font-weight: 700; background: var(--ln-surface-strong) !important; color: var(--ln-ink) !important; border-top: 2px solid var(--ln-hairline-strong) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.bdl-col-month { + color: var(--ln-ink); } /* 冻结列(对齐 lc-list-table--nested) */ @@ -229,6 +712,11 @@ background: var(--ln-surface-card) !important; } +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-start, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-left { + background: var(--ln-divider-soft) !important; +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-start, .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-left { background: var(--ln-canvas-soft) !important; @@ -258,13 +746,27 @@ background: var(--ln-surface-card) !important; } +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt:hover > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar), +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt:hover > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) { + background: var(--ln-canvas-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar), +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) { + background: var(--ln-divider-soft) !important; +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar), .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) { background: var(--ln-canvas-soft) !important; } .vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-left-last::after { - box-shadow: inset 10px 0 8px -8px rgba(15, 23, 42, 0.12); + box-shadow: inset 1px 0 0 var(--ln-hairline); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-thead .ant-table-cell-fix-left-last::after { + box-shadow: none; } .vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-right-last::after, @@ -288,6 +790,7 @@ } .vm-page.bdl-page .bdl-list-table--nested .ant-table-cell-scrollbar { + display: none !important; width: 0 !important; min-width: 0 !important; max-width: 0 !important; @@ -300,18 +803,58 @@ overflow: hidden; } -.vm-page.bdl-page .bdl-list-table--nested .ant-table-content { - overflow: auto hidden; +/* 横向滚动交给 ant-table-body,勿在 content 层滚动(否则会破坏右侧冻结列) */ +.vm-page.bdl-page .bdl-list-table--nested .ant-table-body { + overflow: auto auto !important; } -/* 金额列最小宽度,避免合计行大额被裁切 */ +.vm-page.bdl-page .bdl-list-table--nested .ant-table-content { + overflow: visible !important; +} + +/* 金额列:宽度由 JS 按容器自适应,不在 CSS 中强制最小 128px */ .vm-page.bdl-page .bdl-list-table .col-amount, .vm-page.bdl-page .bdl-list-table .col-summary { - min-width: 128px; + min-width: 0; + letter-spacing: 0; } -.vm-page.bdl-page .bdl-list-table .col-summary { - min-width: 148px; +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:nth-child(2) > th.col-amount, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:nth-child(2) > th.col-amount-profit, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-tbody > tr:not(.ant-table-measure-row) > td.col-amount, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-tbody > tr:not(.ant-table-measure-row) > td.col-amount-profit, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-summary > tr > td.col-amount, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-summary > tr > td.col-amount-profit { + font-size: 10.5px !important; + line-height: 1.35; + padding-left: 5px !important; + padding-right: 5px !important; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .biz-standbook-perf-link { + font-size: inherit; +} + +/* 分组表头与表体铺满容器宽度,避免右侧留白 */ +.vm-page.bdl-page .bdl-list-table--fit-viewport.ant-table-wrapper, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-wrapper, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-container, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-content, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-sticky-holder, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-header { + width: 100% !important; + max-width: 100% !important; +} + +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-header table, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-header table[style], +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-body table, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-body table[style], +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-summary table, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-summary table[style] { + width: 100% !important; + min-width: 100% !important; + table-layout: fixed; } .vm-page.bdl-page .bdl-list-table .bdl-amount-cell { @@ -323,116 +866,316 @@ .vm-page.bdl-page .bdl-list-table .biz-standbook-perf-link, .vm-page.bdl-page .biz-standbook-perf-link { cursor: pointer; - color: var(--ln-primary); + color: inherit; padding: 0; margin: 0; border: none; background: transparent; font: inherit; border-radius: 4px; - transition: background 0.2s, color 0.2s; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + transition: text-decoration-color 0.2s, text-decoration-thickness 0.2s; max-width: none; overflow: visible; text-align: inherit; white-space: nowrap; } -/* 钻取子页表格:复用主表边框规范 */ -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-thead > tr > th, -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td, -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-summary > tr > td { - border-color: var(--ln-hairline) !important; - border-inline-end: 1px solid var(--ln-hairline) !important; - border-bottom: 1px solid var(--ln-hairline) !important; - padding: 8px 10px !important; - font-size: 13px; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table .ant-table-thead > tr > th { - background: var(--ln-canvas-soft) !important; - color: var(--ln-muted) !important; - font-weight: 500 !important; -} - -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table-wrap, -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table { +/* 钻取子页表格容器 */ +.vm-page.bdl-page .bdl-drill-card .vm-table-wrap.bdl-drill-table-wrap { border: 1px solid var(--ln-hairline); border-radius: var(--ln-radius-card); overflow: hidden; + background: var(--ln-surface-card); +} + +.vm-page.bdl-page .bdl-drill-card .bdl-list-table .ant-table-header { + box-shadow: 0 1px 0 var(--ln-hairline), 0 6px 16px -10px rgba(15, 23, 42, 0.14); + z-index: 5; } .vm-page.bdl-page .biz-standbook-perf-link:hover { - color: var(--ln-primary-focus); - background: var(--ln-primary-soft); + text-decoration-thickness: 2px; } -.vm-page.bdl-page .biz-standbook-perf-link:focus { - outline: 2px solid var(--ln-primary); +.vm-page.bdl-page .biz-standbook-perf-link:focus-visible { + outline: 2px solid var(--ln-hairline-strong); outline-offset: 2px; } .vm-page.bdl-page .bdl-amount-neg { - color: #b91c1c; + color: var(--ln-success); font-weight: 600; } +.vm-page.bdl-page .bdl-amount-pos { + color: var(--ln-error); + font-weight: 600; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total .bdl-amount-pos, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total .bdl-amount-neg, +.vm-page.bdl-page .bdl-list-table .ant-table-summary .bdl-amount-pos, +.vm-page.bdl-page .bdl-list-table .ant-table-summary .bdl-amount-neg { + font-weight: 700; +} + +/* 合计分组表头:右冻结三列 */ +.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:first-child > th.bdl-summary-group { + background: var(--ln-surface-strong) !important; + border-bottom: 2px solid var(--ln-hairline-strong) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:first-child > th.bdl-summary-group.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:first-child > th.bdl-summary-group.ant-table-cell-fix-right { + z-index: 5; + background: var(--ln-surface-strong) !important; +} + +.vm-page.bdl-page .bdl-list-table .bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .bdl-summary-col.ant-table-cell-fix-right { + background: var(--ln-surface-card) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.bdl-summary-col.ant-table-cell-fix-right { + z-index: 4; + background: var(--ln-canvas-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.bdl-summary-col.ant-table-cell-fix-right { + background: var(--ln-divider-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.bdl-summary-col.ant-table-cell-fix-right { + background: var(--ln-canvas-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td.bdl-summary-col, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr.row-total > td.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr.row-total > td.ant-table-cell-fix-right { + background: var(--ln-surface-strong) !important; + z-index: 3; +} + /* 钻取子页 */ .vm-page.bdl-page .bdl-drill-card { - padding: 16px 20px 20px; + padding: 0; + overflow: hidden; } .vm-page.bdl-page .bdl-drill-section { margin-top: 0; } -.vm-page.bdl-page .bdl-drill-toolbar { - align-items: center; - padding-bottom: 16px; - display: grid; - grid-template-columns: auto 1fr auto; - gap: 12px 16px; -} - -.vm-page.bdl-page .bdl-drill-toolbar-spacer { - width: 88px; -} - -.vm-page.bdl-page .bdl-drill-toolbar--inline { +.vm-page.bdl-page .bdl-drill-header { display: flex; - flex-wrap: wrap; - gap: 12px 16px; - padding-bottom: 16px; + align-items: flex-start; + gap: 14px 18px; + padding: 16px 18px 14px; + border-bottom: 1px solid var(--ln-hairline); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--ln-canvas-soft) 72%, var(--ln-surface-card)) 0%, + var(--ln-surface-card) 100% + ); } -.vm-page.bdl-page .bdl-drill-toolbar--inline .bdl-drill-title { - margin: 0; - flex: 1; - min-width: 0; +.vm-page.bdl-page .bdl-drill-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 0 12px; + flex-shrink: 0; + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-surface-card); + color: var(--ln-ink); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +.vm-page.bdl-page .bdl-drill-back-btn:hover { + border-color: color-mix(in srgb, var(--ln-primary) 28%, var(--ln-hairline)); + background: var(--ln-canvas-soft); +} + +.vm-page.bdl-page .bdl-drill-back-btn:focus-visible { + outline: 2px solid var(--ln-hairline-strong); + outline-offset: 2px; +} + +.vm-page.bdl-page .bdl-drill-back-label { + white-space: nowrap; } .vm-page.bdl-page .bdl-drill-heading { flex: 1; - text-align: center; - padding: 0 12px; + min-width: 0; +} + +.vm-page.bdl-page .bdl-drill-title-capsule { + display: inline-flex; + max-width: 100%; + padding: 6px 8px 6px 14px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--ln-primary) 14%, var(--ln-hairline)); + background: color-mix(in srgb, var(--ln-canvas-soft) 68%, var(--ln-surface-card)); + box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 55%, transparent); +} + +.vm-page.bdl-page .bdl-drill-title-row { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + min-width: 0; } .vm-page.bdl-page .bdl-drill-title { margin: 0; - font-size: 16px; + font-size: 15px; font-weight: 600; + line-height: 1.35; color: var(--ln-ink); + letter-spacing: -0.01em; + white-space: nowrap; } -.vm-page.bdl-page .bdl-drill-subtitle { - margin: 6px 0 0; - font-size: 13px; +.vm-page.bdl-page .bdl-drill-period { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--ln-primary) 22%, var(--ln-hairline)); + background: var(--ln-surface-card); + color: var(--ln-muted); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-drill-capsule-sep { + display: inline-block; + width: 1px; + height: 14px; + flex-shrink: 0; + background: color-mix(in srgb, var(--ln-muted) 28%, var(--ln-hairline)); +} + +.vm-page.bdl-page .bdl-drill-source { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + min-width: 0; + font-size: 12px; + line-height: 1.45; color: var(--ln-muted); } +.vm-page.bdl-page .bdl-drill-source-label { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border-radius: 999px; + border: 1px solid var(--ln-hairline); + background: var(--ln-surface-card); + color: var(--ln-muted); + font-weight: 600; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-drill-source-value { + color: var(--ln-ink); + font-weight: 500; +} + +.vm-page.bdl-page .bdl-drill-source-detail { + color: var(--ln-muted-80, var(--ln-muted)); +} + +.vm-page.bdl-page .bdl-drill-scope-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.vm-page.bdl-page .bdl-drill-scope-tag { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--ln-hairline); + background: var(--ln-surface-card); + font-size: 12px; +} + +.vm-page.bdl-page .bdl-drill-scope-tag-label { + color: var(--ln-muted); +} + +.vm-page.bdl-page .bdl-drill-scope-tag-value { + color: var(--ln-ink); + font-weight: 500; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-drill-card .vm-table-wrap.bdl-drill-table-wrap { + margin: 0; + border: none; + border-radius: 0; +} + +@media (max-width: 768px) { + .vm-page.bdl-page .bdl-drill-header { + flex-direction: column; + align-items: stretch; + } + + .vm-page.bdl-page .bdl-drill-back-btn { + align-self: flex-start; + } + + .vm-page.bdl-page .bdl-drill-title-capsule { + display: flex; + width: 100%; + } + + .vm-page.bdl-page .bdl-drill-title-row { + width: 100%; + } + + .vm-page.bdl-page .bdl-drill-scope-tag-value { + max-width: 160px; + } +} + @media (prefers-reduced-motion: reduce) { - .vm-page.bdl-page .biz-standbook-perf-link { + .vm-page.bdl-page .biz-standbook-perf-link, + .vm-page.bdl-page .bdl-drill-back-btn, + .vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th, + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td { transition: none; } } diff --git a/src/prototypes/customer-payment-collection/index.tsx b/src/prototypes/customer-payment-collection/index.tsx new file mode 100644 index 0000000..7609bab --- /dev/null +++ b/src/prototypes/customer-payment-collection/index.tsx @@ -0,0 +1,28 @@ +/** + * @name 客户回款情况 + * 自 ONE-OS Web 端 / 数据分析 / 客户回款情况 独立预览(OneOS 菜单入口) + */ +import '../../common/oneosWebLegacy/legacyGlobals'; +import React, { useEffect } from 'react'; +import * as antd from 'antd'; +import dayjs from 'dayjs'; +import { ConfigProvider } from 'antd'; +import 'antd/dist/reset.css'; +import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; +import CustomerPaymentCollectionPage from './pages/客户回款情况.jsx'; + +window.React = React; +window.antd = antd; +window.dayjs = dayjs; + +export default function CustomerPaymentCollection() { + useEffect(() => { + clearHostPrototypeRouteInfo(); + }, []); + + return ( + + + + ); +} diff --git a/src/prototypes/customer-payment-collection/pages/客户回款情况.jsx b/src/prototypes/customer-payment-collection/pages/客户回款情况.jsx new file mode 100644 index 0000000..eb84729 --- /dev/null +++ b/src/prototypes/customer-payment-collection/pages/客户回款情况.jsx @@ -0,0 +1,847 @@ +// 【重要】必须使用 const Component 作为组件变量名 +// 数据分析 - 客户回款情况 +// 默认展示当期客户应收/实收/期末余额;钻取至账单周期与多业务条线;实收变更实时联动期末余额 + +const Component = function () { + var useState = React.useState; + var useMemo = React.useMemo; + var useCallback = React.useCallback; + + var antd = window.antd; + var App = antd.App; + var Card = antd.Card; + var Button = antd.Button; + var Table = antd.Table; + var Select = antd.Select; + var DatePicker = antd.DatePicker; + var Row = antd.Row; + var Col = antd.Col; + var Space = antd.Space; + var Breadcrumb = antd.Breadcrumb; + var InputNumber = antd.InputNumber; + var Tag = antd.Tag; + var Statistic = antd.Statistic; + var message = antd.message; + + var LINE_DEFS = [ + { key: 'lease', label: '租赁业务' }, + { key: 'logistics', label: '物流业务' }, + { key: 'h2', label: '氢费业务' }, + { key: 'electricity', label: '电费业务' }, + { key: 'etc', label: 'ETC业务' } + ]; + + var LINE_KEYS = LINE_DEFS.map(function (d) { return d.key; }); + + var CUSTOMER_SEEDS = [ + { name: '嘉兴古道物流有限公司', dept: '业务二部', salesperson: '谈云' }, + { name: '杭州绿道城配科技有限公司', dept: '业务二部', salesperson: '刘念念' }, + { name: '宁波港联氢运物流有限公司', dept: '业务一部', salesperson: '谯云' }, + { name: '上海虹钦物流有限公司', dept: '业务一部', salesperson: '董剑煜' }, + { name: '嘉兴市乍浦港口经营有限公司', dept: '业务三部', salesperson: '尚建华' }, + { name: '荣达餐饮(广东)集团有限公司', dept: '业务三部', salesperson: '谈云' }, + { name: '苏州氢能城配科技有限公司', dept: '业务二部', salesperson: '刘念念' }, + { name: '无锡绿运供应链有限公司', dept: '业务一部', salesperson: '谯云' } + ]; + + function filterOption(input, option) { + var label = (option && (option.label || option.children)) || ''; + return String(label).toLowerCase().indexOf(String(input || '').toLowerCase()) >= 0; + } + + function fmtMoney(n) { + if (n === null || n === undefined || n === '') return '—'; + var x = Number(n); + if (isNaN(x)) return '—'; + return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + function numOrZero(v) { + if (v === null || v === undefined || v === '') return 0; + var n = Number(v); + return isNaN(n) ? 0 : n; + } + + function escapeCsv(v) { + var s = v == null ? '' : String(v); + if (s.indexOf(',') !== -1 || s.indexOf('"') !== -1 || s.indexOf('\n') !== -1) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; + } + + function downloadCsv(filename, lines) { + var csv = lines.map(function (row) { return row.map(escapeCsv).join(','); }).join('\n'); + var blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + function defaultPeriodMonth() { + try { + if (window.dayjs) return window.dayjs('2026-07-01'); + } catch (e1) {} + return null; + } + + function periodKeyFromMonth(m) { + if (!m || !m.format) return '2026-07'; + return m.format('YYYY-MM'); + } + + function periodLabel(key) { + if (!key || key.indexOf('-') < 0) return key || '—'; + var parts = key.split('-'); + return parts[0] + '年' + Number(parts[1]) + '月'; + } + + function lineLabel(key) { + var found = LINE_DEFS.find(function (d) { return d.key === key; }); + return found ? found.label : key; + } + + function sumLineAmounts(lines, field) { + var total = 0; + LINE_KEYS.forEach(function (k) { + total += numOrZero(lines && lines[k] && lines[k][field]); + }); + return total; + } + + function periodUncollected(lines) { + return sumLineAmounts(lines, 'receivable') - sumLineAmounts(lines, 'received'); + } + + function buildSeedRecords() { + var records = []; + var months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07']; + CUSTOMER_SEEDS.forEach(function (c, ci) { + var periods = months.map(function (pk, mi) { + var lines = {}; + LINE_KEYS.forEach(function (lk, li) { + var base = 28000 + ci * 4200 + mi * 1800 + li * 2600; + var recv = Math.round(base * (0.85 + (ci % 3) * 0.05)); + var got = recv - (mi % 4 === 0 && li % 2 === 0 ? Math.round(recv * 0.18) : Math.round(recv * 0.06)); + lines[lk] = { receivable: recv, received: got }; + }); + return { periodKey: pk, lines: lines }; + }); + records.push({ + id: 'cust-' + ci, + customerName: c.name, + dept: c.dept, + salesperson: c.salesperson, + periods: periods + }); + }); + return records; + } + + function enrichCustomerPeriods(customer) { + var sorted = (customer.periods || []).slice().sort(function (a, b) { + return String(a.periodKey).localeCompare(String(b.periodKey)); + }); + var cumulative = 0; + return sorted.map(function (p) { + var receivable = sumLineAmounts(p.lines, 'receivable'); + var received = sumLineAmounts(p.lines, 'received'); + var uncollected = receivable - received; + cumulative += uncollected; + return Object.assign({}, p, { + receivable: receivable, + received: received, + uncollected: uncollected, + endingBalance: cumulative + }); + }); + } + + function getCustomerPeriod(customer, periodKey) { + var enriched = enrichCustomerPeriods(customer); + return enriched.find(function (p) { return p.periodKey === periodKey; }) || null; + } + + function buildCustomerSummaryRows(records, periodKey, filters) { + return records.map(function (cust) { + var periods = enrichCustomerPeriods(cust); + var current = periods.find(function (p) { return p.periodKey === periodKey; }); + if (!current) return null; + if (filters.dept && cust.dept !== filters.dept) return null; + if (filters.salesperson && cust.salesperson !== filters.salesperson) return null; + if (filters.customer && cust.customerName !== filters.customer) return null; + if (filters.lineKey) { + var line = current.lines[filters.lineKey]; + if (!line || (line.receivable === 0 && line.received === 0)) return null; + } + return { + key: cust.id, + customerId: cust.id, + customerName: cust.customerName, + dept: cust.dept, + salesperson: cust.salesperson, + receivable: current.receivable, + received: current.received, + uncollected: current.uncollected, + endingBalance: current.endingBalance, + periodKey: periodKey + }; + }).filter(Boolean); + } + + function buildLineRows(customer, periodKey) { + var period = getCustomerPeriod(customer, periodKey); + if (!period) return []; + return LINE_DEFS.map(function (def) { + var line = period.lines[def.key] || { receivable: 0, received: 0 }; + var recv = numOrZero(line.receivable); + var got = numOrZero(line.received); + return { + key: def.key, + lineKey: def.key, + lineLabel: def.label, + receivable: recv, + received: got, + uncollected: recv - got + }; + }); + } + + function updateLineReceived(records, customerId, periodKey, lineKey, newReceived) { + return records.map(function (cust) { + if (cust.id !== customerId) return cust; + var periods = cust.periods.map(function (p) { + if (p.periodKey !== periodKey) return p; + var lines = Object.assign({}, p.lines); + var prev = lines[lineKey] || { receivable: 0, received: 0 }; + lines[lineKey] = { + receivable: prev.receivable, + received: Math.max(0, numOrZero(newReceived)) + }; + return Object.assign({}, p, { lines: lines }); + }); + return Object.assign({}, cust, { periods: periods }); + }); + } + + var layoutStyle = { padding: '16px 24px', background: '#f5f7fa', minHeight: '100vh' }; + var filterLabelStyle = { marginBottom: 6, fontSize: 14, color: 'rgba(0,0,0,0.65)' }; + var filterItemStyle = { marginBottom: 12 }; + var filterControlStyle = { width: '100%' }; + var drillLinkStyle = { cursor: 'pointer', color: '#165dff', border: 'none', background: 'none', padding: 0, font: 'inherit' }; + var moneyCellStyle = { fontVariantNumeric: 'tabular-nums' }; + var warnStyle = { color: '#cf1322', fontVariantNumeric: 'tabular-nums' }; + + var tableStyle = + '.cpr-table .ant-table-thead>tr>th{background:#e8f3ff!important;font-weight:500;font-size:13px}' + + '.cpr-table .ant-table-tbody>tr>td{white-space:nowrap}' + + '.cpr-table .ant-table-summary{background:#fafafa;font-weight:600}' + + '.cpr-kpi .ant-statistic-title{font-size:13px;color:rgba(0,0,0,0.55)}' + + '.cpr-kpi .ant-statistic-content{font-variant-numeric:tabular-nums}'; + + var recordsState = useState(buildSeedRecords); + var records = recordsState[0]; + var setRecords = recordsState[1]; + + var periodDraftState = useState(defaultPeriodMonth); + var periodDraft = periodDraftState[0]; + var setPeriodDraft = periodDraftState[1]; + var periodAppliedState = useState(defaultPeriodMonth); + var periodApplied = periodAppliedState[0]; + var setPeriodApplied = periodAppliedState[1]; + + var deptDraftState = useState(undefined); + var deptDraft = deptDraftState[0]; + var setDeptDraft = deptDraftState[1]; + var deptAppliedState = useState(undefined); + var deptApplied = deptAppliedState[0]; + var setDeptApplied = deptAppliedState[1]; + + var spDraftState = useState(undefined); + var spDraft = spDraftState[0]; + var setSpDraft = spDraftState[1]; + var spAppliedState = useState(undefined); + var spApplied = spAppliedState[0]; + var setSpApplied = spAppliedState[1]; + + var cuDraftState = useState(undefined); + var cuDraft = cuDraftState[0]; + var setCuDraft = cuDraftState[1]; + var cuAppliedState = useState(undefined); + var cuApplied = cuAppliedState[0]; + var setCuApplied = cuAppliedState[1]; + + var lineDraftState = useState(undefined); + var lineDraft = lineDraftState[0]; + var setLineDraft = lineDraftState[1]; + var lineAppliedState = useState(undefined); + var lineApplied = lineAppliedState[0]; + var setLineApplied = lineAppliedState[1]; + + var viewState = useState({ level: 'list', customerId: null, periodKey: null }); + var view = viewState[0]; + var setView = viewState[1]; + + var appliedPeriodKey = useMemo(function () { + return periodKeyFromMonth(periodApplied); + }, [periodApplied]); + + var filters = useMemo(function () { + return { + dept: deptApplied, + salesperson: spApplied, + customer: cuApplied, + lineKey: lineApplied + }; + }, [deptApplied, spApplied, cuApplied, lineApplied]); + + var summaryRows = useMemo(function () { + return buildCustomerSummaryRows(records, appliedPeriodKey, filters); + }, [records, appliedPeriodKey, filters]); + + var activeCustomer = useMemo(function () { + if (!view.customerId) return null; + return records.find(function (r) { return r.id === view.customerId; }) || null; + }, [records, view.customerId]); + + var customerPeriodRows = useMemo(function () { + if (!activeCustomer) return []; + return enrichCustomerPeriods(activeCustomer); + }, [activeCustomer]); + + var lineRows = useMemo(function () { + if (!activeCustomer || !view.periodKey) return []; + return buildLineRows(activeCustomer, view.periodKey); + }, [activeCustomer, view.periodKey]); + + var kpi = useMemo(function () { + var recv = 0; + var got = 0; + var endBal = 0; + summaryRows.forEach(function (r) { + recv += numOrZero(r.receivable); + got += numOrZero(r.received); + endBal += numOrZero(r.endingBalance); + }); + return { + customerCount: summaryRows.length, + receivable: recv, + received: got, + uncollected: recv - got, + endingBalance: endBal + }; + }, [summaryRows]); + + var deptOptions = useMemo(function () { + var set = {}; + records.forEach(function (r) { set[r.dept] = true; }); + return [{ value: '', label: '全部部门' }].concat(Object.keys(set).sort().map(function (d) { + return { value: d, label: d }; + })); + }, [records]); + + var spOptions = useMemo(function () { + var set = {}; + records.forEach(function (r) { set[r.salesperson] = true; }); + return [{ value: '', label: '全部业务员' }].concat(Object.keys(set).sort().map(function (s) { + return { value: s, label: s }; + })); + }, [records]); + + var cuOptions = useMemo(function () { + return [{ value: '', label: '全部客户' }].concat(records.map(function (r) { + return { value: r.customerName, label: r.customerName }; + })); + }, [records]); + + var lineOptions = useMemo(function () { + return [{ value: '', label: '全部条线' }].concat(LINE_DEFS.map(function (d) { + return { value: d.key, label: d.label }; + })); + }, []); + + var handleQuery = useCallback(function () { + setPeriodApplied(periodDraft); + setDeptApplied(deptDraft || undefined); + setSpApplied(spDraft || undefined); + setCuApplied(cuDraft || undefined); + setLineApplied(lineDraft || undefined); + setView({ level: 'list', customerId: null, periodKey: null }); + }, [periodDraft, deptDraft, spDraft, cuDraft, lineDraft]); + + var handleReset = useCallback(function () { + var p0 = defaultPeriodMonth(); + setPeriodDraft(p0); + setPeriodApplied(p0); + setDeptDraft(undefined); + setDeptApplied(undefined); + setSpDraft(undefined); + setSpApplied(undefined); + setCuDraft(undefined); + setCuApplied(undefined); + setLineDraft(undefined); + setLineApplied(undefined); + setView({ level: 'list', customerId: null, periodKey: null }); + }, []); + + var openCustomer = useCallback(function (customerId) { + setView({ level: 'customer', customerId: customerId, periodKey: null }); + }, []); + + var openPeriod = useCallback(function (periodKey) { + setView(function (prev) { + return Object.assign({}, prev, { level: 'period', periodKey: periodKey }); + }); + }, []); + + var backToList = useCallback(function () { + setView({ level: 'list', customerId: null, periodKey: null }); + }, []); + + var backToCustomer = useCallback(function () { + setView(function (prev) { + return Object.assign({}, prev, { level: 'customer', periodKey: null }); + }); + }, []); + + var handleLineReceivedChange = useCallback(function (lineKey, value) { + if (!activeCustomer || !view.periodKey) return; + var period = getCustomerPeriod(activeCustomer, view.periodKey); + if (!period) return; + var line = period.lines[lineKey] || { receivable: 0, received: 0 }; + var recv = numOrZero(line.receivable); + var got = numOrZero(value); + if (got > recv) { + message.warning('实收不能超过应收'); + return; + } + setRecords(function (prev) { + return updateLineReceived(prev, activeCustomer.id, view.periodKey, lineKey, got); + }); + }, [activeCustomer, view.periodKey]); + + var exportListCsv = useCallback(function () { + var headers = ['客户名称', '业务部门', '业务员', '统计周期', '应收', '实收', '未收', '期末余额']; + var lines = [headers]; + summaryRows.forEach(function (r) { + lines.push([ + r.customerName, r.dept, r.salesperson, periodLabel(appliedPeriodKey), + r.receivable, r.received, r.uncollected, r.endingBalance + ]); + }); + downloadCsv('客户回款情况_' + appliedPeriodKey + '.csv', lines); + message.success('已导出客户汇总'); + }, [summaryRows, appliedPeriodKey]); + + var exportCustomerCsv = useCallback(function () { + if (!activeCustomer) return; + var headers = ['账单周期', '应收', '实收', '未收', '期末余额']; + var lines = [headers]; + customerPeriodRows.forEach(function (r) { + lines.push([periodLabel(r.periodKey), r.receivable, r.received, r.uncollected, r.endingBalance]); + }); + downloadCsv(activeCustomer.customerName + '_账单周期回款.csv', lines); + message.success('已导出账单周期明细'); + }, [activeCustomer, customerPeriodRows]); + + var exportLineCsv = useCallback(function () { + if (!activeCustomer || !view.periodKey) return; + var headers = ['业务条线', '应收', '实收', '未收']; + var lines = [headers]; + lineRows.forEach(function (r) { + lines.push([r.lineLabel, r.receivable, r.received, r.uncollected]); + }); + downloadCsv(activeCustomer.customerName + '_' + view.periodKey + '_条线回款.csv', lines); + message.success('已导出条线明细'); + }, [activeCustomer, view.periodKey, lineRows]); + + var summaryFooter = useCallback(function () { + var recv = 0; + var got = 0; + var uncol = 0; + var endBal = 0; + summaryRows.forEach(function (r) { + recv += numOrZero(r.receivable); + got += numOrZero(r.received); + uncol += numOrZero(r.uncollected); + endBal += numOrZero(r.endingBalance); + }); + return React.createElement(Table.Summary, { fixed: true }, + React.createElement(Table.Summary.Row, null, + React.createElement(Table.Summary.Cell, { index: 0, colSpan: 3 }, '合计(' + summaryRows.length + ' 家客户)'), + React.createElement(Table.Summary.Cell, { index: 3, align: 'right' }, React.createElement('span', { style: moneyCellStyle }, fmtMoney(recv))), + React.createElement(Table.Summary.Cell, { index: 4, align: 'right' }, React.createElement('span', { style: moneyCellStyle }, fmtMoney(got))), + React.createElement(Table.Summary.Cell, { index: 5, align: 'right' }, React.createElement('span', { style: uncol > 0 ? warnStyle : moneyCellStyle }, fmtMoney(uncol))), + React.createElement(Table.Summary.Cell, { index: 6, align: 'right' }, React.createElement('span', { style: endBal > 0 ? warnStyle : moneyCellStyle }, fmtMoney(endBal))), + React.createElement(Table.Summary.Cell, { index: 7 }) + ) + ); + }, [summaryRows]); + + var listColumns = [ + { + title: '客户名称', + dataIndex: 'customerName', + key: 'customerName', + width: 220, + fixed: 'left', + render: function (v, row) { + return React.createElement('button', { + type: 'button', + className: 'cpr-drill-link', + style: drillLinkStyle, + onClick: function () { openCustomer(row.customerId); } + }, v); + } + }, + { title: '业务部门', dataIndex: 'dept', key: 'dept', width: 110 }, + { title: '业务员', dataIndex: 'salesperson', key: 'salesperson', width: 100 }, + { + title: '当期应收', + dataIndex: 'receivable', + key: 'receivable', + width: 120, + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '当期实收', + dataIndex: 'received', + key: 'received', + width: 120, + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '当期未收', + dataIndex: 'uncollected', + key: 'uncollected', + width: 120, + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + }, + { + title: '期末余额', + dataIndex: 'endingBalance', + key: 'endingBalance', + width: 130, + align: 'right', + render: function (v) { + return React.createElement('span', { + style: v > 0 ? warnStyle : moneyCellStyle, + title: '截至本期的累计未收余额(含历史各期未收之和)' + }, fmtMoney(v)); + } + }, + { + title: '操作', + key: 'action', + width: 88, + fixed: 'right', + render: function (_, row) { + return React.createElement(Button, { + type: 'link', + size: 'small', + onClick: function () { openCustomer(row.customerId); } + }, '钻取'); + } + } + ]; + + var periodColumns = [ + { + title: '账单周期', + dataIndex: 'periodKey', + key: 'periodKey', + width: 140, + render: function (v, row) { + return React.createElement('button', { + type: 'button', + style: drillLinkStyle, + onClick: function () { openPeriod(row.periodKey); } + }, periodLabel(v)); + } + }, + { + title: '应收', + dataIndex: 'receivable', + key: 'receivable', + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '实收', + dataIndex: 'received', + key: 'received', + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '未收', + dataIndex: 'uncollected', + key: 'uncollected', + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + }, + { + title: '期末余额', + dataIndex: 'endingBalance', + key: 'endingBalance', + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + }, + { + title: '操作', + key: 'action', + width: 120, + render: function (_, row) { + return React.createElement(Button, { + type: 'link', + size: 'small', + onClick: function () { openPeriod(row.periodKey); } + }, '条线明细'); + } + } + ]; + + var lineColumns = [ + { title: '业务条线', dataIndex: 'lineLabel', key: 'lineLabel', width: 120 }, + { + title: '应收', + dataIndex: 'receivable', + key: 'receivable', + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '实收', + dataIndex: 'received', + key: 'received', + align: 'right', + width: 160, + render: function (v, row) { + return React.createElement(InputNumber, { + min: 0, + max: numOrZero(row.receivable), + step: 100, + value: numOrZero(v), + formatter: function (val) { return val == null ? '' : String(val); }, + parser: function (val) { return val ? val.replace(/,/g, '') : ''; }, + style: { width: '100%' }, + onChange: function (val) { handleLineReceivedChange(row.lineKey, val); } + }); + } + }, + { + title: '未收', + dataIndex: 'uncollected', + key: 'uncollected', + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + } + ]; + + var breadcrumbItems = useMemo(function () { + var items = [{ title: React.createElement('button', { type: 'button', style: drillLinkStyle, onClick: backToList }, '客户回款情况') }]; + if (view.level !== 'list' && activeCustomer) { + items.push({ + title: view.level === 'customer' + ? activeCustomer.customerName + : React.createElement('button', { type: 'button', style: drillLinkStyle, onClick: backToCustomer }, activeCustomer.customerName) + }); + } + if (view.level === 'period' && view.periodKey) { + items.push({ title: periodLabel(view.periodKey) }); + } + return items; + }, [view, activeCustomer, backToList, backToCustomer]); + + var pageTitle = view.level === 'list' + ? '客户回款情况' + : view.level === 'customer' + ? activeCustomer ? activeCustomer.customerName + ' · 账单周期' : '账单周期' + : '业务条线回款明细'; + + var pageHint = view.level === 'list' + ? '默认展示当期(' + periodLabel(appliedPeriodKey) + ')全部客户的应收、实收与期末余额;期末余额为截至当期的累计未收。' + : view.level === 'customer' + ? '按账单周期查看该客户各期回款;期末余额逐期累加历史未收。' + : '调整实收后,当期未收与本客户后续各期期末余额将实时重算。'; + + var exportHandler = view.level === 'list' ? exportListCsv : view.level === 'customer' ? exportCustomerCsv : exportLineCsv; + + var tableData = view.level === 'list' + ? summaryRows + : view.level === 'customer' + ? customerPeriodRows + : lineRows; + + var tableColumns = view.level === 'list' + ? listColumns + : view.level === 'customer' + ? periodColumns + : lineColumns; + + var activePeriodSummary = useMemo(function () { + if (!activeCustomer || !view.periodKey) return null; + return getCustomerPeriod(activeCustomer, view.periodKey); + }, [activeCustomer, view.periodKey, records]); + + return React.createElement(App, null, + React.createElement('style', null, tableStyle), + React.createElement('div', { style: layoutStyle }, + React.createElement(Card, { + bordered: false, + style: { marginBottom: 16 }, + title: pageTitle, + extra: React.createElement(Space, null, + view.level !== 'list' ? React.createElement(Button, { onClick: view.level === 'period' ? backToCustomer : backToList }, '返回上一级') : null, + React.createElement(Button, { onClick: exportHandler }, '导出 CSV') + ) + }, + React.createElement(Breadcrumb, { items: breadcrumbItems, style: { marginBottom: 12 } }), + React.createElement('div', { style: { marginBottom: 16, color: 'rgba(0,0,0,0.55)', fontSize: 13 } }, pageHint), + view.level === 'list' ? React.createElement(Row, { gutter: 16, style: { marginBottom: 16 }, className: 'cpr-kpi' }, + React.createElement(Col, { xs: 12, sm: 8, md: 4 }, + React.createElement(Statistic, { title: '客户数', value: kpi.customerCount, suffix: '家' }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '当期应收合计', value: kpi.receivable, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '当期实收合计', value: kpi.received, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '当期未收合计', value: kpi.uncollected, precision: 2, suffix: '元', valueStyle: kpi.uncollected > 0 ? { color: '#cf1322' } : undefined }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '期末余额合计', value: kpi.endingBalance, precision: 2, suffix: '元', valueStyle: kpi.endingBalance > 0 ? { color: '#cf1322' } : undefined }) + ) + ) : null, + view.level === 'period' && activePeriodSummary ? React.createElement(Row, { gutter: 16, style: { marginBottom: 16 }, className: 'cpr-kpi' }, + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '本期应收', value: activePeriodSummary.receivable, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '本期实收', value: activePeriodSummary.received, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '本期未收', value: activePeriodSummary.uncollected, precision: 2, suffix: '元', valueStyle: activePeriodSummary.uncollected > 0 ? { color: '#cf1322' } : undefined }) + ), + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '期末余额', value: activePeriodSummary.endingBalance, precision: 2, suffix: '元', valueStyle: activePeriodSummary.endingBalance > 0 ? { color: '#cf1322' } : undefined }) + ) + ) : null, + view.level === 'list' ? React.createElement(Card, { + size: 'small', + style: { marginBottom: 16, background: '#fafafa' }, + title: '筛选条件' + }, + React.createElement(Row, { gutter: 16 }, + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '统计周期'), + React.createElement(DatePicker, { + picker: 'month', + style: filterControlStyle, + value: periodDraft, + onChange: setPeriodDraft, + allowClear: false + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '客户名称'), + React.createElement(Select, { + showSearch: true, + allowClear: true, + placeholder: '全部客户', + style: filterControlStyle, + options: cuOptions, + value: cuDraft, + onChange: setCuDraft, + filterOption: filterOption + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '业务部门'), + React.createElement(Select, { + allowClear: true, + placeholder: '全部部门', + style: filterControlStyle, + options: deptOptions, + value: deptDraft, + onChange: setDeptDraft + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '业务员'), + React.createElement(Select, { + showSearch: true, + allowClear: true, + placeholder: '全部业务员', + style: filterControlStyle, + options: spOptions, + value: spDraft, + onChange: setSpDraft, + filterOption: filterOption + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '业务条线'), + React.createElement(Select, { + allowClear: true, + placeholder: '全部条线', + style: filterControlStyle, + options: lineOptions, + value: lineDraft, + onChange: setLineDraft + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: Object.assign({}, filterItemStyle, { paddingTop: 28 }) }, + React.createElement(Space, null, + React.createElement(Button, { type: 'primary', onClick: handleQuery }, '查询'), + React.createElement(Button, { onClick: handleReset }, '重置') + ) + ) + ) + ) + ) : null, + view.level === 'period' ? React.createElement(Tag, { color: 'processing', style: { marginBottom: 12 } }, '可在「实收」列直接修改,期末余额将实时联动') : null, + React.createElement(Table, { + className: 'cpr-table', + size: 'small', + bordered: true, + scroll: { x: view.level === 'list' ? 980 : 720 }, + columns: tableColumns, + dataSource: tableData, + pagination: view.level === 'list' ? { pageSize: 10, showSizeChanger: true, showTotal: function (t) { return '共 ' + t + ' 条'; } } : false, + summary: view.level === 'list' ? summaryFooter : undefined, + locale: { emptyText: '暂无符合条件的数据' } + }) + ) + ) + ); +}; + +export default Component; diff --git a/src/prototypes/insurance-procurement/InsuranceProcurement.jsx b/src/prototypes/insurance-procurement/InsuranceProcurement.jsx index a49de8a..e7b0753 100644 --- a/src/prototypes/insurance-procurement/InsuranceProcurement.jsx +++ b/src/prototypes/insurance-procurement/InsuranceProcurement.jsx @@ -6,6 +6,11 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; import { OperationActions } from '../../common/OperationActions'; +import { + shouldShowFilterExpand, + splitFilterFields, + VM_FILTER_PRIMARY_VISIBLE_COUNT, +} from '../../common/vm-filter-panel'; import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import { @@ -6335,7 +6340,7 @@ const Component = function () { }; const renderFilterField = (label, control) => ( - + ))} + + +
+
+ 3 上传填写好的 Excel 文件
- {failedCount > 0 && onDownloadErrorLog ? ( + {hasResultSummary ? (
-

- 本次有 {failedCount} 条记录导入失败(如车牌号不存在),可下载错误日志核对。 -

- + {failedCount > 0 ? ( +

+ 本次有 {failedCount} 条记录导入失败,可下载错误日志核对。 +

+ ) : null} + {skippedCount > 0 ? ( +

+ 已跳过 {skippedCount} 条重复记录。 +

+ ) : null} + {overwrittenCount > 0 ? ( +

+ 已覆盖 {overwrittenCount} 条重复记录。 +

+ ) : null} + {failedCount > 0 && onDownloadErrorLog ? ( + + ) : null}
) : null} diff --git a/src/prototypes/lease-business-detail/components/DetailMainTabs.tsx b/src/prototypes/lease-business-detail/components/DetailMainTabs.tsx new file mode 100644 index 0000000..85059f5 --- /dev/null +++ b/src/prototypes/lease-business-detail/components/DetailMainTabs.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +export type DetailMainTab = 'detail' | 'monthly-pl'; + +interface DetailMainTabsProps { + mainTab: DetailMainTab; + onChange: (tab: DetailMainTab) => void; +} + +export function DetailMainTabs({ mainTab, onChange }: DetailMainTabsProps) { + return ( + + ); +} diff --git a/src/prototypes/lease-business-detail/components/DetailTable.tsx b/src/prototypes/lease-business-detail/components/DetailTable.tsx index 7128a69..64f19ec 100644 --- a/src/prototypes/lease-business-detail/components/DetailTable.tsx +++ b/src/prototypes/lease-business-detail/components/DetailTable.tsx @@ -28,7 +28,6 @@ import { paymentStatusSortValue, resolveDetailPaymentStatus, } from '../utils/payment-status'; -import { canEditDetailRow } from '../utils/change-log'; import { LedgerDetailPopover, LedgerDetailTable, @@ -38,7 +37,6 @@ interface DetailTableProps { records: LeaseBusinessDetailRow[]; summary: LeaseDetailKpiSummary; loading?: boolean; - isSupervisor: boolean; onEdit: (row: LeaseBusinessDetailRow) => void; onDelete: (row: LeaseBusinessDetailRow) => void; onChangeLog: (row: LeaseBusinessDetailRow) => void; @@ -157,8 +155,11 @@ function renderCellContent( } if (col.key === 'outstanding') { - const { text, warn } = displayOutstanding(row.outstanding); + const { text, warn, overpaid } = displayOutstanding(row.outstanding); if (!text) return ''; + if (overpaid) { + return {text}; + } return warn ? {text} : {text}; @@ -218,7 +219,10 @@ function renderSummaryCell(col: DetailTableColumn, summary: LeaseDetailKpiSummar const value = summary[col.summaryKey]; if (col.summaryKey === 'outstanding') { - const { text, warn } = displayOutstanding(value); + const { text, warn, overpaid } = displayOutstanding(value); + if (overpaid) { + return {text}; + } return warn ? {text} : {text}; @@ -249,7 +253,6 @@ export function DetailTable({ records, summary, loading = false, - isSupervisor, onEdit, onDelete, onChangeLog, @@ -386,11 +389,8 @@ export function DetailTable({ - {sortedRecords.map((row) => { - const canEdit = canEditDetailRow(row, isSupervisor); - const archived = row.archiveStatus === 'archived'; - return ( - + {sortedRecords.map((row) => ( + {DETAIL_TABLE_COLUMNS.map((col) => ( onEdit(row) } : undefined} + edit={{ onClick: () => onEdit(row) }} more={[ - ...(canEdit - ? [{ - key: 'delete', - label: '删除', - danger: true, - onClick: () => onDelete(row), - }] - : []), + { + key: 'delete', + label: '删除', + danger: true, + onClick: () => onDelete(row), + }, { key: 'changeLog', label: '变更日志', @@ -425,8 +423,7 @@ export function DetailTable({ /> - ); - })} + ))} diff --git a/src/prototypes/lease-business-detail/components/FilterPanel.tsx b/src/prototypes/lease-business-detail/components/FilterPanel.tsx index 5bff6aa..4ccab61 100644 --- a/src/prototypes/lease-business-detail/components/FilterPanel.tsx +++ b/src/prototypes/lease-business-detail/components/FilterPanel.tsx @@ -1,18 +1,17 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { ChevronDown, Search } from 'lucide-react'; +import { + shouldShowFilterExpand, + splitFilterFields, + VM_FILTER_PRIMARY_VISIBLE_COUNT, +} from '../../../common/vm-filter-panel'; import type { LeaseBusinessDetailRow, LeaseDetailFilters } from '../types'; import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField'; import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField'; import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail'; import { DETAIL_PAYMENT_STATUS_OPTIONS } from '../utils/payment-status'; -const PRIMARY_KEYS = ['statMonth', 'businessDept', 'salesperson', 'customerName', 'paymentStatus'] as const; -const EXTRA_KEYS = ['plateNos'] as const; - -type FieldKey = (typeof PRIMARY_KEYS)[number] | (typeof EXTRA_KEYS)[number]; - interface FilterFieldDef { - key: FieldKey; + key: string; label: string; control: React.ReactNode; } @@ -26,7 +25,11 @@ interface FilterPanelProps { } function hasExtraFiltersActive(filters: LeaseDetailFilters): boolean { - return filters.plateNos.length > 0; + return Boolean(filters.paymentStatus || filters.plateNos.length > 0); +} + +function countExtraFiltersActive(filters: LeaseDetailFilters): number { + return [filters.paymentStatus, filters.plateNos.length > 0].filter(Boolean).length; } export function FilterPanel({ @@ -40,14 +43,14 @@ export function FilterPanel({ useEffect(() => { if (hasExtraFiltersActive(filters)) setExpanded(true); - }, [filters.plateNos]); + }, [filters.paymentStatus, filters.plateNos]); const monthOptions = useMemo(() => buildDetailMonthOptions(records), [records]); const deptOptions = useMemo(() => buildDetailOptions(records, 'businessDept'), [records]); const salespersonOptions = useMemo(() => buildDetailOptions(records, 'salesperson'), [records]); - const fieldMap = useMemo>(() => ({ - statMonth: { + const fields = useMemo(() => [ + { key: 'statMonth', label: '统计月份', control: ( @@ -60,7 +63,7 @@ export function FilterPanel({ /> ), }, - businessDept: { + { key: 'businessDept', label: '业务部门', control: ( @@ -73,7 +76,7 @@ export function FilterPanel({ /> ), }, - salesperson: { + { key: 'salesperson', label: '业务员', control: ( @@ -86,12 +89,12 @@ export function FilterPanel({ /> ), }, - customerName: { + { key: 'customerName', label: '客户名称', control: ( ), }, - paymentStatus: { + { key: 'paymentStatus', label: '状态', control: ( @@ -112,7 +115,7 @@ export function FilterPanel({ /> ), }, - plateNos: { + { key: 'plateNos', label: '车牌号码', control: ( @@ -124,11 +127,12 @@ export function FilterPanel({ /> ), }, - }), [filters, onChange, monthOptions, deptOptions, salespersonOptions]); + ], [filters, onChange, monthOptions, deptOptions, salespersonOptions]); - const primaryFields = PRIMARY_KEYS.map((key) => fieldMap[key]); - const extraFields = EXTRA_KEYS.map((key) => fieldMap[key]); - const extraActiveCount = filters.plateNos.length > 0 ? 1 : 0; + const { primary, extra } = splitFilterFields(fields, VM_FILTER_PRIMARY_VISIBLE_COUNT); + const showExpand = shouldShowFilterExpand(fields.length); + const extraActiveCount = countExtraFiltersActive(filters); + const expandPanelId = 'lbd-filter-expand-panel'; const renderField = (field: FilterFieldDef) => (