From 305199c5080450072caa04dee7a8a15da526cbd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=86=95?= Date: Fri, 17 Jul 2026 16:10:34 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=AD=E4=BB=A3=E5=8A=A0=E6=B0=A2=E7=AB=99?= =?UTF-8?q?=20H5/Web=EF=BC=9A=E6=89=8B=E5=B7=A5=E5=8F=B0=E8=B4=A6=E4=B8=8E?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E5=88=97=E8=A1=A8=E3=80=81=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E4=B8=BB=E9=94=AE=E4=B8=8E=E5=AE=9E=E6=94=B6=E8=A7=84=E5=88=99?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=90=8C=E6=AD=A5=E7=AB=99=E7=82=B9=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E6=8B=86=E5=88=86=E4=B8=8E=E5=AF=BC=E8=88=AA=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 | 8 +- rules/global-design-spec.md | 2 +- src/common/h2VehicleLedgerBridge.js | 318 +- .../.spec/import-rules.md | 45 + .../.spec/prototype-review.md | 8 +- .../.spec/requirements-prd.md | 13 +- .../annotation-source.json | 39 +- .../components/DetailEditModal.tsx | 2 +- .../components/DetailImportModal.tsx | 4 +- .../scripts/sync-annotation-directory.mjs | 24 +- .../lease-business-detail/utils/batch-io.ts | 8 +- .../utils/import-merge.ts | 15 +- .../lease-business-detail/utils/row-key.ts | 13 +- .../utils/validate-detail.ts | 19 +- .../oneos-h5-h2-order/.spec/feature-create.md | 46 + .../oneos-h5-h2-order/.spec/feature-detail.md | 35 + .../oneos-h5-h2-order/.spec/feature-list.md | 40 + .../.spec/feature-manual-ledger.md | 58 + .../.spec/fleet-plate-tag.md | 30 + .../.spec/reconcile-linkage.md | 61 +- .../.spec/requirements-prd.md | 52 +- .../oneos-h5-h2-order/annotation-source.json | 470 ++- .../components/CreateWizard.tsx | 148 +- .../components/DateTimePickerSheet.tsx | 103 + .../components/ManualLedgerPanel.tsx | 347 ++ .../components/OrderCard.tsx | 29 +- .../components/OrderDetail.tsx | 72 +- .../components/PlateKeyboardSheet.tsx | 107 + src/prototypes/oneos-h5-h2-order/index.tsx | 254 +- .../scripts/build-annotation-source.mjs | 374 ++ .../oneos-h5-h2-order/styles/index.css | 886 ++++- src/prototypes/oneos-h5-h2-order/types.ts | 6 + .../oneos-h5-h2-order/utils/fleet-plates.ts | 47 + .../oneos-h5-h2-order/utils/format.ts | 8 + .../oneos-h5-h2-order/utils/manual-ledger.ts | 202 ++ .../oneos-h5-h2-order/utils/orders.ts | 90 +- .../prototype-registry.json | 3118 +++++++++++------ .../oneos-prototype-nav/xll-nav-menu.json | 2 +- .../.spec/period-end-balance.md | 171 + .../.spec/prototype-review.md | 2 +- .../requirements-prd-kpi-balance-alert.md | 2 +- .../.spec/requirements-prd-list.md | 2 +- .../.spec/requirements-prd.md | 25 +- .../annotation-source.json | 85 +- .../oneos-web-h2-station-site/index.tsx | 2 +- .../pages/03-站点信息.jsx | 433 ++- .../scripts/build-annotation-source.mjs | 34 +- .../.spec/prototype-review.md | 51 + .../.spec/record-data-model.md | 85 +- .../.spec/requirements-prd.md | 34 +- .../oneos-web-h2-station/.spec/ui-review.md | 81 + .../annotation-source.json | 66 +- src/prototypes/oneos-web-h2-station/index.tsx | 25 +- .../pages/01-加氢订单.jsx | 194 - .../pages/02-加氢记录.jsx | 1523 ++++++-- .../oneos-web-h2-station/pages/README.md | 15 + .../vehicle-h2-fee-ledger/.spec/PRD.md | 9 +- .../.spec/verify-reconcile.md | 73 + .../vehicle-h2-fee-ledger/H2LedgerPage.jsx | 99 +- .../annotation-source.json | 16 +- src/prototypes/vm-shared/DESIGN.md | 2 +- src/resources/design-system/DESIGN.md | 2 +- 62 files changed, 8071 insertions(+), 2063 deletions(-) create mode 100644 src/prototypes/lease-business-detail/.spec/import-rules.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/feature-create.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/feature-list.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/feature-manual-ledger.md create mode 100644 src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md create mode 100644 src/prototypes/oneos-h5-h2-order/components/DateTimePickerSheet.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/components/ManualLedgerPanel.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/components/PlateKeyboardSheet.tsx create mode 100644 src/prototypes/oneos-h5-h2-order/scripts/build-annotation-source.mjs create mode 100644 src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts create mode 100644 src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts create mode 100644 src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md rename src/prototypes/{oneos-web-h2-station => oneos-web-h2-station-site}/pages/03-站点信息.jsx (96%) create mode 100644 src/prototypes/oneos-web-h2-station/.spec/prototype-review.md create mode 100644 src/prototypes/oneos-web-h2-station/.spec/ui-review.md delete mode 100644 src/prototypes/oneos-web-h2-station/pages/01-加氢订单.jsx create mode 100644 src/prototypes/oneos-web-h2-station/pages/README.md create mode 100644 src/prototypes/vehicle-h2-fee-ledger/.spec/verify-reconcile.md diff --git a/.axhub/make/sidebar-tree.json b/.axhub/make/sidebar-tree.json index 5fe0748..63505ab 100644 --- a/.axhub/make/sidebar-tree.json +++ b/.axhub/make/sidebar-tree.json @@ -1,7 +1,13 @@ { "version": 1, - "updatedAt": "2026-07-16T01:03:00.000Z", + "updatedAt": "2026-07-16T01:04:51.317Z", "prototypes": [ + { + "id": "item-prototypes-oneos-web-approval", + "kind": "item", + "title": "oneos web approval", + "itemKey": "prototypes/oneos-web-approval" + }, { "id": "folder-1783875936186-27raza", "kind": "folder", diff --git a/rules/global-design-spec.md b/rules/global-design-spec.md index 923424d..e04b554 100644 --- a/rules/global-design-spec.md +++ b/rules/global-design-spec.md @@ -395,7 +395,7 @@ const vmTheme = { | 合同 Ant 对齐 | `src/prototypes/lease-contract-management/styles/lease-contract.css` | | 参考:车辆管理 | `src/prototypes/vehicle-management/index.tsx` | | 参考:租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` | -| 参考:加氢站站点 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | +| 参考:加氢站站点 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` | | 主题 token 来源 | `src/themes/linear/DESIGN.md`(inverse 浅色用于 vm) | --- diff --git a/src/common/h2VehicleLedgerBridge.js b/src/common/h2VehicleLedgerBridge.js index 07eecda..7d66bef 100644 --- a/src/common/h2VehicleLedgerBridge.js +++ b/src/common/h2VehicleLedgerBridge.js @@ -5,26 +5,32 @@ var H2_RECONCILE_RECONCILED = 'reconciled'; var H2_RECONCILE_PENDING = 'pending'; +var H2_VERIFY_VERIFIED = 'verified'; +var H2_VERIFY_UNVERIFIED = 'unverified'; var H2_STATION_CODE_MAP = { - '嘉兴加氢站(一期)': 'JX-H2-001', + '中国石油中油高新能源牙谷加油加氢站': 'JX-H2-001', '杭州临平加氢站': 'HZ-H2-002', '上海宝山加氢站': 'SH-H2-003', '苏州工业园区备用站': 'SZ-H2-004' }; -/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(10 条) */ +/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(含本站未核对 / 已核对未对账用例) */ var H2_CANONICAL_LEDGER_SEED = [ - { key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_PENDING } + /* 嘉兴本站 · 列表首页用例:未核对(无核对时间)→ 已核对未对账 → 已核对已对账 */ + { key: 'rf-11', id: 'rf-11', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-16 09:20:18', plateNo: '浙A55666F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 11.2, costUnitPrice: 42.5, costTotal: 476.0, customerUnitPrice: 42.5, customerAmount: 476.0, settlementStatus: 'customer', mileageKm: 132080, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING }, + { key: 'rf-12', id: 'rf-12', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-15 16:45:02', plateNo: '浙A77888F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 9.8, costUnitPrice: 42.5, costTotal: 416.5, customerUnitPrice: 42.5, customerAmount: 416.5, settlementStatus: 'customer', mileageKm: null, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING }, + { key: 'rf-13', id: 'rf-13', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-14 11:08:40', plateNo: '浙A99001F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 14.0, costUnitPrice: 42.5, costTotal: 595.0, customerUnitPrice: 45, customerAmount: 630.0, settlementStatus: 'customer', mileageKm: 155200, creatorName: '站端账号', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-07-14 15:30:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null }, + { key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-29 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-29 10:00:00' }, + { key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-27 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-27 10:00:00' }, + { key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-23 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-23 10:00:00' }, + { key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-19 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-19 10:00:00' }, + { key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-31 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-31 10:00:00' }, + { key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-28 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-28 10:00:00' }, + { key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-25 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-25 10:00:00' }, + { key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-21 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-04-21 10:00:00' }, + { key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-09 10:00:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null }, + { key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING } ]; function h2BridgeCloneRows(rows) { @@ -38,6 +44,26 @@ function h2BridgeFormatDateTime(value) { return String(value); } +/** 已对账记录必须有对账时间;缺省时按加氢日次日 10:00:00 推导(原型演示) */ +function h2BridgeDeriveReconcileTime(hydrogenTime) { + var s = h2BridgeFormatDateTime(hydrogenTime); + var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!m) return '2026-01-01 10:00:00'; + var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1, 10, 0, 0); + var pad = function (n) { return n < 10 ? '0' + n : String(n); }; + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' 10:00:00'; +} + +function h2BridgeResolveReconcileTime(row) { + /* 对账时间仅来自对账单回写的 reconcileDate;勿用 reconciledAt(完成核对会写入) */ + var explicit = row.reconcileDate || ''; + if (explicit) return h2BridgeFormatDateTime(explicit); + if (row.reconcileStatus === H2_RECONCILE_RECONCILED) { + return h2BridgeDeriveReconcileTime(row.hydrogenTime); + } + return ''; +} + function h2BridgeMatchStatementPatch(row, patch) { if (patch.ledgerId && row.ledgerSyncId && patch.ledgerId === row.ledgerSyncId) return true; var rowTime = h2BridgeFormatDateTime(row.hydrogenTime); @@ -66,7 +92,6 @@ function h2BridgeApplyStatementPatches(rows, patches) { receiptDate: patch.receiptDate, stationPaymentStatus: patch.paymentStatus || 'paid', paymentStatus: patch.paymentStatus || 'paid', - reconciledAt: patch.reconcileDate, statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId }); }); @@ -77,9 +102,71 @@ function h2BridgeBuildInitialRows() { return h2BridgeApplyStatementPatches(h2BridgeCloneRows(H2_CANONICAL_LEDGER_SEED), patches); } +function h2BridgePad2(n) { + return n < 10 ? '0' + n : String(n); +} + +/** 自然日 YYYY-MM-DD(设备本地时区) */ +function h2BridgeTodayDateKey(d) { + var x = d || new Date(); + return x.getFullYear() + '-' + h2BridgePad2(x.getMonth() + 1) + '-' + h2BridgePad2(x.getDate()); +} + +function h2BridgeYesterdayDateKey() { + var d = new Date(); + d.setDate(d.getDate() - 1); + return h2BridgeTodayDateKey(d); +} + +function h2BridgeManualLedgerKey(stationId, ledgerDate) { + return String(stationId || '').trim() + '|' + String(ledgerDate || '').trim(); +} + +function h2BridgeResolveStationId(stationId, stationName) { + var id = String(stationId || '').trim(); + if (id) return id; + var name = String(stationName || '').trim(); + return H2_STATION_CODE_MAP[name] || ''; +} + +/** 手工台账种子:昨日已上传;今日默认未上传,便于演示拦截新增 */ +function h2BridgeBuildInitialManualLedgers() { + var yesterday = h2BridgeYesterdayDateKey(); + return [ + { + id: 'ml-seed-yesterday', + stationId: 'JX-H2-001', + stationName: '中国石油中油高新能源牙谷加油加氢站', + ledgerDate: yesterday, + images: [ + { + id: 'ml-img-seed-1', + name: '昨日手工台账.jpg', + dataUrl: '', + placeholder: true, + archived: true, + uploadedAt: yesterday + ' 18:30:00' + } + ], + updatedAt: yesterday + ' 18:30:00' + } + ]; +} + +function h2BridgeCloneManualLedgers(list) { + return (list || []).map(function (item) { + return Object.assign({}, item, { + images: (item.images || []).map(function (img) { return Object.assign({}, img); }) + }); + }); +} + function h2BridgeCreateStore() { var listeners = []; - var state = { rows: h2BridgeBuildInitialRows() }; + var state = { + rows: h2BridgeBuildInitialRows(), + manualLedgers: h2BridgeBuildInitialManualLedgers() + }; function notify() { listeners.forEach(function (fn) { @@ -93,6 +180,7 @@ function h2BridgeCreateStore() { state.rows = h2BridgeCloneRows(rows); notify(); }, + getManualLedgers: function () { return h2BridgeCloneManualLedgers(state.manualLedgers); }, subscribe: function (fn) { listeners.push(fn); return function () { @@ -115,6 +203,7 @@ function h2BridgeCreateStore() { next.key = next.key || next.id; } if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING; + if (!next.verifyStatus) next.verifyStatus = H2_VERIFY_UNVERIFIED; var found = false; state.rows = state.rows.map(function (r) { if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) { @@ -137,6 +226,93 @@ function h2BridgeCreateStore() { var changed = state.rows.length !== before; if (changed) notify(); return changed; + }, + upsertManualLedger: function (input) { + if (!input) return null; + var stationId = h2BridgeResolveStationId(input.stationId, input.stationName); + var stationName = String(input.stationName || '').trim(); + var ledgerDate = String(input.ledgerDate || h2BridgeTodayDateKey()).trim(); + if (!stationId || !ledgerDate) return null; + var key = h2BridgeManualLedgerKey(stationId, ledgerDate); + var existing = null; + state.manualLedgers.forEach(function (item) { + if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) existing = item; + }); + function normalizeImage(img, i) { + return { + id: img.id || ('ml-img-' + Date.now() + '-' + i), + name: String(img.name || ('台账照片' + (i + 1) + '.jpg')), + dataUrl: String(img.dataUrl || ''), + placeholder: Boolean(img.placeholder), + archived: true, + uploadedAt: img.uploadedAt || (ledgerDate + ' 12:00:00') + }; + } + /** 已归档图不可删:保留原有全部,仅追加新图 */ + var images = (existing && existing.images ? existing.images : []).map(function (img, i) { + return normalizeImage(img, i); + }); + var seen = {}; + images.forEach(function (img) { seen[img.id] = true; }); + (input.images || []).forEach(function (img, i) { + var nextImg = normalizeImage(img, images.length + i); + if (!nextImg.dataUrl && !nextImg.placeholder) return; + if (seen[nextImg.id]) return; + seen[nextImg.id] = true; + images.push(nextImg); + }); + if (!images.length) return null; + var now = new Date(); + var nowStr = now.getFullYear() + '-' + h2BridgePad2(now.getMonth() + 1) + '-' + h2BridgePad2(now.getDate()) + + ' ' + h2BridgePad2(now.getHours()) + ':' + h2BridgePad2(now.getMinutes()) + ':' + h2BridgePad2(now.getSeconds()); + var next = { + id: 'ml-' + stationId + '-' + ledgerDate, + stationId: stationId, + stationName: stationName || stationId, + ledgerDate: ledgerDate, + images: images, + updatedAt: nowStr + }; + var found = false; + state.manualLedgers = state.manualLedgers.map(function (item) { + if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) { + found = true; + return next; + } + return item; + }); + if (!found) state.manualLedgers = [next].concat(state.manualLedgers); + notify(); + return h2BridgeCloneManualLedgers([next])[0]; + }, + getManualLedger: function (stationIdOrName, ledgerDate) { + var dateKey = String(ledgerDate || h2BridgeTodayDateKey()).trim(); + var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName); + if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName); + if (!sid) return null; + var key = h2BridgeManualLedgerKey(sid, dateKey); + var i; + for (i = 0; i < state.manualLedgers.length; i++) { + var item = state.manualLedgers[i]; + if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) { + return h2BridgeCloneManualLedgers([item])[0]; + } + } + return null; + }, + listManualLedgersByStation: function (stationIdOrName) { + var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName); + if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName); + if (!sid) return []; + return h2BridgeCloneManualLedgers(state.manualLedgers.filter(function (item) { + return item.stationId === sid; + })).sort(function (a, b) { + return String(b.ledgerDate).localeCompare(String(a.ledgerDate)); + }); + }, + hasManualLedgerForDate: function (stationIdOrName, ledgerDate) { + var entry = this.getManualLedger(stationIdOrName, ledgerDate); + return Boolean(entry && entry.images && entry.images.length); } }; } @@ -193,8 +369,9 @@ function h2BridgeMapToHrRecord(row, index, 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); + var reconcileTime = h2BridgeResolveReconcileTime(row); + var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(row.reconcileDate); + var verifiedAt = row.verifiedAt || ''; return { id: row.id || row.key || ('hr-' + (index + 1)), hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime), @@ -211,8 +388,10 @@ function h2BridgeMapToHrRecord(row, index, fillerNames) { settlementStatus: row.settlementStatus, stationName: row.stationName, stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '', + verifyStatus: row.verifyStatus === H2_VERIFY_VERIFIED ? H2_VERIFY_VERIFIED : H2_VERIFY_UNVERIFIED, + verifiedAt: verifiedAt ? h2BridgeFormatDateTime(verifiedAt) : '', reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING, - reconcileTime: isReconciled ? h2BridgeFormatDateTime(reconcileTime) : '' + reconcileTime: isReconciled ? reconcileTime : '' }; } @@ -299,6 +478,84 @@ function h2BridgeGetStationList() { return list; } +/** 车牌写入口径:大写并补尾缀 F */ +function h2BridgeNormalizePlate(plateNo) { + var plate = String(plateNo || '').trim().toUpperCase(); + if (plate && !/F$/u.test(plate)) plate += 'F'; + return plate; +} + +/** 时间写入口径:补全秒 */ +function h2BridgeNormalizeHydrogenTime(hydrogenTime) { + var s = String(hydrogenTime || '').trim(); + if (s.length === 16) s += ':00'; + return s; +} + +/** + * 业务主键:同站 + 同车牌 + 同加氢时间(精确到秒)视为重复。 + * @returns {object|null} 已存在行的浅拷贝,未命中返回 null + */ +function h2BridgeFindDuplicateRow(candidate, excludeId) { + if (!candidate) return null; + var store = h2BridgeGetStore(); + if (!store) return null; + var station = String(candidate.stationName || '').trim(); + var plate = h2BridgeNormalizePlate(candidate.plateNo); + var time = h2BridgeNormalizeHydrogenTime(candidate.hydrogenTime); + if (!station || !plate || !time) return null; + var exclude = excludeId != null ? String(excludeId) : ''; + var rows = store.getRows(); + var i; + for (i = 0; i < rows.length; i++) { + var row = rows[i]; + var id = String(row.id || row.key || ''); + if (exclude && id === exclude) continue; + if (String(row.stationName || '').trim() !== station) continue; + if (h2BridgeNormalizePlate(row.plateNo) !== plate) continue; + if (h2BridgeNormalizeHydrogenTime(row.hydrogenTime) !== time) continue; + return Object.assign({}, row); + } + return null; +} + +/** + * 总额与「单价×量」偏差(元)。容差默认 0.05。 + * @returns {{ expected: number, actual: number, diff: number }|null} + */ +function h2BridgeTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount, toleranceYuan) { + var p = Number(unitPrice); + var k = Number(hydrogenKg); + var a = Number(totalAmount); + if (!isFinite(p) || !isFinite(k) || !isFinite(a)) return null; + var expected = Math.round(p * k * 100) / 100; + var actual = Math.round(a * 100) / 100; + var tol = toleranceYuan != null ? Number(toleranceYuan) : 0.05; + if (!isFinite(tol)) tol = 0.05; + var diff = Math.round(Math.abs(actual - expected) * 100) / 100; + if (diff <= tol) return null; + return { expected: expected, actual: actual, diff: diff }; +} + +/** 取该站最近一条流水的站端口径单价,缺省 42.5 */ +function h2BridgeLookupStationUnitPrice(stationName) { + var name = String(stationName || '').trim(); + if (!name) return 42.5; + var store = h2BridgeGetStore(); + if (!store) return 42.5; + var rows = store.getRows().slice().sort(function (a, b) { + return String(b.hydrogenTime || '').localeCompare(String(a.hydrogenTime || '')); + }); + var i; + for (i = 0; i < rows.length; i++) { + if (String(rows[i].stationName || '').trim() !== name) continue; + var price = rows[i].costUnitPrice != null ? rows[i].costUnitPrice : rows[i].customerUnitPrice; + var n = Number(price); + if (isFinite(n) && n >= 0) return Math.round(n * 100) / 100; + } + return 42.5; +} + function h2BridgeInit() { h2BridgeEnsureApi(); var store = h2BridgeGetStore(); @@ -311,6 +568,24 @@ function h2BridgeInit() { getHrRecords: h2BridgeGetHrRecords, getOrderRecords: h2BridgeGetOrderRecords, getStationList: h2BridgeGetStationList, + normalizePlate: h2BridgeNormalizePlate, + normalizeHydrogenTime: h2BridgeNormalizeHydrogenTime, + findDuplicateRow: h2BridgeFindDuplicateRow, + totalAmountMismatch: h2BridgeTotalAmountMismatch, + lookupStationUnitPrice: h2BridgeLookupStationUnitPrice, + todayDateKey: h2BridgeTodayDateKey, + getManualLedger: function (stationIdOrName, ledgerDate) { + return store.getManualLedger(stationIdOrName, ledgerDate); + }, + listManualLedgersByStation: function (stationIdOrName) { + return store.listManualLedgersByStation(stationIdOrName); + }, + hasManualLedgerForDate: function (stationIdOrName, ledgerDate) { + return store.hasManualLedgerForDate(stationIdOrName, ledgerDate); + }, + upsertManualLedger: function (input) { + return store.upsertManualLedger(input); + }, upsertRow: function (row) { return store.upsertRow(row); }, removeRow: function (id) { return store.removeRow(id); }, subscribe: function (fn) { return store.subscribe(fn); } @@ -326,6 +601,8 @@ export { H2_STATION_CODE_MAP, H2_RECONCILE_RECONCILED, H2_RECONCILE_PENDING, + H2_VERIFY_VERIFIED, + H2_VERIFY_UNVERIFIED, h2BridgeInit, h2BridgeGetStore, h2BridgeGetAllRefuelRecords, @@ -333,5 +610,10 @@ export { h2BridgeBuildStationLedgerStore, h2BridgeGetHrRecords, h2BridgeGetOrderRecords, - h2BridgeGetStationList + h2BridgeGetStationList, + h2BridgeNormalizePlate, + h2BridgeNormalizeHydrogenTime, + h2BridgeFindDuplicateRow, + h2BridgeTotalAmountMismatch, + h2BridgeLookupStationUnitPrice }; diff --git a/src/prototypes/lease-business-detail/.spec/import-rules.md b/src/prototypes/lease-business-detail/.spec/import-rules.md new file mode 100644 index 0000000..042a3c4 --- /dev/null +++ b/src/prototypes/lease-business-detail/.spec/import-rules.md @@ -0,0 +1,45 @@ +# 租赁业务明细 · 导入与金额规则 + +> 实现:`utils/row-key.ts`、`utils/import-merge.ts`、`utils/batch-io.ts`、`utils/validate-detail.ts` +> UI:`components/DetailImportModal.tsx`、`components/DetailEditModal.tsx` + +## 导入业务主键(去重) + +| 组成字段 | 说明 | +|---|---| +| 年份 | `year` | +| 月份 | `month` | +| 车牌号码 | `plateNo`(trim) | +| **客户名称** | `customerName`(trim) | + +键格式:`{year}-{MM}-{plateNo}::{customerName}`(见 `detailRowBusinessKey`)。 + +同一主键下支持三种策略: + +| 策略 | 行为 | +|---|---| +| 跳过重复(默认) | 已存在同键记录则跳过导入行 | +| 覆盖重复 | 用导入行覆盖已有记录(保留原 `id`) | +| 报错 | 重复行写入错误日志,不入库 | + +**边界**:同一车牌、同一月份、**不同客户**视为两条不同记录,不互相覆盖。 + +数据源:原型本地导入解析 + 内存中的已有明细;**未接真实后端 API**。 + +--- + +## 实收金额 vs 应收合计 + +| 项 | 规则 | +|---|---| +| 导入 | **不拦截**实收大于应收合计 | +| 编辑保存 | **不拦截**实收大于应收合计 | +| 未收计算 | 仍为「应收合计 − 实收金额」;实收超额时未收为负,列表用超额样式提示 | + +历史规则「实收金额 ≤ 应收合计」已在 `validate-detail.ts` 中注释停用,函数保留签名便于对照。 + +--- + +## 与收款状态的关系 + +收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。 diff --git a/src/prototypes/lease-business-detail/.spec/prototype-review.md b/src/prototypes/lease-business-detail/.spec/prototype-review.md index b15204f..23f91f7 100644 --- a/src/prototypes/lease-business-detail/.spec/prototype-review.md +++ b/src/prototypes/lease-business-detail/.spec/prototype-review.md @@ -34,8 +34,8 @@ | 场景 | 现状 | |------|------| -| 重复导入 | 跳过 / 覆盖 / 报错 + 摘要 Toast | -| 实收超额 | 导入/编辑拦截;列表负未收警示 | +| 重复导入 | 跳过 / 覆盖 / 报错;主键 year-month-plate-customer | +| 实收超额 | **允许**;导入/编辑不拦截;列表负未收/超额样式 | | 行内操作 | 编辑、删除、变更日志 | ## 证据与评估说明 @@ -49,6 +49,6 @@ |---|---|---| | P1 | 缺少盈亏月度汇总 Tab | ➡️ 改由业务部台账;本页已移除 Tab | | P1 | 对账/锁定无入口 | ➡️ 产品范围调整:本页不做对账锁定 | -| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate | -| P2 | 实收超额 | ✅ 导入/编辑校验 + 负未收样式 | +| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate-**customer** | +| P2 | 实收超额 | ➡️ 产品调整:**允许**超额;拦截已停用 | | P3 | PRD/annotation 偏差 | ✅ 公式、导出 48 列、边界文案已更新 | diff --git a/src/prototypes/lease-business-detail/.spec/requirements-prd.md b/src/prototypes/lease-business-detail/.spec/requirements-prd.md index 32e7800..21a9f38 100644 --- a/src/prototypes/lease-business-detail/.spec/requirements-prd.md +++ b/src/prototypes/lease-business-detail/.spec/requirements-prd.md @@ -79,7 +79,7 @@ ### 导入重复策略 -业务主键:`年份 + 月份 + 车牌号码`。 +业务主键:`年份 + 月份 + 车牌号码 + 客户名称`(完整规则见 **`.spec/import-rules.md`**)。 | 策略 | 行为 | |---|---| @@ -87,7 +87,9 @@ | 覆盖重复 | 用导入行覆盖已有记录 | | 报错 | 重复行写入错误日志 | -导入与编辑均校验:**实收金额 ≤ 应收合计**。 +### 实收金额 + +**不限制**实收 ≤ 应收合计:导入与编辑均允许实收大于应收合计(未收可为负,列表超额样式提示)。旧规则已停用,见 `.spec/import-rules.md`。 --- @@ -179,9 +181,10 @@ | 筛选 / KPI 汇总 | `utils/detail.ts` | | 收款状态 | `utils/payment-status.ts` | | 公式计算 | `utils/calc-detail.ts` | -| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` | +| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` | | 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` | -| 保存校验 | `utils/validate-detail.ts` | +| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) | +| 导入规则说明 | `.spec/import-rules.md` | | 变更日志 | `utils/change-log.ts` | | 类型 | `types.ts` | | 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` | @@ -196,6 +199,8 @@ 4. 氢费非零可点击明细;编辑/删除/变更日志可用 5. 未收 > 0 标红;盈亏正负着色 6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`) +7. 导入主键含客户名称;同车牌同月不同客户可并存 +8. 实收可大于应收合计:导入/编辑不拦截;未收可为负并有超额样式 --- diff --git a/src/prototypes/lease-business-detail/annotation-source.json b/src/prototypes/lease-business-detail/annotation-source.json index 2cca98d..6cc8d21 100644 --- a/src/prototypes/lease-business-detail/annotation-source.json +++ b/src/prototypes/lease-business-detail/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "lease-business-detail", "pageId": "list", - "updatedAt": 1783932410001, + "updatedAt": 1784265940579, "nodes": [ { "id": "lbd-filter", @@ -62,7 +62,7 @@ "path": [] }, "aiPrompt": "导入模板与导出列范围。", - "annotationText": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。\n\n---", + "annotationText": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。\n\n### 实收金额\n\n允许实收大于应收合计(导入/编辑不拦截)。详见 `.spec/import-rules.md`。\n\n---", "hasMarkdown": true, "color": "#7c3aed", "images": [], @@ -280,10 +280,10 @@ "markdownMap": { "lbd-filter": "## 2. 筛选区\n\n默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。\n\n| 筛选项 | 说明 |\n|---|---|\n| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |\n| 业务部门 | 精确匹配 |\n| 业务员 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |\n| 车牌号码 | 多选精确匹配(更多筛选) |\n\n查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。\n\n---", "lbd-kpi": "## 3. KPI 统计卡片(7 张)\n\n顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。\n\n| KPI | 汇总字段 |\n|---|---|\n| 应收总计 | receivableTotal |\n| 押金总计 | deposit(仅数值型) |\n| 实收总计 | receivedAmount |\n| 里程减免总计 | mileageDeduction |\n| 其他减免总计 | otherDeduction |\n| 未收总计 | outstanding(>0 标红) |\n| 盈亏总计 | profitLoss(正负着色) |\n\n---", - "lbd-toolbar": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。\n\n---", + "lbd-toolbar": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。\n\n### 实收金额\n\n允许实收大于应收合计(导入/编辑不拦截)。详见 `.spec/import-rules.md`。\n\n---", "lbd-table": "## 5. 明细列表(48 列)\n\n表头顺序对齐 Excel「2.租赁车辆收入明细表」,并在月份列左侧增加「状态」列。\n\n### 交互\n\n- 横向滚动;左冻结:**状态 + 月份 + 车牌号码**\n- 仅**日期类列**表头支持正序/倒序排序\n- 公式列(`calculated`)浅灰底色,导入模板不含\n- 表尾**汇总行**:23 个金额列 SUBTOTAL,月份列显示「汇总」\n- 未收 > 0 单元格标红;盈亏正负着色\n- 底部分页:10 / 20 / 50 / 100\n\n### 收款状态(状态列)\n\n| 状态 | 判定规则 |\n|---|---|\n| 未收款 | 实收金额 = 0 或未录入 |\n| 部分收款 | 实收金额 > 0 且 < 应收合计 |\n| 已结清 | 实收金额 ≥ 应收合计 |\n\n### 表尾汇总(23 项)\n\n押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。\n\n### 单元格系统校验\n\n部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。\n\n**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。\n\n| 字段 | 校验要点 |\n|---|---|\n| 车牌号码 | 是否在车辆管理中登记 |\n| **客户名称** | 见下表 |\n| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |\n| 押金、合同标的租金 | 与租赁合同一致 |\n| 应收租金 | = 合同标的租金 × 结算周期月数 |\n| 里程 / 其他减免 | 应以负数录入 |\n| 资产归属 | 与车辆登记所有权一致 |\n\n#### 客户名称判定顺序\n\n1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。\n2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。\n3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。\n4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。\n5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。\n\n车牌或客户名为空时不显示校验图标。\n\n---", "lbd-doc-formulas": "## 6. 公式列(系统自动计算)\n\n| 字段 | 计算方式 |\n|---|---|\n| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **未收** | 应收合计 − 实收金额 |\n| **车辆实际成本** | 车辆标准成本 × 平均天数 |\n| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |\n| **盈亏** | 月度收入 − 总成本 |\n\n---", - "lbd-doc-boundary": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重与实收校验 |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---", + "lbd-doc-boundary": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重(含客户名称) |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---", "lbd-doc-overview": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务明细 |\n| 目标用户 | 业务二部业管 / 运营人员 |\n| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |\n| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表 + 分页 |\n| 设计基底 | vm-page + ldb-page + lc-page |\n\n对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」。\n\n---", "lbd-col-receivable-total": "## 应收合计\n\n**计算方式**:维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免金额(负数)+ 其他减免金额(负数)。\n**录入方式**:系统自动计算,只读。\n\n---", "lbd-col-outstanding": "## 未收\n\n**计算方式**:应收合计 − 实收金额。\n**录入方式**:系统自动计算,只读。\n\n---", @@ -295,7 +295,8 @@ "lbd-changelog-modal": "## 变更日志\n\n弹窗标题仅显示「变更日志」;表格上方展示记录条数,列表字段为操作时间、操作人、字段、修改前、修改后。\n\n---", "lbd-doc-monthly-pl": "## 租赁业务盈亏月度汇总\n\n按年份聚合明细数据为 1–12 月行:押金、应收、实收、未收、自然月收入(月度收入)、成本(车辆实际成本)、居间费、氢费、总成本、盈亏。\n\n- 筛选:选择年份 + 查询\n- 导出:Excel\n- 氢费预充值:明细表暂无字段,汇总列占位为 0\n\n---", "lbd-doc-field-checks": "# 租赁业务明细 · 单元格系统校验\n\n> 实现:`utils/field-checks.ts`、`utils/system-ref.ts`;UI:`components/FieldCheckIcon.tsx`\n\n列表部分列在单元格右侧展示**校验图标**(绿色 ✓ / 黄色 ⚠)。点击可查看说明;警告项在支持时提供**一键替换**为系统建议值。\n\n## 对照数据源(原型本地种子)\n\n| 来源 | 路径 | 用途 |\n|---|---|---|\n| 客户管理 | `customer-management/data/customers.json` | 客户名称是否已登记 |\n| 车辆管理 | `vehicle-management/data/vehicles.json` | 车牌是否存在、交车/还车日期、登记所有权 |\n| 明细种子合同 | `lease-business-detail/data/rows.json` | 车牌 + 客户 → 合同(押金、租金、提车/退车日期等) |\n\n启动时由 `system-ref.ts` 聚合为内存索引;**当前原型未接真实后端 API**。\n\n## 交互与状态\n\n| 状态 | 图标 | 点击行为 |\n|---|---|---|\n| `ok` | 绿色 ✓ | 展示通过说明(tooltip) |\n| `warn` | 黄色 ⚠ | 展示差异说明;部分字段可「一键替换」 |\n| `none` | 不显示 | 无校验条件或无需提示 |\n\n匹配规则:字符串比较前 **trim 首尾空格**;金额比较容差 **0.005 元**。\n\n---\n\n## 客户名称(`customerName`)\n\n**前置**:车牌、客户名称均非空,否则不校验。\n\n**期望客户 `expected`**:`resolveExpectedCustomer(车牌, 提车日期, 退车日期)`\n\n- 在同车牌的所有合同记录中筛选;\n- 若明细行有提车日期且合同有提车日期 → 必须一致;\n- 若明细行有退车日期且合同有退车日期 → 必须一致;\n- 取匹配合同的客户名称(多条时以后遍历结果为准)。\n\n**判定顺序**(命中即返回,不再往下):\n\n| 优先级 | 条件 | 结果 | 提示文案 |\n|---|---|---|---|\n| 1 | `expected` 非空且与当前客户名完全一致 | ✅ 通过 | 客户名称与系统登记一致 |\n| 2 | `getContractRef(车牌, 客户名)` 命中且名称一致 | ✅ 通过 | 客户名称与租赁合同一致 |\n| 3 | `expected` 非空但与当前客户名不一致 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;**建议客户:{expected}**;可一键替换 |\n| 4 | `isKnownCustomerName(客户名)` 为真 | ✅ 通过(弱校验) | 客户名称已在客户管理中登记 |\n| 5 | 以上均否 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;请核对提车日期与退车日期 |\n\n**`isKnownCustomerName` 弱校验**:\n\n- 与客户管理名单**完全一致**;或\n- **双向包含**(任一方名称包含另一方,如简称/全称)。\n\n---\n\n## 车牌号码(`plateNo`)\n\n| 条件 | 结果 | 说明 |\n|---|---|---|\n| 车牌为空 | 不校验 | — |\n| 车牌在车辆管理中 | ✅ 通过 | 车牌已在车辆管理中登记 |\n| 否则 | ⚠️ 警告 | 车牌不存在;该车牌未在系统「车辆管理」中登记,无法导入 |\n\n---\n\n## 提车日期(`pickupDate`)\n\n对照车辆管理 `lastDeliveryTime`(交车管理)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无提车日期 | 不校验 |\n| 与系统交车日期一致(YYYY-MM-DD) | ✅ 提车日期与交车管理一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 退车日期(`returnDate`)\n\n对照车辆管理 `lastReturnTime`(还车记录)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无退车日期 | 不校验 |\n| 与系统还车日期一致 | ✅ 退车日期与还车记录一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 押金(`deposit`)\n\n对照租赁合同保证金(`车牌 + 客户名` 合同)。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或押金非数值 | 不校验 |\n| 与合同保证金一致 | ✅ 押金与合同保证金一致 |\n| 不一致 | ⚠️ 警告;可一键替换为合同值 |\n\n---\n\n## 合同标的租金(`contractRent`)\n\n对照租赁合同租金。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或无租金 | 不校验 |\n| 与合同租金一致 | ✅ 租金与合同一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 应收租金(`receivableRent`)\n\n**期望**:`合同标的租金 × 结算周期月数`(月数来自合同 `paymentMethod` 解析:月度 1 / 季度 3 / 半年 6 / 年度 12)。\n\n| 条件 | 结果 |\n|---|---|\n| 期望与当前均为 0 | 不校验 |\n| 与期望一致 | ✅ 应收租金 = 合同标的租金 × N 个月 |\n| 不一致 | ⚠️ 警告;展示正确金额;可一键替换 |\n\n---\n\n## 里程减免 / 其他减免(`mileageDeduction`、`otherDeduction`)\n\n| 条件 | 结果 |\n|---|---|\n| 值为 0 | 不校验 |\n| 值为负数 | ✅ 以负数显示(符合 Excel 口径) |\n| 值为正数 | ⚠️ 警告:应录入负值 |\n\n---\n\n## 资产归属(`assetOwner`)\n\n对照车辆管理「登记所有权」。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无资产归属 | 不校验 |\n| 与登记所有权一致 | ✅ 与车辆登记所有权一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 启用校验的列\n\n在 `components/tableColumns.ts` 中通过 `checkField` 挂载,由 `DetailTable` 调用 `getFieldCheck()`:\n\n`plateNo`、`customerName`、`pickupDate`、`returnDate`、`deposit`、`contractRent`、`receivableRent`、`mileageDeduction`、`otherDeduction`、`assetOwner`。\n", - "lbd-col-customer": "## 客户名称 · 系统校验\n\n导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。\n\n### 判定顺序\n\n1. 期望客户与当前名完全一致 → ✅ 与系统登记一致\n2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致\n3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**\n4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过\n5. 否则 → ⚠️ 请核对提车/退车日期\n\n完整规则见目录「单元格系统校验」或 `.spec/field-checks.md`。\n\n---" + "lbd-col-customer": "## 客户名称 · 系统校验\n\n导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。\n\n### 判定顺序\n\n1. 期望客户与当前名完全一致 → ✅ 与系统登记一致\n2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致\n3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**\n4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过\n5. 否则 → ⚠️ 请核对提车/退车日期\n\n完整规则见目录「单元格系统校验」或 `.spec/field-checks.md`。\n\n---", + "lbd-doc-import-rules": "# 租赁业务明细 · 导入与金额规则\n\n> 实现:`utils/row-key.ts`、`utils/import-merge.ts`、`utils/batch-io.ts`、`utils/validate-detail.ts` \n> UI:`components/DetailImportModal.tsx`、`components/DetailEditModal.tsx`\n\n## 导入业务主键(去重)\n\n| 组成字段 | 说明 |\n|---|---|\n| 年份 | `year` |\n| 月份 | `month` |\n| 车牌号码 | `plateNo`(trim) |\n| **客户名称** | `customerName`(trim) |\n\n键格式:`{year}-{MM}-{plateNo}::{customerName}`(见 `detailRowBusinessKey`)。\n\n同一主键下支持三种策略:\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过导入行 |\n| 覆盖重复 | 用导入行覆盖已有记录(保留原 `id`) |\n| 报错 | 重复行写入错误日志,不入库 |\n\n**边界**:同一车牌、同一月份、**不同客户**视为两条不同记录,不互相覆盖。\n\n数据源:原型本地导入解析 + 内存中的已有明细;**未接真实后端 API**。\n\n---\n\n## 实收金额 vs 应收合计\n\n| 项 | 规则 |\n|---|---|\n| 导入 | **不拦截**实收大于应收合计 |\n| 编辑保存 | **不拦截**实收大于应收合计 |\n| 未收计算 | 仍为「应收合计 − 实收金额」;实收超额时未收为负,列表用超额样式提示 |\n\n历史规则「实收金额 ≤ 应收合计」已在 `validate-detail.ts` 中注释停用,函数保留签名便于对照。\n\n---\n\n## 与收款状态的关系\n\n收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。\n" }, "assetMap": {}, "directory": { @@ -323,7 +324,7 @@ "type": "markdown", "id": "lbd-doc-prd", "title": "PRD 全文", - "markdown": "# 租赁业务明细 · 产品需求说明(PRD)\n\n> 对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」;业管人员按月份维护运营明细,公式列系统自动计算。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务明细 |\n| 原型路径 | `src/prototypes/lease-business-detail` |\n| 预览地址 | `/prototypes/lease-business-detail` |\n| 目标用户 | 业务二部业管 / 运营人员 |\n| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |\n| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表(表尾 23 项汇总)+ 分页 |\n| 设计基底 | `vm-page` + `ldb-page` + `lc-page`(见 `ledger-shared/DESIGN.md`) |\n\n### 与「租赁业务台账」的分工\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细**(本模块) | 对齐业管 Excel 运营台账宽表;手工列导入维护;6 个公式列自动计算;收款状态按实收/应收展示 |\n| **租赁业务台账** | 账单维度维护、关联收款记录、收款状态(未收/部分/已收)、归档与角色数据范围 |\n\n下游 **业务部台账 / 业务汇总分析** 中「租赁业绩」数据来源为本模块明细(见 `oneos-web-data-analysis/05-业务部台账`)。\n\n### 不做范围(当前原型)\n\n- 租赁业务盈亏月度汇总(改由**业务部台账**汇总展示)\n- 服务端持久化、员工/主管数据权限隔离(主管预览:`?role=supervisor`)\n- 关联收款记录自动回写实收\n\n---\n\n## 2. 筛选区\n\n默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。\n\n| 筛选项 | 说明 |\n|---|---|\n| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |\n| 业务部门 | 精确匹配 |\n| 业务员 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |\n| 车牌号码 | 多选精确匹配(更多筛选) |\n\n查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。\n\n---\n\n## 3. KPI 统计卡片(7 张)\n\n顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。\n\n| KPI | 汇总字段 |\n|---|---|\n| 应收总计 | `receivableTotal` |\n| 押金总计 | `deposit`(仅数值型) |\n| 实收总计 | `receivedAmount` |\n| 里程减免总计 | `mileageDeduction` |\n| 其他减免总计 | `otherDeduction` |\n| 未收总计 | `outstanding`(>0 标红) |\n| 盈亏总计 | `profitLoss`(正负着色) |\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列手工录入)→ 选择重复策略 → 上传 Excel → 公式列自动计算 |\n| 批量导出 | 导出当前筛选结果(**状态** + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n业务主键:`年份 + 月份 + 车牌号码`。\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过 |\n| 覆盖重复 | 用导入行覆盖已有记录 |\n| 报错 | 重复行写入错误日志 |\n\n导入与编辑均校验:**实收金额 ≤ 应收合计**。\n\n---\n\n## 5. 明细列表(48 列)\n\n表头顺序对齐 Excel「2.租赁车辆收入明细表」,并在月份列左侧增加「状态」列。\n\n### 交互\n\n- 横向滚动;左冻结:**状态 + 月份 + 车牌号码**\n- 仅**日期类列**表头支持正序/倒序排序\n- 公式列(`calculated`)浅灰底色,导入模板不含\n- 表尾**汇总行**:23 个金额列 SUBTOTAL,月份列显示「汇总」\n- 未收 > 0 单元格标红;盈亏正负着色\n- 底部分页:10 / 20 / 50 / 100\n\n### 收款状态(状态列)\n\n| 状态 | 判定规则 |\n|---|---|\n| 未收款 | 实收金额 = 0 或未录入 |\n| 部分收款 | 实收金额 > 0 且 < 应收合计 |\n| 已结清 | 实收金额 ≥ 应收合计 |\n\n### 表尾汇总(23 项)\n\n押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。\n\n### 单元格系统校验\n\n部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。\n\n**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。\n\n| 字段 | 校验要点 |\n|---|---|\n| 车牌号码 | 是否在车辆管理中登记 |\n| **客户名称** | 见下表 |\n| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |\n| 押金、合同标的租金 | 与租赁合同一致 |\n| 应收租金 | = 合同标的租金 × 结算周期月数 |\n| 里程 / 其他减免 | 应以负数录入 |\n| 资产归属 | 与车辆登记所有权一致 |\n\n#### 客户名称判定顺序\n\n1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。\n2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。\n3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。\n4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。\n5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。\n\n车牌或客户名为空时不显示校验图标。\n\n---\n\n## 6. 公式列(系统自动计算)\n\n对齐 Excel 表内公式;实现见 `utils/calc-detail.ts`。\n\n| 字段 | 计算方式 |\n|---|---|\n| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **未收** | 应收合计 − 实收金额 |\n| **车辆实际成本** | 车辆标准成本 × 平均天数 |\n| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |\n| **盈亏** | 月度收入 − 总成本 |\n\n减免金额在 Excel 中以负数录入;导入时填负数即可。\n\n---\n\n## 7. 样例数据\n\n- 文件:`data/rows.json`\n- 来源:Excel《2026年业务二部运营台账总表6.17 - 新版》2026 年 1–6 月明细\n- 条数:553 条;汇总与 Excel SUBTOTAL 行一致\n\n---\n\n## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` |\n| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |\n| 保存校验 | `utils/validate-detail.ts` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---\n\n## 9. 验收重点\n\n1. 筛选区含「状态」;状态列展示未收款 / 部分收款 / 已结清\n2. 筛选后 KPI、表尾汇总与明细行合计一致\n3. 左冻结:状态 + 月份 + 车牌号码\n4. 氢费非零可点击明细;编辑/删除/变更日志可用\n5. 未收 > 0 标红;盈亏正负着色\n6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`)\n\n---\n\n## 10. 待规划项(评审记录)\n\n详见 `.spec/prototype-review.md`:\n\n- P2:与租赁业务台账 / 收款模块集成口径(实收自动回写)\n- P3:氢费预充值字段(业务部台账月度汇总占位,明细表暂无对应列)\n", + "markdown": "# 租赁业务明细 · 产品需求说明(PRD)\n\n> 对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」;业管人员按月份维护运营明细,公式列系统自动计算。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务明细 |\n| 原型路径 | `src/prototypes/lease-business-detail` |\n| 预览地址 | `/prototypes/lease-business-detail` |\n| 目标用户 | 业务二部业管 / 运营人员 |\n| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |\n| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表(表尾 23 项汇总)+ 分页 |\n| 设计基底 | `vm-page` + `ldb-page` + `lc-page`(见 `ledger-shared/DESIGN.md`) |\n\n### 与「租赁业务台账」的分工\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细**(本模块) | 对齐业管 Excel 运营台账宽表;手工列导入维护;6 个公式列自动计算;收款状态按实收/应收展示 |\n| **租赁业务台账** | 账单维度维护、关联收款记录、收款状态(未收/部分/已收)、归档与角色数据范围 |\n\n下游 **业务部台账 / 业务汇总分析** 中「租赁业绩」数据来源为本模块明细(见 `oneos-web-data-analysis/05-业务部台账`)。\n\n### 不做范围(当前原型)\n\n- 租赁业务盈亏月度汇总(改由**业务部台账**汇总展示)\n- 服务端持久化、员工/主管数据权限隔离(主管预览:`?role=supervisor`)\n- 关联收款记录自动回写实收\n\n---\n\n## 2. 筛选区\n\n默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。\n\n| 筛选项 | 说明 |\n|---|---|\n| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |\n| 业务部门 | 精确匹配 |\n| 业务员 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |\n| 车牌号码 | 多选精确匹配(更多筛选) |\n\n查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。\n\n---\n\n## 3. KPI 统计卡片(7 张)\n\n顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。\n\n| KPI | 汇总字段 |\n|---|---|\n| 应收总计 | `receivableTotal` |\n| 押金总计 | `deposit`(仅数值型) |\n| 实收总计 | `receivedAmount` |\n| 里程减免总计 | `mileageDeduction` |\n| 其他减免总计 | `otherDeduction` |\n| 未收总计 | `outstanding`(>0 标红) |\n| 盈亏总计 | `profitLoss`(正负着色) |\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列手工录入)→ 选择重复策略 → 上传 Excel → 公式列自动计算 |\n| 批量导出 | 导出当前筛选结果(**状态** + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n业务主键:`年份 + 月份 + 车牌号码 + 客户名称`(完整规则见 **`.spec/import-rules.md`**)。\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过 |\n| 覆盖重复 | 用导入行覆盖已有记录 |\n| 报错 | 重复行写入错误日志 |\n\n### 实收金额\n\n**不限制**实收 ≤ 应收合计:导入与编辑均允许实收大于应收合计(未收可为负,列表超额样式提示)。旧规则已停用,见 `.spec/import-rules.md`。\n\n---\n\n## 5. 明细列表(48 列)\n\n表头顺序对齐 Excel「2.租赁车辆收入明细表」,并在月份列左侧增加「状态」列。\n\n### 交互\n\n- 横向滚动;左冻结:**状态 + 月份 + 车牌号码**\n- 仅**日期类列**表头支持正序/倒序排序\n- 公式列(`calculated`)浅灰底色,导入模板不含\n- 表尾**汇总行**:23 个金额列 SUBTOTAL,月份列显示「汇总」\n- 未收 > 0 单元格标红;盈亏正负着色\n- 底部分页:10 / 20 / 50 / 100\n\n### 收款状态(状态列)\n\n| 状态 | 判定规则 |\n|---|---|\n| 未收款 | 实收金额 = 0 或未录入 |\n| 部分收款 | 实收金额 > 0 且 < 应收合计 |\n| 已结清 | 实收金额 ≥ 应收合计 |\n\n### 表尾汇总(23 项)\n\n押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。\n\n### 单元格系统校验\n\n部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。\n\n**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。\n\n| 字段 | 校验要点 |\n|---|---|\n| 车牌号码 | 是否在车辆管理中登记 |\n| **客户名称** | 见下表 |\n| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |\n| 押金、合同标的租金 | 与租赁合同一致 |\n| 应收租金 | = 合同标的租金 × 结算周期月数 |\n| 里程 / 其他减免 | 应以负数录入 |\n| 资产归属 | 与车辆登记所有权一致 |\n\n#### 客户名称判定顺序\n\n1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。\n2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。\n3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。\n4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。\n5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。\n\n车牌或客户名为空时不显示校验图标。\n\n---\n\n## 6. 公式列(系统自动计算)\n\n对齐 Excel 表内公式;实现见 `utils/calc-detail.ts`。\n\n| 字段 | 计算方式 |\n|---|---|\n| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **未收** | 应收合计 − 实收金额 |\n| **车辆实际成本** | 车辆标准成本 × 平均天数 |\n| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |\n| **盈亏** | 月度收入 − 总成本 |\n\n减免金额在 Excel 中以负数录入;导入时填负数即可。\n\n---\n\n## 7. 样例数据\n\n- 文件:`data/rows.json`\n- 来源:Excel《2026年业务二部运营台账总表6.17 - 新版》2026 年 1–6 月明细\n- 条数:553 条;汇总与 Excel SUBTOTAL 行一致\n\n---\n\n## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` |\n| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |\n| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) |\n| 导入规则说明 | `.spec/import-rules.md` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---\n\n## 9. 验收重点\n\n1. 筛选区含「状态」;状态列展示未收款 / 部分收款 / 已结清\n2. 筛选后 KPI、表尾汇总与明细行合计一致\n3. 左冻结:状态 + 月份 + 车牌号码\n4. 氢费非零可点击明细;编辑/删除/变更日志可用\n5. 未收 > 0 标红;盈亏正负着色\n6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`)\n7. 导入主键含客户名称;同车牌同月不同客户可并存\n8. 实收可大于应收合计:导入/编辑不拦截;未收可为负并有超额样式\n\n---\n\n## 10. 待规划项(评审记录)\n\n详见 `.spec/prototype-review.md`:\n\n- P2:与租赁业务台账 / 收款模块集成口径(实收自动回写)\n- P3:氢费预充值字段(业务部台账月度汇总占位,明细表暂无对应列)\n", "markdownPath": "src/prototypes/lease-business-detail/.spec/requirements-prd.md" }, { @@ -348,7 +349,7 @@ "type": "markdown", "id": "lbd-doc-lbd-toolbar", "title": "批量导入导出", - "markdown": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。\n\n---" + "markdown": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。\n\n### 实收金额\n\n允许实收大于应收合计(导入/编辑不拦截)。详见 `.spec/import-rules.md`。\n\n---" }, { "type": "markdown", @@ -376,6 +377,12 @@ "title": "状态", "markdown": "## 状态\n\n按**实收金额**与**应收合计**自动计算,只读展示。\n\n| 状态 | 判定规则 |\n|---|---|\n| **未收款** | 实收金额 = 0 或未录入 |\n| **部分收款** | 实收金额 > 0 且 < 应收合计 |\n| **已结清** | 实收金额 ≥ 应收合计 |\n\n列位置:月份列左侧;左冻结。\n\n---" }, + { + "type": "markdown", + "id": "lbd-doc-lbd-col-customer", + "title": "客户名称", + "markdown": "## 客户名称 · 系统校验\n\n导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。\n\n### 判定顺序\n\n1. 期望客户与当前名完全一致 → ✅ 与系统登记一致\n2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致\n3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**\n4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过\n5. 否则 → ⚠️ 请核对提车/退车日期\n\n完整规则见目录「单元格系统校验」或 `.spec/field-checks.md`。\n\n---" + }, { "type": "markdown", "id": "lbd-doc-lbd-col-receivable-total", @@ -430,13 +437,27 @@ "type": "markdown", "id": "lbd-doc-boundary", "title": "模块边界", - "markdown": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重与实收校验 |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---" + "markdown": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重(含客户名称) |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---" + }, + { + "type": "markdown", + "id": "lbd-doc-field-checks", + "title": "单元格系统校验", + "markdown": "# 租赁业务明细 · 单元格系统校验\n\n> 实现:`utils/field-checks.ts`、`utils/system-ref.ts`;UI:`components/FieldCheckIcon.tsx`\n\n列表部分列在单元格右侧展示**校验图标**(绿色 ✓ / 黄色 ⚠)。点击可查看说明;警告项在支持时提供**一键替换**为系统建议值。\n\n## 对照数据源(原型本地种子)\n\n| 来源 | 路径 | 用途 |\n|---|---|---|\n| 客户管理 | `customer-management/data/customers.json` | 客户名称是否已登记 |\n| 车辆管理 | `vehicle-management/data/vehicles.json` | 车牌是否存在、交车/还车日期、登记所有权 |\n| 明细种子合同 | `lease-business-detail/data/rows.json` | 车牌 + 客户 → 合同(押金、租金、提车/退车日期等) |\n\n启动时由 `system-ref.ts` 聚合为内存索引;**当前原型未接真实后端 API**。\n\n## 交互与状态\n\n| 状态 | 图标 | 点击行为 |\n|---|---|---|\n| `ok` | 绿色 ✓ | 展示通过说明(tooltip) |\n| `warn` | 黄色 ⚠ | 展示差异说明;部分字段可「一键替换」 |\n| `none` | 不显示 | 无校验条件或无需提示 |\n\n匹配规则:字符串比较前 **trim 首尾空格**;金额比较容差 **0.005 元**。\n\n---\n\n## 客户名称(`customerName`)\n\n**前置**:车牌、客户名称均非空,否则不校验。\n\n**期望客户 `expected`**:`resolveExpectedCustomer(车牌, 提车日期, 退车日期)`\n\n- 在同车牌的所有合同记录中筛选;\n- 若明细行有提车日期且合同有提车日期 → 必须一致;\n- 若明细行有退车日期且合同有退车日期 → 必须一致;\n- 取匹配合同的客户名称(多条时以后遍历结果为准)。\n\n**判定顺序**(命中即返回,不再往下):\n\n| 优先级 | 条件 | 结果 | 提示文案 |\n|---|---|---|---|\n| 1 | `expected` 非空且与当前客户名完全一致 | ✅ 通过 | 客户名称与系统登记一致 |\n| 2 | `getContractRef(车牌, 客户名)` 命中且名称一致 | ✅ 通过 | 客户名称与租赁合同一致 |\n| 3 | `expected` 非空但与当前客户名不一致 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;**建议客户:{expected}**;可一键替换 |\n| 4 | `isKnownCustomerName(客户名)` 为真 | ✅ 通过(弱校验) | 客户名称已在客户管理中登记 |\n| 5 | 以上均否 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;请核对提车日期与退车日期 |\n\n**`isKnownCustomerName` 弱校验**:\n\n- 与客户管理名单**完全一致**;或\n- **双向包含**(任一方名称包含另一方,如简称/全称)。\n\n---\n\n## 车牌号码(`plateNo`)\n\n| 条件 | 结果 | 说明 |\n|---|---|---|\n| 车牌为空 | 不校验 | — |\n| 车牌在车辆管理中 | ✅ 通过 | 车牌已在车辆管理中登记 |\n| 否则 | ⚠️ 警告 | 车牌不存在;该车牌未在系统「车辆管理」中登记,无法导入 |\n\n---\n\n## 提车日期(`pickupDate`)\n\n对照车辆管理 `lastDeliveryTime`(交车管理)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无提车日期 | 不校验 |\n| 与系统交车日期一致(YYYY-MM-DD) | ✅ 提车日期与交车管理一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 退车日期(`returnDate`)\n\n对照车辆管理 `lastReturnTime`(还车记录)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无退车日期 | 不校验 |\n| 与系统还车日期一致 | ✅ 退车日期与还车记录一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 押金(`deposit`)\n\n对照租赁合同保证金(`车牌 + 客户名` 合同)。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或押金非数值 | 不校验 |\n| 与合同保证金一致 | ✅ 押金与合同保证金一致 |\n| 不一致 | ⚠️ 警告;可一键替换为合同值 |\n\n---\n\n## 合同标的租金(`contractRent`)\n\n对照租赁合同租金。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或无租金 | 不校验 |\n| 与合同租金一致 | ✅ 租金与合同一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 应收租金(`receivableRent`)\n\n**期望**:`合同标的租金 × 结算周期月数`(月数来自合同 `paymentMethod` 解析:月度 1 / 季度 3 / 半年 6 / 年度 12)。\n\n| 条件 | 结果 |\n|---|---|\n| 期望与当前均为 0 | 不校验 |\n| 与期望一致 | ✅ 应收租金 = 合同标的租金 × N 个月 |\n| 不一致 | ⚠️ 警告;展示正确金额;可一键替换 |\n\n---\n\n## 里程减免 / 其他减免(`mileageDeduction`、`otherDeduction`)\n\n| 条件 | 结果 |\n|---|---|\n| 值为 0 | 不校验 |\n| 值为负数 | ✅ 以负数显示(符合 Excel 口径) |\n| 值为正数 | ⚠️ 警告:应录入负值 |\n\n---\n\n## 资产归属(`assetOwner`)\n\n对照车辆管理「登记所有权」。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无资产归属 | 不校验 |\n| 与登记所有权一致 | ✅ 与车辆登记所有权一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 启用校验的列\n\n在 `components/tableColumns.ts` 中通过 `checkField` 挂载,由 `DetailTable` 调用 `getFieldCheck()`:\n\n`plateNo`、`customerName`、`pickupDate`、`returnDate`、`deposit`、`contractRent`、`receivableRent`、`mileageDeduction`、`otherDeduction`、`assetOwner`。\n", + "markdownPath": "src/prototypes/lease-business-detail/.spec/field-checks.md" + }, + { + "type": "markdown", + "id": "lbd-doc-import-rules", + "title": "导入与金额规则", + "markdown": "# 租赁业务明细 · 导入与金额规则\n\n> 实现:`utils/row-key.ts`、`utils/import-merge.ts`、`utils/batch-io.ts`、`utils/validate-detail.ts` \n> UI:`components/DetailImportModal.tsx`、`components/DetailEditModal.tsx`\n\n## 导入业务主键(去重)\n\n| 组成字段 | 说明 |\n|---|---|\n| 年份 | `year` |\n| 月份 | `month` |\n| 车牌号码 | `plateNo`(trim) |\n| **客户名称** | `customerName`(trim) |\n\n键格式:`{year}-{MM}-{plateNo}::{customerName}`(见 `detailRowBusinessKey`)。\n\n同一主键下支持三种策略:\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过导入行 |\n| 覆盖重复 | 用导入行覆盖已有记录(保留原 `id`) |\n| 报错 | 重复行写入错误日志,不入库 |\n\n**边界**:同一车牌、同一月份、**不同客户**视为两条不同记录,不互相覆盖。\n\n数据源:原型本地导入解析 + 内存中的已有明细;**未接真实后端 API**。\n\n---\n\n## 实收金额 vs 应收合计\n\n| 项 | 规则 |\n|---|---|\n| 导入 | **不拦截**实收大于应收合计 |\n| 编辑保存 | **不拦截**实收大于应收合计 |\n| 未收计算 | 仍为「应收合计 − 实收金额」;实收超额时未收为负,列表用超额样式提示 |\n\n历史规则「实收金额 ≤ 应收合计」已在 `validate-detail.ts` 中注释停用,函数保留签名便于对照。\n\n---\n\n## 与收款状态的关系\n\n收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。\n", + "markdownPath": "src/prototypes/lease-business-detail/.spec/import-rules.md" }, { "type": "markdown", "id": "lbd-doc-source", "title": "源码入口", - "markdown": "## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` |\n| 校验 | `utils/validate-detail.ts` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---" + "markdown": "## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` |\n| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |\n| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) |\n| 校验说明 | `.spec/field-checks.md` |\n| 导入规则 | `.spec/import-rules.md` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---" } ] } diff --git a/src/prototypes/lease-business-detail/components/DetailEditModal.tsx b/src/prototypes/lease-business-detail/components/DetailEditModal.tsx index 180d619..74dc1d0 100644 --- a/src/prototypes/lease-business-detail/components/DetailEditModal.tsx +++ b/src/prototypes/lease-business-detail/components/DetailEditModal.tsx @@ -54,7 +54,7 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP
-

公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏、氢费等)保存后由系统自动重算。实收金额不得大于应收合计。

+

公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏、氢费等)保存后由系统自动重算。实收金额允许大于应收合计(未收可为负)。

{error ?

{error}

: null}
{DETAIL_EDIT_FIELDS.map((field) => ( diff --git a/src/prototypes/lease-business-detail/components/DetailImportModal.tsx b/src/prototypes/lease-business-detail/components/DetailImportModal.tsx index 23f6d84..54dbffc 100644 --- a/src/prototypes/lease-business-detail/components/DetailImportModal.tsx +++ b/src/prototypes/lease-business-detail/components/DetailImportModal.tsx @@ -16,7 +16,7 @@ interface DetailImportModalProps { } const DUPLICATE_OPTIONS: { value: DetailImportDuplicateStrategy; label: string; hint: string }[] = [ - { value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌」已存在则跳过' }, + { value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌+客户名称」已存在则跳过' }, { value: 'overwrite', label: '覆盖重复', hint: '用导入行覆盖已有记录' }, { value: 'error', label: '报错', hint: '重复行写入错误日志' }, ]; @@ -66,7 +66,7 @@ export function DetailImportModal({
2 - 重复记录处理(主键:年份 + 月份 + 车牌) + 重复记录处理(主键:年份 + 月份 + 车牌 + 客户名称)
{DUPLICATE_OPTIONS.map((option) => ( diff --git a/src/prototypes/lease-business-detail/scripts/sync-annotation-directory.mjs b/src/prototypes/lease-business-detail/scripts/sync-annotation-directory.mjs index 6a64a5c..d1b9ff8 100644 --- a/src/prototypes/lease-business-detail/scripts/sync-annotation-directory.mjs +++ b/src/prototypes/lease-business-detail/scripts/sync-annotation-directory.mjs @@ -10,10 +10,12 @@ const root = join(__dirname, '..'); const jsonPath = join(root, 'annotation-source.json'); const prdPath = join(root, '.spec/requirements-prd.md'); const fieldChecksPath = join(root, '.spec/field-checks.md'); +const importRulesPath = join(root, '.spec/import-rules.md'); const doc = JSON.parse(readFileSync(jsonPath, 'utf8')); const prd = readFileSync(prdPath, 'utf8'); const fieldChecksMd = readFileSync(fieldChecksPath, 'utf8'); +const importRulesMd = readFileSync(importRulesPath, 'utf8'); for (const node of doc.data.nodes) { if (node.annotationText) { @@ -64,10 +66,16 @@ doc.markdownMap['lbd-toolbar'] = `## 4. 列表工具栏 ### 导入重复策略 -主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。 +主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。 + +### 实收金额 + +允许实收大于应收合计(导入/编辑不拦截)。详见 \`.spec/import-rules.md\`。 ---`; +doc.markdownMap['lbd-doc-import-rules'] = importRulesMd; + doc.markdownMap['lbd-doc-formulas'] = `## 6. 公式列(系统自动计算) | 字段 | 计算方式 | @@ -85,7 +93,7 @@ doc.markdownMap['lbd-doc-boundary'] = `## 模块边界 | 模块 | 定位 | |---|---| -| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重与实收校验 | +| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重(含客户名称) | | **租赁业务台账** | 账单维度、收款关联、归档与数据范围 | | **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) | @@ -219,6 +227,13 @@ doc.directory.nodes = [ markdown: mm['lbd-doc-field-checks'], markdownPath: 'src/prototypes/lease-business-detail/.spec/field-checks.md', }, + { + type: 'markdown', + id: 'lbd-doc-import-rules', + title: '导入与金额规则', + markdown: mm['lbd-doc-import-rules'], + markdownPath: 'src/prototypes/lease-business-detail/.spec/import-rules.md', + }, { type: 'markdown', id: 'lbd-doc-source', @@ -233,10 +248,11 @@ doc.directory.nodes = [ | 筛选 / KPI 汇总 | \`utils/detail.ts\` | | 收款状态 | \`utils/payment-status.ts\` | | 公式计算 | \`utils/calc-detail.ts\` | -| 导入导出 | \`utils/batch-io.ts\`、\`utils/import-merge.ts\` | +| 导入导出 | \`utils/batch-io.ts\`、\`utils/import-merge.ts\`、\`utils/row-key.ts\` | | 单元格校验 | \`utils/field-checks.ts\`、\`utils/system-ref.ts\`、\`components/FieldCheckIcon.tsx\` | -| 保存校验 | \`utils/validate-detail.ts\` | +| 保存校验 | \`utils/validate-detail.ts\`(实收超额拦截已停用) | | 校验说明 | \`.spec/field-checks.md\` | +| 导入规则 | \`.spec/import-rules.md\` | | 变更日志 | \`utils/change-log.ts\` | | 类型 | \`types.ts\` | | 组件 | \`FilterPanel.tsx\`、\`DetailKpiRow.tsx\`、\`DetailTable.tsx\`、\`DetailImportModal.tsx\`、\`DetailEditModal.tsx\`、\`DetailChangeLogModal.tsx\` | diff --git a/src/prototypes/lease-business-detail/utils/batch-io.ts b/src/prototypes/lease-business-detail/utils/batch-io.ts index 8ec1e55..0814fdc 100644 --- a/src/prototypes/lease-business-detail/utils/batch-io.ts +++ b/src/prototypes/lease-business-detail/utils/batch-io.ts @@ -4,7 +4,6 @@ import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableC import { applyDetailCalculations } from './calc-detail'; import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref'; import { resolveDetailPaymentStatus } from './payment-status'; -import { validateDetailRowAmounts } from './validate-detail'; /** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */ export const DETAIL_IMPORT_TEMPLATE_HEADERS = [ @@ -373,12 +372,7 @@ export async function parseDetailImportFile(file: File): Promise { - const amountReason = validateDetailRowAmounts(row); - if (amountReason) { - amountErrors.push({ reason: amountReason, rawCells: emptyRawCells() }); - return; - } - + // 实收 ≤ 应收合计:已停用,允许实收大于应收合计 const key = detailRowBusinessKey(row); let existingIndex = keyToIndex.get(key); @@ -62,7 +57,7 @@ export function mergeDetailImportRows( } if (strategy === 'error') { duplicateErrors.push({ - reason: `重复记录:${row.year}年${row.month}月 · ${row.plateNo} 与本批导入重复`, + reason: `重复记录:${detailRowBusinessKeyLabel(row)} 与本批导入重复`, rawCells: emptyRawCells(), }); return; @@ -80,7 +75,7 @@ export function mergeDetailImportRows( if (strategy === 'error') { duplicateErrors.push({ - reason: `重复记录:${row.year}年${row.month}月 · ${row.plateNo} 已存在`, + reason: `重复记录:${detailRowBusinessKeyLabel(row)} 已存在`, rawCells: emptyRawCells(), }); return; diff --git a/src/prototypes/lease-business-detail/utils/row-key.ts b/src/prototypes/lease-business-detail/utils/row-key.ts index b7be0c4..738a571 100644 --- a/src/prototypes/lease-business-detail/utils/row-key.ts +++ b/src/prototypes/lease-business-detail/utils/row-key.ts @@ -1,8 +1,15 @@ import type { LeaseBusinessDetailRow } from '../types'; -/** 业务主键:年份 + 月份 + 车牌号码 */ +/** 业务主键:年份 + 月份 + 车牌号码 + 客户名称 */ export function detailRowBusinessKey( - row: Pick, + row: Pick, ): string { - return `${row.year}-${String(row.month).padStart(2, '0')}-${row.plateNo.trim()}`; + return `${row.year}-${String(row.month).padStart(2, '0')}-${row.plateNo.trim()}::${row.customerName.trim()}`; +} + +/** 重复提示用的可读标签 */ +export function detailRowBusinessKeyLabel( + row: Pick, +): string { + return `${row.year}年${row.month}月 · ${row.plateNo} · ${row.customerName.trim() || '(无客户)'}`; } diff --git a/src/prototypes/lease-business-detail/utils/validate-detail.ts b/src/prototypes/lease-business-detail/utils/validate-detail.ts index 5df78e6..d3a0718 100644 --- a/src/prototypes/lease-business-detail/utils/validate-detail.ts +++ b/src/prototypes/lease-business-detail/utils/validate-detail.ts @@ -3,14 +3,19 @@ import { applyDetailCalculations } from './calc-detail'; const MONEY_EPS = 0.005; -/** 实收不得超过应收合计(允许分位误差) */ +/** + * 【已停用】原规则:实收金额不得超过应收合计。 + * 产品已确认:实收金额可以大于应收合计(多收/预收场景),导入与编辑均不再拦截。 + * 保留函数签名供对照历史逻辑;当前始终返回 null。 + */ export function validateReceivedAgainstReceivable( - receivableTotal: number, - receivedAmount: number, + _receivableTotal: number, + _receivedAmount: number, ): string | null { - if (receivedAmount > receivableTotal + MONEY_EPS) { - return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)})`; - } + // if (receivedAmount > receivableTotal + MONEY_EPS) { + // return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)})`; + // } + void MONEY_EPS; return null; } @@ -18,7 +23,7 @@ export function validateDetailRowAmounts(row: LeaseBusinessDetailRow): string | return validateReceivedAgainstReceivable(row.receivableTotal, row.receivedAmount); } -/** 编辑保存前:合并 patch 并重算后校验 */ +/** 编辑保存前:合并 patch 并重算后校验(当前无金额上限拦截) */ export function validateDetailPatch( before: LeaseBusinessDetailRow, patch: Partial, diff --git a/src/prototypes/oneos-h5-h2-order/.spec/feature-create.md b/src/prototypes/oneos-h5-h2-order/.spec/feature-create.md new file mode 100644 index 0000000..9cb6850 --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/.spec/feature-create.md @@ -0,0 +1,46 @@ +# 功能:新增加氢记录 + +## 目标 + +站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。 + +## 表单分区 + +### 1. 基础信息 + +| 字段 | 规则 | +|------|------| +| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 | +| 加氢站名称 | 只读,登录本站名称 | + +### 2. 车牌识别 + +| 字段 | 规则 | +|------|------| +| 拍摄区 | 提示「请拍摄车牌」;相册/相机;OCR 模拟回填车牌 | +| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 | +| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 | +| 里程(km) | 选填 | + +### 3. 加氢机面板 + +| 字段 | 规则 | +|------|------| +| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;OCR 模拟回填单价、量、总额 | +| 氢气单价 * | 可改;变更后重算总额 | +| 加氢量 * | 可改;变更后重算总额 | +| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 | + +## 提交与拦截 + +- 必填齐全方可提交 +- 同站 + 同车牌 + 同加氢时间 → 拦截重复 +- 有填写内容点返回 → 二次确认防丢失 +- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) + +## 验收 + +1. 时间默认正确且可选到分 +2. 车牌键盘可用;标签随车牌变化 +3. 改单价或量后总额自动更新且不可手改 +4. 空表单可直接返回;有内容返回需确认 diff --git a/src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md b/src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md new file mode 100644 index 0000000..87ab82b --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md @@ -0,0 +1,35 @@ +# 功能:详情、编辑与删除 + +## 目标 + +查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。 + +## 详情展示 + +- 顶部:车牌、加氢时间、核对标签、对账标签 +- 指标条:加氢总额、加氢量 +- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 + +## 锁定规则 + +| 条件 | 结果 | +|------|------| +| 已对账 | 不可编辑/删除;提示已纳入站点对账单 | +| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 | +| 未核对且未对账 | 可编辑、可删除 | + +判定函数:`isOrderLocked`(核对或对账任一成立即锁)。 + +## 编辑 + +进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。 + +## 删除 + +二次确认后从 Bridge 移除该行。 + +## 验收 + +1. 未核对未对账:底部有删除/编辑 +2. 已核对或已对账:无操作栏,有锁定说明 +3. 详情含对账信息;列表卡片不含对账 diff --git a/src/prototypes/oneos-h5-h2-order/.spec/feature-list.md b/src/prototypes/oneos-h5-h2-order/.spec/feature-list.md new file mode 100644 index 0000000..c1d3015 --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/.spec/feature-list.md @@ -0,0 +1,40 @@ +# 功能:本站列表与 KPI + +## 目标 + +站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。 + +## 页面结构 + +1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 +2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 +3. **加氢记录列表**:按加氢时间倒序 +4. **底栏「新增」**:固定不遮挡卡片滚动区 + +## 列表卡片字段 + +| 展示 | 说明 | +|------|------| +| 车牌号 | 去掉尾缀 F 展示 | +| 核对标签 | 未核对(橙)/ 已核对(绿) | +| 加氢时间 | 精确到分 | +| 加氢量 / 加氢总额 | 等宽数字 | +| 单价 | 页脚左侧 | +| 核对时间 | 仅已核对时页脚右侧显示 | + +**不展示**:里程、对账状态(对账在站点对账单侧完成)。 + +## 交互 + +- 点击卡片 → 详情页 +- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) + +## 数据范围 + +仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。 + +## 验收 + +1. 无切站控件 +2. KPI 与列表条数一致 +3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 diff --git a/src/prototypes/oneos-h5-h2-order/.spec/feature-manual-ledger.md b/src/prototypes/oneos-h5-h2-order/.spec/feature-manual-ledger.md new file mode 100644 index 0000000..79a2453 --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/.spec/feature-manual-ledger.md @@ -0,0 +1,58 @@ +# 功能:手工台账上传 + +## 目标 + +站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。 + +## 产品口径 + +| 项 | 规则 | +|---|---| +| 入口 | H5:底部 Tab「手工台账」;Web:顶栏「手工台账」 | +| 上传形态 | 仅图片(拍照/相册),可多张 | +| 维度 | **加氢站 + 自然日(本地日历日)** 一份 | +| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 | +| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 | +| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 | +| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) | +| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store(未接真实 API) | + +## 判定顺序(新增拦截) + +| 优先级 | 条件 | 结果 | +|--------|------|------| +| 1 | 操作=编辑已有记录 | 不校验手工台账 | +| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 | +| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab | + +## 日历与补传 + +| 规则 | 说明 | +|------|------| +| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 | +| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 | +| 未传计数 | 本月内 `max(月初, 首笔日)`~min(月末, 今日) 中未上传的天数 | +| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 | +| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 | +| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 | + +## 用户可见 + +- 今日未上传:橙/黄提示条 + Tab 红点 +- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 +- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」 + +## 代码路径 + +- Bridge:`src/common/h2VehicleLedgerBridge.js`(`hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation`) +- H5:`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts`(`buildManualMonthCells` / `getFirstHydrogenDateKey`) +- Web:`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历) + +## 验收 + +1. 今日未上传时,H5/Web 点「新增」均被拦截并引导上传 +2. 上传至少一张图并确认后,可新增电子记录 +3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 +4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截 +5. 确认上传后的图显示已归档且不可删除;仅可追加补传 +6. H5 与 Web 共用同一 Store,一端上传另一端可见(同会话) diff --git a/src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md b/src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md new file mode 100644 index 0000000..9ee3792 --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md @@ -0,0 +1,30 @@ +# 车牌「羚牛车辆 / 非羚牛车辆」标签 + +原型本地判定,未接真实车辆管理 API。 + +## 判定顺序 + +| 优先级 | 条件 | 结果 | +|--------|------|------| +| 1 | 车牌为空 | 不展示标签 | +| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** | +| 3 | 有车牌但不在集合中 | **非羚牛车辆** | + +## 前置条件 + +- 识别(OCR)或键盘输入得到车牌后才展示。 +- 比较前去掉尾缀 `F`,统一大写。 + +## 数据源 + +- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。 +- 正式环境应改为查询车辆管理 / 系统车辆列表接口。 + +## 用户可见结果 + +- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。 +- 不拦截提交(仅提示归属)。 + +## 代码路径 + +- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。 diff --git a/src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md b/src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md index 98a9696..98b403e 100644 --- a/src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md +++ b/src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md @@ -1,51 +1,48 @@ -# 加氢订单(H5)· 对账联动规则 +# 加氢订单(H5)· 对账 / 核对联动规则 | 项 | 说明 | |---|---| -| 代码路径 | `src/common/h2VehicleLedgerBridge.js`;对账单 UI:`oneos-web-h2-station/pages/03-站点信息.jsx`;PC 加氢记录:`oneos-web-h2-station/pages/02-加氢记录.jsx` | +| 代码路径 | Store:`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`;PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` | +| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) | | 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) | -| 双端一致 | H5「加氢订单」与 Web「加氢记录」读写同一 Store、同一站端口径字段;Web 另有导出 / 预约加氢 | --- -## 1. 判定优先级 +## 1. 两套状态(判定优先级) -| 优先级 | 条件 | 站端 H5 展示 | -|---|---|---| -| 1 | `reconcileStatus === 'reconciled'` 或已写 `reconcileDate`(对账单提交回写) | **已对账**;对账时间取 `reconcileDate` / `reconciledAt`,格式 `YYYY-MM-DD HH:mm` | -| 2 | 否则 | **未对账**;对账时间显示「—」 | +| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 | +|---|---|---|---|---| +| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 | +| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 | -## 2. 前置条件 +> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。 -| 场景 | 是否校验 / 联动 | -|---|---| -| 未打开站点信息、未提交对账单 | 种子里已对账记录仍显示已对账;H5 新增默认为未对账 | -| 记录已绑定 `statementRecordId` | 不再进入下一张对账单 | -| 切换演示站点 | 仅过滤 `stationName`,不对其他站回写 | - -## 3. 数据流 +## 2. 数据流 ```text -站端 H5 加氢订单 / Web 加氢记录 新增/编辑 - → Bridge.upsertRow(reconcileStatus=pending,成本侧单价/量/总额) - → 共享 Store 通知订阅方(双端列表同步) +站端 H5 / Web 新增加氢记录 + → Bridge.upsertRow(verify=unverified,reconcile=pending;成本单价/量/总额) + → 车辆氢费明细可见 -站点信息 · 生成对账单 - → 筛选:本站 ∧ 无 statementRecordId ∧ (pending|reconciled) ∧ 账期内 - → 提交:写 statementRecordId、reconcileStatus=reconciled、reconcileDate - → applyPatches → H5 / Web 同步「已对账」+ 对账时间 +能源部 · 完成核对 + → verifyStatus=verified + verifiedAt + → 站端同步「已核对」,禁改删 + +站点信息 · 对账单提交(仅已核对 ∧ 未对账) + → reconcileStatus=reconciled + reconcileDate + statementRecordId + → 站端 / 台账同步「已对账」 ``` -## 4. 用户可见结果 +## 3. 用户可见结果(H5) -| 结果 | 文案 / 行为 | +| 结果 | 行为 | |---|---| -| 未对账 | 标签「未对账」;可编辑、删除 | -| 已对账 | 标签「已对账」;详情只读,提示「已纳入站点对账单,不可修改」 | -| 提交对账单成功后 | 同步出现对账时间(操作完成时间) | +| 未核对 | 可编辑、删除 | +| 已核对未对账 | 只读;提示能源部已核对 | +| 已对账 | 只读;提示已纳入对账单 | -## 5. 边界 +## 4. 边界 -- 站端口径单价/总额映射 `costUnitPrice` / `costTotal`(加氢机成本侧) -- OCR 为模拟识别,不解析真实图片数字 -- 跨页联动依赖同浏览器会话内未刷新导致的 Store 重建场景有限:刷新后以种子 + `H2_STATION_STATEMENT_LEDGER_UPDATES` 队列重建 +- 站端上报默认未核对、未对账 +- 对账时间只认 `reconcileDate`(对账单回写) +- 跨页联动依赖同浏览器会话共享 Store diff --git a/src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md b/src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md index d2f73ec..44e4c29 100644 --- a/src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md +++ b/src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md @@ -1,21 +1,21 @@ -# 加氢订单(H5)— 产品需求文档 +# 加氢订单(H5)— 产品需求文档(PRD) | 项 | 内容 | |---|---| -| 文档版本 | v1.0 | +| 文档版本 | v1.3 | | 模块名称 | 加氢站管理 → 加氢订单 | | 所属系统 | ONE-OS(加氢站手机浏览器) | | 交互原型 | `/prototypes/oneos-h5-h2-order` | | 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` | | 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) | -| 决策快照 | [2026-07-14-requirements-design.md](./2026-07-14-requirements-design.md) | -| 对账规则全文 | [reconcile-linkage.md](./reconcile-linkage.md) | --- ## 1. 背景与目标 -采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(数据结构一致;PC 另有导出与预约加氢)。 +采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据;PC 另有导出与预约加氢)。 + +每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。 ### 1.1 目标用户 @@ -23,16 +23,18 @@ ### 1.2 功能清单 -| # | 功能 | 说明 | +| # | 功能 | 说明文档 | |---|---|---| -| 1 | 本站列表 | 仅当前站;KPI:订单数 / 加氢量 / 加氢金额;字段见下表 | -| 2 | OCR 新增 | 单表单:拍车牌 / 拍面板 OCR 可校正后提交;返回时非空二次确认 | -| 3 | 未对账编辑/删除 | 已对账只读 | -| 4 | 对账单联动 | 共享氢费 Store;对账后状态与时间回写 | +| 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) | +| 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) | +| 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) | +| 4 | **手工台账上传** | [feature-manual-ledger.md](./feature-manual-ledger.md) | +| 5 | 羚牛车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) | +| 6 | 核对与对账联动 | [reconcile-linkage.md](./reconcile-linkage.md) | ### 1.3 列表字段 -加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间(`YYYY-MM-DD HH:mm`)。 +加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态;已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态。 ### 1.4 不做 @@ -43,10 +45,12 @@ ## 2. 验收项 1. 菜单位于 OneOS → 加氢站管理 → 加氢订单 -2. 默认已登录;可切换演示站点;列表仅本站 -3. FAB 进入单表单新增;OCR 可校正;时间默认当前;站名只读;有内容返回二次确认 -4. 已对账不可编辑/删除;未对账可 -5. 站点信息提交对账单后,本页对应记录显示「已对账」+ 对账时间(同会话 Store) +2. 默认已登录本站;列表仅本站(无切站) +3. 底栏 Tab:加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分) +4. 已核对或已对账不可编辑/删除;未核对未对账可 +5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间 +6. 同站同车牌同加氢时间重复保存被拦截 +7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致(同 Store) --- @@ -54,4 +58,20 @@ | 主题 | 摘要 | 全文 | |---|---|---| -| 对账联动 | 站端上报写入共享 Store(与 Web 加氢记录同源);对账单纳入 pending/已对账未结算记录;提交后标记已对账并写对账时间 | [reconcile-linkage.md](./reconcile-linkage.md) | +| 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) | +| 重复键 | 与 Web 共用 Bridge | [Web 数据模型](../oneos-web-h2-station/.spec/record-data-model.md) | +| 羚牛车辆标签 | 本地系统车辆集合判定 | [fleet-plate-tag.md](./fleet-plate-tag.md) | +| 总额 | 单价×加氢量自动算,界面不展示公式 | [feature-create.md](./feature-create.md) | +| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;未上传今日禁新增 | [feature-manual-ledger.md](./feature-manual-ledger.md) | + +--- + +## 4. 页面与标注对照 + +| 页面 pageId | 主要能力 | +|---|---| +| `list` | 站点头、KPI、订单卡片、底栏新增 | +| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 | +| `detail` | 详情字段、锁定说明、编辑/删除 | + +标注数据:`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。 diff --git a/src/prototypes/oneos-h5-h2-order/annotation-source.json b/src/prototypes/oneos-h5-h2-order/annotation-source.json index b610cbc..123658c 100644 --- a/src/prototypes/oneos-h5-h2-order/annotation-source.json +++ b/src/prototypes/oneos-h5-h2-order/annotation-source.json @@ -2,89 +2,443 @@ "documentVersion": 1, "format": "axhub-annotation-source", "data": { - "version": 1, + "version": 2, "prototypeName": "oneos-h5-h2-order", - "pageId": "h5-h2-order", - "updatedAt": 1784010000000, + "pageId": "list", + "updatedAt": 1784260628220, "nodes": [ { - "id": "h5-station", + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-hero", "index": 1, - "title": "当前加氢站(演示)", - "pageId": "h5-h2-order", - "locator": { - "selectors": ["#h5-station-switch", "[data-ax=\"h5-h2-order-root\"]"], - "fingerprint": "station|h5-h2-order", - "path": [] - }, - "annotationText": "已登录态演示:可切换示例站点;列表仅展示本站加氢记录。", - "hasMarkdown": false, + "title": "当前站点", + "pageId": "list", "color": "#7AB929", - "images": [], - "createdAt": 1784010000000, - "updatedAt": 1784010000000, - "controls": [] + "locator": { + "selectors": [ + "[data-annotation-id='h5-hero']", + ".h5-hero" + ], + "fingerprint": "hero|list", + "path": [] + } }, { - "id": "h5-fab", + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-summary", "index": 2, - "title": "新增加氢记录", - "pageId": "h5-h2-order", + "title": "本站 KPI", + "pageId": "list", + "color": "#0E8A7B", "locator": { - "selectors": [".h5-fab"], - "fingerprint": "fab|h5-h2-order", + "selectors": [ + "[data-annotation-id='h5-summary']", + ".h5-summary" + ], + "fingerprint": "summary|list", "path": [] - }, - "annotationText": "单表单新增:加氢时间默认当前;拍车牌/面板 OCR 可校正;有内容点返回会二次确认防丢失。", - "hasMarkdown": false, - "color": "#7AB929", - "images": [], - "createdAt": 1784010000000, - "updatedAt": 1784010000000, - "controls": [] + } }, { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", "id": "h5-card", "index": 3, "title": "订单卡片", - "pageId": "h5-h2-order", - "locator": { - "selectors": [".h5-card"], - "fingerprint": "card|h5-h2-order", - "path": [] - }, - "annotationText": "展示时间、车牌、单价、量、总额、对账状态/时间;已对账只读,未对账可编辑删除。", - "hasMarkdown": false, + "pageId": "list", "color": "#FF7D00", - "images": [], - "createdAt": 1784010000000, - "updatedAt": 1784010000000, - "controls": [] - } - ], - "directory": [ + "locator": { + "selectors": [ + "[data-annotation-id='h5-card']", + ".h5-card" + ], + "fingerprint": "card|list", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-fab", + "index": 4, + "title": "底栏新增", + "pageId": "list", + "color": "#7AB929", + "locator": { + "selectors": [ + "[data-annotation-id='h5-fab']", + ".h5-fab" + ], + "fingerprint": "fab|list", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-time", + "index": 5, + "title": "加氢时间", + "pageId": "create", + "color": "#7AB929", + "locator": { + "selectors": [ + "[data-annotation-id='h5-time']", + "#h5-time" + ], + "fingerprint": "field|h5-time", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-station", + "index": 6, + "title": "加氢站名称", + "pageId": "create", + "color": "#86909C", + "locator": { + "selectors": [ + "[data-annotation-id='h5-station']", + "#h5-station" + ], + "fingerprint": "field|h5-station", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-plate", + "index": 7, + "title": "车牌号", + "pageId": "create", + "color": "#FF7D00", + "locator": { + "selectors": [ + "[data-annotation-id='h5-plate']", + "#h5-plate" + ], + "fingerprint": "field|h5-plate", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-price", + "index": 8, + "title": "氢气单价", + "pageId": "create", + "color": "#7AB929", + "locator": { + "selectors": [ + "[data-annotation-id='h5-price']", + "#h5-price" + ], + "fingerprint": "field|h5-price", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-kg", + "index": 9, + "title": "加氢量", + "pageId": "create", + "color": "#7AB929", + "locator": { + "selectors": [ + "[data-annotation-id='h5-kg']", + "#h5-kg" + ], + "fingerprint": "field|h5-kg", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-total-label", + "index": 10, + "title": "加氢总额", + "pageId": "create", + "color": "#0E8A7B", + "locator": { + "selectors": [ + "[data-annotation-id='h5-total-label']", + "#h5-total" + ], + "fingerprint": "field|h5-total", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-detail", + "index": 11, + "title": "记录详情", + "pageId": "detail", + "color": "#FF7D00", + "locator": { + "selectors": [ + "[data-annotation-id='h5-detail']", + ".h5-panel--detail" + ], + "fingerprint": "detail|panel", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-manual", + "index": 12, + "title": "手工台账", + "pageId": "manual", + "color": "#7AB929", + "locator": { + "selectors": [ + "[data-annotation-id='h5-manual']", + ".h5-manual" + ], + "fingerprint": "manual|panel", + "path": [] + } + }, + { + "images": [], + "controls": [], + "createdAt": 1784260628220, + "updatedAt": 1784260628220, + "hasMarkdown": true, + "annotationText": "", + "id": "h5-manual-calendar", + "index": 13, + "title": "台账日历", + "pageId": "manual", + "color": "#0E8A7B", + "locator": { + "selectors": [ + "[data-annotation-id='h5-manual-calendar']", + ".h5-cal-grid" + ], + "fingerprint": "manual|calendar", + "path": [] + } + } + ] + }, + "markdownMap": { + "h5-hero": "## 当前站点\n\n只读展示登录账号对应加氢站,列表页不提供切站。\n\n详见 [本站列表](feature-list)。", + "h5-summary": "## 本站 KPI\n\n订单数 / 加氢量(kg) / 加氢金额,随本站列表实时汇总。\n\n详见功能说明「本站列表与 KPI」。", + "h5-card": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n", + "h5-fab": "## 底栏新增\n\n固定在列表底部,不遮挡卡片滚动区。点击进入新增表单,加氢时间默认=点击时刻。\n\n详见功能说明「新增加氢记录」。", + "h5-time": "## 加氢时间(必填)\n\n默认=点击「新增」时的当前时间;点击后底部弹层选择日期 + 时 + 分。", + "h5-station": "## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称,不可切换。", + "h5-plate": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定,未接真实车辆管理 API。\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别(OCR)或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。\n- 正式环境应改为查询车辆管理 / 系统车辆列表接口。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。\n", + "h5-price": "## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。", + "h5-kg": "## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。", + "h5-total-label": "## 加氢总额(元)\n\n只读展示;由单价×加氢量自动计算(两位小数)。界面标签不展示公式文案。", + "h5-detail": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n", + "h5-manual": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5:底部 Tab「手工台账」;Web:顶栏「手工台账」 |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store(未接真实 API) |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |\n| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`~min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |\n\n## 用户可见\n\n- 今日未上传:橙/黄提示条 + Tab 红点 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」\n\n## 代码路径\n\n- Bridge:`src/common/h2VehicleLedgerBridge.js`(`hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation`) \n- H5:`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts`(`buildManualMonthCells` / `getFirstHydrogenDateKey`) \n- Web:`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历) \n\n## 验收\n\n1. 今日未上传时,H5/Web 点「新增」均被拦截并引导上传 \n2. 上传至少一张图并确认后,可新增电子记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store,一端上传另一端可见(同会话) \n", + "h5-manual-calendar": "## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。" + }, + "assetMap": {}, + "directory": { + "nodes": [ { - "id": "h5-prd-root", - "title": "产品需求", "type": "folder", + "id": "h5-doc-root", + "title": "加氢订单(H5)说明", + "defaultExpanded": true, "children": [ { - "id": "h5-prd-main", - "title": "加氢订单(H5)PRD", "type": "markdown", - "markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md" + "id": "h5-doc-prd", + "title": "PRD 全文", + "markdown": "# 加氢订单(H5)— 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.3 |\n| 模块名称 | 加氢站管理 → 加氢订单 |\n| 所属系统 | ONE-OS(加氢站手机浏览器) |\n| 交互原型 | `/prototypes/oneos-h5-h2-order` |\n| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |\n| 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) |\n\n---\n\n## 1. 背景与目标\n\n采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据;PC 另有导出与预约加氢)。\n\n每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。\n\n### 1.1 目标用户\n\n加氢站操作人员(户外 / 站场手机使用)。\n\n### 1.2 功能清单\n\n| # | 功能 | 说明文档 |\n|---|---|---|\n| 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) |\n| 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) |\n| 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) |\n| 4 | **手工台账上传** | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n| 5 | 羚牛车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 6 | 核对与对账联动 | [reconcile-linkage.md](./reconcile-linkage.md) |\n\n### 1.3 列表字段\n\n加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态;已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态。\n\n### 1.4 不做\n\n真登录、真 OCR SDK、PLC、原生 App、后端联调。\n\n---\n\n## 2. 验收项\n\n1. 菜单位于 OneOS → 加氢站管理 → 加氢订单 \n2. 默认已登录本站;列表仅本站(无切站) \n3. 底栏 Tab:加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分) \n4. 已核对或已对账不可编辑/删除;未核对未对账可 \n5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间 \n6. 同站同车牌同加氢时间重复保存被拦截 \n7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致(同 Store) \n\n---\n\n## 3. 复杂逻辑摘要\n\n| 主题 | 摘要 | 全文 |\n|---|---|---|\n| 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 重复键 | 与 Web 共用 Bridge | [Web 数据模型](../oneos-web-h2-station/.spec/record-data-model.md) |\n| 羚牛车辆标签 | 本地系统车辆集合判定 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 总额 | 单价×加氢量自动算,界面不展示公式 | [feature-create.md](./feature-create.md) |\n| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;未上传今日禁新增 | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n\n---\n\n## 4. 页面与标注对照\n\n| 页面 pageId | 主要能力 |\n|---|---|\n| `list` | 站点头、KPI、订单卡片、底栏新增 |\n| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 |\n| `detail` | 详情字段、锁定说明、编辑/删除 |\n\n标注数据:`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。\n", + "markdownPath": ".spec/requirements-prd.md" }, { - "id": "h5-prd-reconcile", - "title": "对账联动规则", - "type": "markdown", - "markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md" + "type": "folder", + "id": "h5-doc-features", + "title": "功能说明", + "defaultExpanded": true, + "children": [ + { + "type": "markdown", + "id": "h5-doc-list", + "title": "本站列表与 KPI", + "markdown": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n", + "markdownPath": ".spec/feature-list.md" + }, + { + "type": "markdown", + "id": "h5-doc-create", + "title": "新增加氢记录", + "markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机;OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程(km) | 选填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;OCR 模拟回填单价、量、总额 |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 改单价或量后总额自动更新且不可手改 \n4. 空表单可直接返回;有内容返回需确认 \n", + "markdownPath": ".spec/feature-create.md" + }, + { + "type": "markdown", + "id": "h5-doc-manual", + "title": "手工台账上传", + "markdown": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5:底部 Tab「手工台账」;Web:顶栏「手工台账」 |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store(未接真实 API) |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |\n| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`~min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |\n\n## 用户可见\n\n- 今日未上传:橙/黄提示条 + Tab 红点 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」\n\n## 代码路径\n\n- Bridge:`src/common/h2VehicleLedgerBridge.js`(`hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation`) \n- H5:`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts`(`buildManualMonthCells` / `getFirstHydrogenDateKey`) \n- Web:`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历) \n\n## 验收\n\n1. 今日未上传时,H5/Web 点「新增」均被拦截并引导上传 \n2. 上传至少一张图并确认后,可新增电子记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store,一端上传另一端可见(同会话) \n", + "markdownPath": ".spec/feature-manual-ledger.md" + }, + { + "type": "markdown", + "id": "h5-doc-detail", + "title": "详情编辑删除", + "markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n", + "markdownPath": ".spec/feature-detail.md" + }, + { + "type": "markdown", + "id": "h5-doc-fleet", + "title": "羚牛车辆标签", + "markdown": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定,未接真实车辆管理 API。\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别(OCR)或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。\n- 正式环境应改为查询车辆管理 / 系统车辆列表接口。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。\n", + "markdownPath": ".spec/fleet-plate-tag.md" + }, + { + "type": "markdown", + "id": "h5-doc-reconcile", + "title": "核对与对账联动", + "markdown": "# 加氢订单(H5)· 对账 / 核对联动规则\n\n| 项 | 说明 |\n|---|---|\n| 代码路径 | Store:`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`;PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` |\n| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) |\n\n---\n\n## 1. 两套状态(判定优先级)\n\n| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |\n|---|---|---|---|---|\n| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |\n| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |\n\n> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。\n\n## 2. 数据流\n\n```text\n站端 H5 / Web 新增加氢记录\n → Bridge.upsertRow(verify=unverified,reconcile=pending;成本单价/量/总额)\n → 车辆氢费明细可见\n\n能源部 · 完成核对\n → verifyStatus=verified + verifiedAt\n → 站端同步「已核对」,禁改删\n\n站点信息 · 对账单提交(仅已核对 ∧ 未对账)\n → reconcileStatus=reconciled + reconcileDate + statementRecordId\n → 站端 / 台账同步「已对账」\n```\n\n## 3. 用户可见结果(H5)\n\n| 结果 | 行为 |\n|---|---|\n| 未核对 | 可编辑、删除 |\n| 已核对未对账 | 只读;提示能源部已核对 |\n| 已对账 | 只读;提示已纳入对账单 |\n\n## 4. 边界\n\n- 站端上报默认未核对、未对账\n- 对账时间只认 `reconcileDate`(对账单回写)\n- 跨页联动依赖同浏览器会话共享 Store\n", + "markdownPath": ".spec/reconcile-linkage.md" + } + ] }, { - "id": "h5-prd-decision", - "title": "需求与设计决策", - "type": "markdown", - "markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/2026-07-14-requirements-design.md" + "type": "folder", + "id": "h5-doc-pages", + "title": "按页面", + "defaultExpanded": true, + "children": [ + { + "type": "folder", + "id": "h5-page-list", + "title": "列表页", + "defaultExpanded": true, + "children": [ + { + "type": "route", + "id": "h5-route-list", + "title": "list", + "route": "list", + "payload": { + "pageId": "list" + } + }, + { + "type": "markdown", + "id": "h5-page-list-md", + "title": "列表页要点", + "markdown": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n" + } + ] + }, + { + "type": "folder", + "id": "h5-page-create", + "title": "新增页", + "defaultExpanded": true, + "children": [ + { + "type": "route", + "id": "h5-route-create", + "title": "create", + "route": "create", + "payload": { + "pageId": "create" + } + }, + { + "type": "markdown", + "id": "h5-page-create-md", + "title": "新增页要点", + "markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机;OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程(km) | 选填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;OCR 模拟回填单价、量、总额 |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 改单价或量后总额自动更新且不可手改 \n4. 空表单可直接返回;有内容返回需确认 \n" + } + ] + }, + { + "type": "folder", + "id": "h5-page-detail", + "title": "详情页", + "defaultExpanded": true, + "children": [ + { + "type": "route", + "id": "h5-route-detail", + "title": "detail", + "route": "detail", + "payload": { + "pageId": "detail" + } + }, + { + "type": "markdown", + "id": "h5-page-detail-md", + "title": "详情页要点", + "markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数:`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n" + } + ] + } + ] } ] } diff --git a/src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx b/src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx index 77d7c4d..d787d20 100644 --- a/src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx +++ b/src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx @@ -1,6 +1,8 @@ -import React, { useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import type { CreateDraft } from '../types'; +import { getVehicleTag } from '../utils/fleet-plates'; import { delay, mockPanelOcr, mockPlateOcr, readFileAsDataUrl } from '../utils/ocr-mock'; +import { DateTimePickerSheet } from './DateTimePickerSheet'; import { IconCamera, IconCheck, @@ -10,6 +12,7 @@ import { IconSpinner, } from './Icons'; import { PhoneShell } from './PhoneShell'; +import { PlateKeyboardSheet } from './PlateKeyboardSheet'; type Props = { mode: 'create' | 'edit'; @@ -26,6 +29,7 @@ function draftFingerprint(draft: CreateDraft): string { return [ draft.hydrogenTime.trim(), draft.plateNo.trim(), + draft.mileageKm.trim(), draft.unitPrice.trim(), draft.hydrogenKg.trim(), draft.totalAmount.trim(), @@ -44,6 +48,7 @@ export function isDraftDirty( } return Boolean( draft.plateNo.trim() || + draft.mileageKm.trim() || draft.unitPrice.trim() || draft.hydrogenKg.trim() || draft.totalAmount.trim() || @@ -52,6 +57,23 @@ export function isDraftDirty( ); } +function calcTotalAmount(unitPrice: string, hydrogenKg: string): string { + const price = Number(unitPrice); + const kg = Number(hydrogenKg); + if (!Number.isFinite(price) || !Number.isFinite(kg) || unitPrice === '' || hydrogenKg === '') { + return ''; + } + return String(Math.round(price * kg * 100) / 100); +} + +function RequiredMark() { + return ( + + * + + ); +} + export function CreateWizard({ mode, draft, @@ -66,6 +88,19 @@ export function CreateWizard({ const panelInputRef = useRef(null); const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null); const [submitting, setSubmitting] = useState(false); + const [timeOpen, setTimeOpen] = useState(false); + const [plateOpen, setPlateOpen] = useState(false); + + const vehicleTag = useMemo(() => getVehicleTag(draft.plateNo), [draft.plateNo]); + + function patchDraft(patch: Partial) { + const nextPrice = patch.unitPrice !== undefined ? patch.unitPrice : draft.unitPrice; + const nextKg = patch.hydrogenKg !== undefined ? patch.hydrogenKg : draft.hydrogenKg; + if (patch.unitPrice !== undefined || patch.hydrogenKg !== undefined) { + patch = { ...patch, totalAmount: calcTotalAmount(nextPrice, nextKg) }; + } + onChangeDraft(patch); + } async function handlePlateFile(file: File | null) { if (!file) return; @@ -153,28 +188,39 @@ export function CreateWizard({
} > -
+

基础信息

-

加氢时间默认当前,站点由登录账号带入。

- - + + 加氢时间 + +
- +
@@ -185,7 +231,7 @@ export function CreateWizard({

车牌识别

-

拍照或相册选择,OCR 结果可校正。

+

请拍摄车牌

- + +
+ + {vehicleTag === 'lingniu' ? ( + 羚牛车辆 + ) : null} + {vehicleTag === 'external' ? ( + 非羚牛车辆 + ) : null} +
+
+
+ onChangeDraft({ plateNo: e.target.value.toUpperCase() })} - placeholder="如 浙A88H201" + id="h5-mileage" + inputMode="numeric" + value={draft.mileageKm} + onChange={(e) => onChangeDraft({ mileageKm: e.target.value })} + placeholder="请输入里程" autoComplete="off" />
@@ -238,7 +310,7 @@ export function CreateWizard({

加氢机面板识别

-

识别单价、加氢量与总额,结果可校正。

+

请拍摄清晰的加氢机照片

- + onChangeDraft({ unitPrice: e.target.value })} + onChange={(e) => patchDraft({ unitPrice: e.target.value })} />
- + onChangeDraft({ hydrogenKg: e.target.value })} + onChange={(e) => patchDraft({ hydrogenKg: e.target.value })} />
- + onChangeDraft({ totalAmount: e.target.value })} + readOnly + disabled />
@@ -306,6 +389,25 @@ export function CreateWizard({ {toast} ) : null} + + setTimeOpen(false)} + onConfirm={(next) => { + onChangeDraft({ hydrogenTime: next }); + setTimeOpen(false); + }} + /> + setPlateOpen(false)} + onConfirm={(next) => { + onChangeDraft({ plateNo: next }); + setPlateOpen(false); + }} + /> ); } diff --git a/src/prototypes/oneos-h5-h2-order/components/DateTimePickerSheet.tsx b/src/prototypes/oneos-h5-h2-order/components/DateTimePickerSheet.tsx new file mode 100644 index 0000000..a8df89a --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/components/DateTimePickerSheet.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { pad2 } from '../utils/format'; + +type Props = { + open: boolean; + value: string; + onClose: () => void; + onConfirm: (next: string) => void; +}; + +function parseValue(value: string): { date: string; hour: number; minute: number } { + const raw = String(value || '').trim().replace('T', ' '); + const date = raw.slice(0, 10); + const hour = Number(raw.slice(11, 13)); + const minute = Number(raw.slice(14, 16)); + const now = new Date(); + return { + date: /^\d{4}-\d{2}-\d{2}$/.test(date) + ? date + : `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`, + hour: Number.isFinite(hour) ? hour : now.getHours(), + minute: Number.isFinite(minute) ? minute : now.getMinutes(), + }; +} + +export function DateTimePickerSheet({ open, value, onClose, onConfirm }: Props) { + const initial = useMemo(() => parseValue(value), [value, open]); + const [date, setDate] = useState(initial.date); + const [hour, setHour] = useState(initial.hour); + const [minute, setMinute] = useState(initial.minute); + + useEffect(() => { + if (!open) return; + const next = parseValue(value); + setDate(next.date); + setHour(next.hour); + setMinute(next.minute); + }, [open, value]); + + if (!open) return null; + + const preview = `${date} ${pad2(hour)}:${pad2(minute)}`; + + return ( +
+ + 加氢时间 + +
+
{preview}
+
+ + + +
+ + + ); +} diff --git a/src/prototypes/oneos-h5-h2-order/components/ManualLedgerPanel.tsx b/src/prototypes/oneos-h5-h2-order/components/ManualLedgerPanel.tsx new file mode 100644 index 0000000..3560fdf --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/components/ManualLedgerPanel.tsx @@ -0,0 +1,347 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { StationOption } from '../types'; +import { readFileAsDataUrl } from '../utils/ocr-mock'; +import { + buildManualMonthCells, + countMissingInMonth, + getFirstHydrogenDateKey, + getManualLedger, + isArchivedManualImage, + listManualLedgersByStation, + todayDateKey, + upsertManualLedger, + type ManualLedgerImage, +} from '../utils/manual-ledger'; +import { IconCamera, IconCheck } from './Icons'; + +type Props = { + station: StationOption; + tick: number; + onUploaded: () => void; + showToast: (msg: string) => void; +}; + +const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六']; + +export function ManualLedgerPanel({ station, tick, onUploaded, showToast }: Props) { + const today = todayDateKey(); + const fileRef = useRef(null); + const [draftImages, setDraftImages] = useState([]); + const [saving, setSaving] = useState(false); + const [selectedDate, setSelectedDate] = useState(today); + const [cursorYm, setCursorYm] = useState(() => { + const [y, m] = today.split('-').map(Number); + return { y, m }; + }); + + const stationKey = station.stationId || station.stationName; + + const allEntries = useMemo( + () => listManualLedgersByStation(stationKey), + [stationKey, tick], + ); + + const uploadedSet = useMemo(() => { + const set = new Set(); + allEntries.forEach((item) => { + if (item.images?.length) set.add(item.ledgerDate); + }); + return set; + }, [allEntries]); + + const firstHydrogenDate = useMemo( + () => getFirstHydrogenDateKey(stationKey) || getFirstHydrogenDateKey(station.stationName), + [stationKey, station.stationName, tick], + ); + + const selectedEntry = useMemo( + () => getManualLedger(stationKey, selectedDate), + [stationKey, selectedDate, tick], + ); + + const cells = useMemo( + () => buildManualMonthCells(cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate), + [cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate], + ); + + const missingInMonth = useMemo( + () => countMissingInMonth(cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate), + [cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate], + ); + + useEffect(() => { + if (selectedEntry?.images?.length) { + setDraftImages( + selectedEntry.images.map((img) => ({ + ...img, + archived: true, + })), + ); + } else { + setDraftImages([]); + } + }, [selectedEntry, selectedDate, tick]); + + const selectedUploaded = Boolean(selectedEntry?.images?.length); + const isTodaySelected = selectedDate === today; + const canEditSelected = selectedDate <= today; + const pendingNewCount = draftImages.filter((img) => !isArchivedManualImage(img)).length; + const canSubmit = canEditSelected && (selectedUploaded ? pendingNewCount > 0 : draftImages.length > 0); + + async function handleFiles(files: FileList | null) { + if (!files?.length || !canEditSelected) return; + const next: ManualLedgerImage[] = [...draftImages]; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.type.startsWith('image/')) continue; + next.push({ + id: `ml-img-${Date.now()}-${i}`, + name: file.name || `台账照片${next.length + 1}.jpg`, + dataUrl: await readFileAsDataUrl(file), + archived: false, + uploadedAt: new Date().toISOString().slice(0, 19).replace('T', ' '), + }); + } + setDraftImages(next); + } + + function removeImage(id: string) { + setDraftImages((prev) => + prev.filter((img) => { + if (img.id !== id) return true; + return !isArchivedManualImage(img); + }), + ); + } + + function handleSave() { + if (!canEditSelected) { + showToast('不能上传未来日期'); + return; + } + if (selectedUploaded && pendingNewCount === 0) { + showToast('请先添加补传照片'); + return; + } + if (!draftImages.length) { + showToast('请至少上传一张手工台账照片'); + return; + } + const ok = window.confirm('确认上传后该记录将归档无法修改,是否确认无误'); + if (!ok) return; + setSaving(true); + try { + const saved = upsertManualLedger({ + stationId: station.stationId, + stationName: station.stationName, + ledgerDate: selectedDate, + images: draftImages, + }); + if (!saved) { + showToast('上传失败,请重试'); + return; + } + showToast( + selectedUploaded + ? `${selectedDate} 已补传` + : isTodaySelected + ? '今日手工台账已上传' + : `${selectedDate} 手工台账已上传`, + ); + onUploaded(); + } finally { + setSaving(false); + } + } + + function shiftMonth(delta: number) { + setCursorYm((prev) => { + let m = prev.m + delta; + let y = prev.y; + if (m < 1) { + m = 12; + y -= 1; + } else if (m > 12) { + m = 1; + y += 1; + } + return { y, m }; + }); + } + + function selectDay(dateKey: string, isFuture: boolean) { + if (isFuture) return; + setSelectedDate(dateKey); + } + + return ( +
+
+ {uploadedSet.has(today) ? : null} +
+ {uploadedSet.has(today) ? '今日已上传' : '今日尚未上传'} +

+ {today} · {station.stationName} + {!uploadedSet.has(today) ? '。上传今日台账后才可新增加氢记录。' : ''} +

+
+
+ +
+
+ + + {cursorYm.y}年{cursorYm.m}月 + + +
+

+ {firstHydrogenDate + ? missingInMonth > 0 + ? `本月尚有 ${missingInMonth} 天未上传(自 ${firstHydrogenDate} 起),点选日期可补传` + : `本月自 ${firstHydrogenDate} 起截至今日均已上传` + : '本站尚无加氢记录,历史日期无需补传'} +

+
+ {WEEK_LABELS.map((w) => ( + {w} + ))} +
+
+ {cells.map((cell) => { + const cls = [ + 'h5-cal-day', + cell.inMonth ? '' : 'is-out', + cell.isFuture ? 'is-future' : '', + cell.isToday ? 'is-today' : '', + !cell.needsLedger && cell.inMonth && !cell.isFuture ? 'is-before-start' : '', + cell.needsLedger && cell.uploaded ? 'is-uploaded' : '', + cell.needsLedger && !cell.uploaded ? 'is-missing' : '', + selectedDate === cell.dateKey ? 'is-selected' : '', + ] + .filter(Boolean) + .join(' '); + const statusLabel = cell.isFuture + ? '未来' + : !cell.needsLedger + ? '无需补传' + : cell.uploaded + ? '已上传' + : '未上传'; + return ( + + ); + })} +
+
+ + 已上传 + + + 未上传 + +
+
+ +
+
+
+ +
+
+

+ 上传 {selectedDate} + {isTodaySelected ? '(今日)' : ''} 手工台账 +

+

+ {selectedUploaded + ? '该日台账已归档,仅可补传新图,不可删除' + : '请拍摄清晰的纸质台账照片,可多张'} +

+
+
+ +
+ {draftImages.map((img) => { + const archived = isArchivedManualImage(img); + return ( +
+ {img.dataUrl ? ( + {img.name} + ) : ( +
已归档
+ )} + {archived ? ( + 已归档 + ) : ( + + )} +
+ ); + })} + +
+ { + void handleFiles(e.target.files); + e.target.value = ''; + }} + /> + + +
+
+ ); +} diff --git a/src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx b/src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx index 88f86cd..a633fae 100644 --- a/src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx +++ b/src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx @@ -10,38 +10,45 @@ type Props = { }; export function OrderCard({ order, onClick, index = 0 }: Props) { - const done = order.reconcileStatus === 'reconciled'; + const verified = order.verifyStatus === 'verified'; return ( ); diff --git a/src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx b/src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx index 1d79b6d..f202622 100644 --- a/src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx +++ b/src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx @@ -1,8 +1,9 @@ import React from 'react'; import type { H5OrderRecord } from '../types'; -import { formatDateTimeMinute, formatKg, formatMoney, formatPlateDisplay } from '../utils/format'; +import { formatDateTimeMinute, formatKg, formatMileage, formatMoney, formatPlateDisplay } from '../utils/format'; import { IconCheck, IconChevronLeft, IconClock } from './Icons'; import { PhoneShell } from './PhoneShell'; +import { isOrderLocked } from '../utils/orders'; type Props = { order: H5OrderRecord; @@ -12,7 +13,9 @@ type Props = { }; export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) { - const done = order.reconcileStatus === 'reconciled'; + const reconciled = order.reconcileStatus === 'reconciled'; + const verified = order.verifyStatus === 'verified'; + const locked = isOrderLocked(order); return ( } - bodyClassName={done ? undefined : 'h5-body--wizard'} + bodyClassName={locked ? undefined : 'h5-body--wizard'} footer={ - done ? null : ( + locked ? null : (
+ 车牌号 + +
+
+ {draft || 请输入车牌号} +
+
+ {needProvince + ? PROVINCES.map((p) => ( + + )) + : ( + <> + {LETTERS.map((ch) => ( + + ))} + {DIGITS.map((ch) => ( + + ))} + {SPECIAL.map((ch) => ( + + ))} + + )} + + {!needProvince ? ( + + ) : null} +
+ + + ); +} diff --git a/src/prototypes/oneos-h5-h2-order/index.tsx b/src/prototypes/oneos-h5-h2-order/index.tsx index 2ecaeb1..8b1ba45 100644 --- a/src/prototypes/oneos-h5-h2-order/index.tsx +++ b/src/prototypes/oneos-h5-h2-order/index.tsx @@ -15,6 +15,7 @@ import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; import annotationSourceDocument from './annotation-source.json'; import { CreateWizard } from './components/CreateWizard'; import { + IconCamera, IconChevronLeft, IconDroplet, IconInbox, @@ -23,12 +24,17 @@ import { IconPlus, IconWallet, } from './components/Icons'; +import { ManualLedgerPanel } from './components/ManualLedgerPanel'; import { OrderCard } from './components/OrderCard'; import { OrderDetail } from './components/OrderDetail'; import { PhoneShell } from './components/PhoneShell'; import type { AppView, CreateDraft, StationOption } from './types'; import { formatDateTimeMinute, formatKg, formatMoney, nowDateTimeMinute } from './utils/format'; +import { hasManualLedgerForDate } from './utils/manual-ledger'; import { + findDuplicateStationOrder, + getTotalAmountMismatch, + isOrderLocked, listOrdersForStation, listStations, removeStationOrder, @@ -41,6 +47,7 @@ function emptyDraft(partial?: Partial): CreateDraft { return { hydrogenTime: nowDateTimeMinute(), plateNo: '', + mileageKm: '', unitPrice: '', hydrogenKg: '', totalAmount: '', @@ -56,11 +63,14 @@ export default function OneosH5H2Order() { }, []); const stations = useMemo(() => listStations(), []); - const [station, setStation] = useState( - () => stations[0] || { stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)' }, + /** 已登录本站(原型固定演示站,不提供切换) */ + const station: StationOption = useMemo( + () => stations[0] || { stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站' }, + [stations], ); const [tick, setTick] = useState(0); const [view, setView] = useState({ name: 'list' }); + const [mainTab, setMainTab] = useState<'orders' | 'manual'>('orders'); const [toast, setToast] = useState(''); useEffect(() => { @@ -79,6 +89,12 @@ export default function OneosH5H2Order() { } function openCreate() { + if (!hasManualLedgerForDate(station.stationId || station.stationName)) { + showToast('请先上传今日手工台账'); + setMainTab('manual'); + setView({ name: 'list' }); + return; + } const draft = emptyDraft(); setView({ name: 'create', @@ -90,7 +106,7 @@ export default function OneosH5H2Order() { function openEdit(id: string) { const order = orders.find((item) => item.id === id); - if (!order || order.reconcileStatus === 'reconciled') return; + if (!order || isOrderLocked(order)) return; const draft = emptyDraft({ id: order.id, hydrogenTime: @@ -98,6 +114,7 @@ export default function OneosH5H2Order() { ? nowDateTimeMinute() : order.hydrogenTime.slice(0, 16), plateNo: order.plateNo.replace(/F$/u, ''), + mileageKm: order.mileageKm != null ? String(order.mileageKm) : '', unitPrice: String(order.unitPrice), hydrogenKg: String(order.hydrogenKg), totalAmount: String(order.totalAmount), @@ -120,12 +137,41 @@ export default function OneosH5H2Order() { showToast('请完善加氢信息'); return; } + let mileageKm: number | null = null; + const mileageRaw = draft.mileageKm.trim(); + if (mileageRaw) { + const n = Number(mileageRaw); + if (!Number.isFinite(n) || n < 0) { + showToast('里程须为不小于 0 的数字'); + return; + } + mileageKm = Math.round(n); + } + if ( + findDuplicateStationOrder({ + id: draft.id, + stationName: station.stationName, + plateNo: draft.plateNo.trim(), + hydrogenTime: draft.hydrogenTime, + }) + ) { + showToast('已存在相同加氢站、车牌号与加氢时间的记录'); + return; + } + const mismatch = getTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount); + if (mismatch) { + const ok = window.confirm( + `加氢总额与单价×量不一致:应为 ${mismatch.expected.toFixed(2)} 元,当前 ${mismatch.actual.toFixed(2)} 元。仍要保存吗?`, + ); + if (!ok) return; + } const saved = upsertStationOrder({ id: draft.id, stationId: station.stationId, stationName: station.stationName, hydrogenTime: draft.hydrogenTime, plateNo: draft.plateNo.trim(), + mileageKm, unitPrice, hydrogenKg, totalAmount, @@ -141,7 +187,7 @@ export default function OneosH5H2Order() { function handleDelete(id: string) { const order = orders.find((item) => item.id === id); - if (!order || order.reconcileStatus === 'reconciled') return; + if (!order || isOrderLocked(order)) return; if (!window.confirm(`确认删除车牌 ${order.plateNo.replace(/F$/u, '')} 的未对账记录?`)) return; removeStationOrder(id); setTick((n) => n + 1); @@ -149,6 +195,9 @@ export default function OneosH5H2Order() { showToast('已删除'); } + const annotationPageId = + view.name === 'create' ? 'create' : view.name === 'detail' ? 'detail' : 'list'; + const annotationOptions = useMemo( () => ({ showToolbar: true, @@ -156,9 +205,9 @@ export default function OneosH5H2Order() { showColorFilter: true, emptyWhenNoData: false, toolbarEdge: 'right', - currentPageId: 'h5-h2-order', + currentPageId: annotationPageId, }), - [], + [annotationPageId], ); let content: React.ReactNode = null; @@ -208,90 +257,129 @@ export default function OneosH5H2Order() { 记录不存在 - 可能已切换站点,请返回列表查看 + 请返回列表查看
); } else { + const todayUploaded = hasManualLedgerForDate(station.stationId || station.stationName); content = ( - -
-
- - 当前加氢站(演示可切换) -
- - -
- -
-
-
- -
- {summary.count} - 订单数 -
-
-
- -
- {formatKg(summary.totalKg)} - 加氢量 kg -
-
-
- -
- {formatMoney(summary.totalAmount)} - 加氢金额 -
-
- - {orders.length === 0 ? ( -
-
- -
- 本站暂无加氢记录 - 点击下方「新增」开始上报第一笔订单 -
+ + {mainTab === 'orders' ? ( +
+ {!todayUploaded ? ( +

今日未上传手工台账,无法新增电子记录

+ ) : null} + +
+ ) : null} + + {toast ? ( +
+ {toast} +
+ ) : null} + + } + > + {mainTab === 'manual' ? ( + setTick((n) => n + 1)} + /> ) : ( - orders.map((order, index) => ( - setView({ name: 'detail', id: order.id })} - /> - )) - )} + <> +
+
+ + 当前站点 +
+
{station.stationName}
+
- - {toast ? ( -
- {toast} -
- ) : null} +
+
+
+ +
+ {summary.count} + 订单数 +
+
+
+ +
+ {formatKg(summary.totalKg)} + 加氢量 kg +
+
+
+ +
+ {formatMoney(summary.totalAmount)} + 加氢金额 +
+
+ + {orders.length === 0 ? ( +
+
+ +
+ 本站暂无加氢记录 + 点击下方「新增」开始上报第一笔订单 +
+ ) : ( +
+
+

加氢记录

+ 共 {orders.length} 条 +
+ {orders.map((order, index) => ( + setView({ name: 'detail', id: order.id })} + /> + ))} +
+ )} + + )}
); } diff --git a/src/prototypes/oneos-h5-h2-order/scripts/build-annotation-source.mjs b/src/prototypes/oneos-h5-h2-order/scripts/build-annotation-source.mjs new file mode 100644 index 0000000..4c88eac --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/scripts/build-annotation-source.mjs @@ -0,0 +1,374 @@ +/** + * 从 .spec/*.md 生成 annotation-source.json + * 用法:node src/prototypes/oneos-h5-h2-order/scripts/build-annotation-source.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const specDir = path.join(root, '.spec'); +const outFile = path.join(root, 'annotation-source.json'); +const now = Date.now(); + +function readMd(name) { + return fs.readFileSync(path.join(specDir, name), 'utf8'); +} + +function node(partial) { + return { + images: [], + controls: [], + createdAt: now, + updatedAt: now, + hasMarkdown: true, + annotationText: '', + ...partial, + }; +} + +const mdPrd = readMd('requirements-prd.md'); +const mdList = readMd('feature-list.md'); +const mdCreate = readMd('feature-create.md'); +const mdDetail = readMd('feature-detail.md'); +const mdManual = readMd('feature-manual-ledger.md'); +const mdFleet = readMd('fleet-plate-tag.md'); +const mdReconcile = readMd('reconcile-linkage.md'); + +const source = { + documentVersion: 1, + format: 'axhub-annotation-source', + data: { + version: 2, + prototypeName: 'oneos-h5-h2-order', + pageId: 'list', + updatedAt: now, + nodes: [ + node({ + id: 'h5-hero', + index: 1, + title: '当前站点', + pageId: 'list', + color: '#7AB929', + locator: { + selectors: ["[data-annotation-id='h5-hero']", '.h5-hero'], + fingerprint: 'hero|list', + path: [], + }, + }), + node({ + id: 'h5-summary', + index: 2, + title: '本站 KPI', + pageId: 'list', + color: '#0E8A7B', + locator: { + selectors: ["[data-annotation-id='h5-summary']", '.h5-summary'], + fingerprint: 'summary|list', + path: [], + }, + }), + node({ + id: 'h5-card', + index: 3, + title: '订单卡片', + pageId: 'list', + color: '#FF7D00', + locator: { + selectors: ["[data-annotation-id='h5-card']", '.h5-card'], + fingerprint: 'card|list', + path: [], + }, + }), + node({ + id: 'h5-fab', + index: 4, + title: '底栏新增', + pageId: 'list', + color: '#7AB929', + locator: { + selectors: ["[data-annotation-id='h5-fab']", '.h5-fab'], + fingerprint: 'fab|list', + path: [], + }, + }), + node({ + id: 'h5-time', + index: 5, + title: '加氢时间', + pageId: 'create', + color: '#7AB929', + locator: { + selectors: ["[data-annotation-id='h5-time']", '#h5-time'], + fingerprint: 'field|h5-time', + path: [], + }, + }), + node({ + id: 'h5-station', + index: 6, + title: '加氢站名称', + pageId: 'create', + color: '#86909C', + locator: { + selectors: ["[data-annotation-id='h5-station']", '#h5-station'], + fingerprint: 'field|h5-station', + path: [], + }, + }), + node({ + id: 'h5-plate', + index: 7, + title: '车牌号', + pageId: 'create', + color: '#FF7D00', + locator: { + selectors: ["[data-annotation-id='h5-plate']", '#h5-plate'], + fingerprint: 'field|h5-plate', + path: [], + }, + }), + node({ + id: 'h5-price', + index: 8, + title: '氢气单价', + pageId: 'create', + color: '#7AB929', + locator: { + selectors: ["[data-annotation-id='h5-price']", '#h5-price'], + fingerprint: 'field|h5-price', + path: [], + }, + }), + node({ + id: 'h5-kg', + index: 9, + title: '加氢量', + pageId: 'create', + color: '#7AB929', + locator: { + selectors: ["[data-annotation-id='h5-kg']", '#h5-kg'], + fingerprint: 'field|h5-kg', + path: [], + }, + }), + node({ + id: 'h5-total-label', + index: 10, + title: '加氢总额', + pageId: 'create', + color: '#0E8A7B', + locator: { + selectors: ["[data-annotation-id='h5-total-label']", '#h5-total'], + fingerprint: 'field|h5-total', + path: [], + }, + }), + node({ + id: 'h5-detail', + index: 11, + title: '记录详情', + pageId: 'detail', + color: '#FF7D00', + locator: { + selectors: ["[data-annotation-id='h5-detail']", '.h5-panel--detail'], + fingerprint: 'detail|panel', + path: [], + }, + }), + node({ + id: 'h5-manual', + index: 12, + title: '手工台账', + pageId: 'manual', + color: '#7AB929', + locator: { + selectors: ["[data-annotation-id='h5-manual']", '.h5-manual'], + fingerprint: 'manual|panel', + path: [], + }, + }), + node({ + id: 'h5-manual-calendar', + index: 13, + title: '台账日历', + pageId: 'manual', + color: '#0E8A7B', + locator: { + selectors: ["[data-annotation-id='h5-manual-calendar']", '.h5-cal-grid'], + fingerprint: 'manual|calendar', + path: [], + }, + }), + ], + }, + markdownMap: { + 'h5-hero': + '## 当前站点\n\n只读展示登录账号对应加氢站,列表页不提供切站。\n\n详见 [本站列表](feature-list)。', + 'h5-summary': + '## 本站 KPI\n\n订单数 / 加氢量(kg) / 加氢金额,随本站列表实时汇总。\n\n详见功能说明「本站列表与 KPI」。', + 'h5-card': mdList, + 'h5-fab': + '## 底栏新增\n\n固定在列表底部,不遮挡卡片滚动区。点击进入新增表单,加氢时间默认=点击时刻。\n\n详见功能说明「新增加氢记录」。', + 'h5-time': + '## 加氢时间(必填)\n\n默认=点击「新增」时的当前时间;点击后底部弹层选择日期 + 时 + 分。', + 'h5-station': + '## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称,不可切换。', + 'h5-plate': mdFleet, + 'h5-price': + '## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。', + 'h5-kg': + '## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。', + 'h5-total-label': + '## 加氢总额(元)\n\n只读展示;由单价×加氢量自动计算(两位小数)。界面标签不展示公式文案。', + 'h5-detail': mdDetail, + 'h5-manual': mdManual, + 'h5-manual-calendar': + '## 台账日历\n\n自本站首笔加氢记录日起:绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。', + }, + assetMap: {}, + directory: { + nodes: [ + { + type: 'folder', + id: 'h5-doc-root', + title: '加氢订单(H5)说明', + defaultExpanded: true, + children: [ + { + type: 'markdown', + id: 'h5-doc-prd', + title: 'PRD 全文', + markdown: mdPrd, + markdownPath: '.spec/requirements-prd.md', + }, + { + type: 'folder', + id: 'h5-doc-features', + title: '功能说明', + defaultExpanded: true, + children: [ + { + type: 'markdown', + id: 'h5-doc-list', + title: '本站列表与 KPI', + markdown: mdList, + markdownPath: '.spec/feature-list.md', + }, + { + type: 'markdown', + id: 'h5-doc-create', + title: '新增加氢记录', + markdown: mdCreate, + markdownPath: '.spec/feature-create.md', + }, + { + type: 'markdown', + id: 'h5-doc-manual', + title: '手工台账上传', + markdown: mdManual, + markdownPath: '.spec/feature-manual-ledger.md', + }, + { + type: 'markdown', + id: 'h5-doc-detail', + title: '详情编辑删除', + markdown: mdDetail, + markdownPath: '.spec/feature-detail.md', + }, + { + type: 'markdown', + id: 'h5-doc-fleet', + title: '羚牛车辆标签', + markdown: mdFleet, + markdownPath: '.spec/fleet-plate-tag.md', + }, + { + type: 'markdown', + id: 'h5-doc-reconcile', + title: '核对与对账联动', + markdown: mdReconcile, + markdownPath: '.spec/reconcile-linkage.md', + }, + ], + }, + { + type: 'folder', + id: 'h5-doc-pages', + title: '按页面', + defaultExpanded: true, + children: [ + { + type: 'folder', + id: 'h5-page-list', + title: '列表页', + defaultExpanded: true, + children: [ + { + type: 'route', + id: 'h5-route-list', + title: 'list', + route: 'list', + payload: { pageId: 'list' }, + }, + { + type: 'markdown', + id: 'h5-page-list-md', + title: '列表页要点', + markdown: mdList, + }, + ], + }, + { + type: 'folder', + id: 'h5-page-create', + title: '新增页', + defaultExpanded: true, + children: [ + { + type: 'route', + id: 'h5-route-create', + title: 'create', + route: 'create', + payload: { pageId: 'create' }, + }, + { + type: 'markdown', + id: 'h5-page-create-md', + title: '新增页要点', + markdown: mdCreate, + }, + ], + }, + { + type: 'folder', + id: 'h5-page-detail', + title: '详情页', + defaultExpanded: true, + children: [ + { + type: 'route', + id: 'h5-route-detail', + title: 'detail', + route: 'detail', + payload: { pageId: 'detail' }, + }, + { + type: 'markdown', + id: 'h5-page-detail-md', + title: '详情页要点', + markdown: mdDetail, + }, + ], + }, + ], + }, + ], + }, + ], + }, +}; + +fs.writeFileSync(outFile, `${JSON.stringify(source, null, 2)}\n`, 'utf8'); +console.log(`Wrote ${path.relative(process.cwd(), outFile)} (${source.data.nodes.length} markers)`); diff --git a/src/prototypes/oneos-h5-h2-order/styles/index.css b/src/prototypes/oneos-h5-h2-order/styles/index.css index 9f03f58..b208c39 100644 --- a/src/prototypes/oneos-h5-h2-order/styles/index.css +++ b/src/prototypes/oneos-h5-h2-order/styles/index.css @@ -134,64 +134,59 @@ scroll-behavior: smooth; } +.h5-body--list { + padding-bottom: 16px; +} + .h5-body--wizard { padding-bottom: calc(100px + env(safe-area-inset-bottom, 0px)); } -/* —— 站点头 —— */ +/* —— 站点头(只读本站,无切站) —— */ .h5-hero { position: relative; overflow: hidden; border-radius: 18px; - padding: 14px 14px 12px; + padding: 14px 16px 15px; margin-bottom: 12px; background: - linear-gradient(135deg, rgba(122, 185, 41, 0.18) 0%, rgba(122, 185, 41, 0.06) 48%, #fff 100%); - border: 1px solid rgba(122, 185, 41, 0.22); + linear-gradient(135deg, rgba(122, 185, 41, 0.2) 0%, rgba(122, 185, 41, 0.07) 42%, #fff 100%); + border: 1px solid rgba(122, 185, 41, 0.26); box-shadow: var(--shadow-card); } .h5-hero::after { content: ''; position: absolute; - right: -20px; - top: -24px; - width: 100px; - height: 100px; + right: -18px; + top: -28px; + width: 110px; + height: 110px; border-radius: 50%; - background: radial-gradient(circle, rgba(122, 185, 41, 0.28), transparent 70%); + background: radial-gradient(circle, rgba(122, 185, 41, 0.32), transparent 70%); pointer-events: none; } .h5-hero-label { + position: relative; + z-index: 1; display: inline-flex; align-items: center; - gap: 6px; + gap: 5px; font-size: 12px; font-weight: 600; color: var(--xll-green-deep); - margin-bottom: 8px; + margin-bottom: 6px; } -.h5-station-select { - width: 100%; - min-height: 44px; - border-radius: 12px; - border: 1px solid rgba(122, 185, 41, 0.28); - background: rgba(255, 255, 255, 0.92); - padding: 0 12px; - font-size: 15px; - font-weight: 600; +.h5-hero-station { + position: relative; + z-index: 1; + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; color: var(--color-text); - cursor: pointer; - transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out); -} - -.h5-station-select:hover, -.h5-station-select:focus { - border-color: var(--xll-green); - box-shadow: 0 0 0 3px var(--xll-green-soft); - outline: none; + line-height: 1.35; } /* —— KPI —— */ @@ -199,7 +194,7 @@ display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; - margin-bottom: 14px; + margin-bottom: 16px; } .h5-summary-chip { @@ -207,7 +202,7 @@ background: var(--color-bg); border: 1px solid var(--color-line); border-radius: 14px; - padding: 10px 8px 10px; + padding: 11px 8px 12px; box-shadow: var(--shadow-card); transition: transform 180ms var(--ease-out), box-shadow 180ms var(--ease-out); } @@ -223,7 +218,7 @@ display: inline-flex; align-items: center; justify-content: center; - margin-bottom: 6px; + margin-bottom: 7px; color: var(--xll-green-deep); background: var(--xll-green-soft); } @@ -240,7 +235,7 @@ .h5-summary-chip strong { display: block; - font-size: 15px; + font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.25; @@ -254,16 +249,43 @@ font-size: 11px; font-weight: 500; color: var(--color-muted); - margin-top: 2px; + margin-top: 3px; display: block; } +/* —— 列表分区 —— */ +.h5-list-section { + margin: 0; +} + +.h5-list-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin: 0 2px 10px; +} + +.h5-list-head__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.01em; +} + +.h5-list-head__count { + font-size: 12px; + font-weight: 600; + color: var(--color-muted); +} + /* —— 卡片 —— */ .h5-card { background: var(--color-bg); border: 1px solid var(--color-line); border-radius: 16px; - padding: 14px 14px 12px; + padding: 14px 14px 12px 16px; margin-bottom: 10px; box-shadow: var(--shadow-card); cursor: pointer; @@ -281,12 +303,6 @@ animation: h5CardIn 280ms var(--ease-out) both; } -.h5-card:nth-child(1) { animation-delay: 0ms; } -.h5-card:nth-child(2) { animation-delay: 40ms; } -.h5-card:nth-child(3) { animation-delay: 80ms; } -.h5-card:nth-child(4) { animation-delay: 120ms; } -.h5-card:nth-child(5) { animation-delay: 160ms; } - @keyframes h5CardIn { from { opacity: 0; @@ -304,15 +320,31 @@ left: 0; top: 0; bottom: 0; - width: 3px; - background: linear-gradient(180deg, var(--xll-green), #9fd14a); + width: 4px; + background: linear-gradient(180deg, #ff9a2e, var(--color-warn)); +} + +.h5-card--pending { + border-color: rgba(255, 125, 0, 0.28); + background: + linear-gradient(90deg, rgba(255, 125, 0, 0.05) 0%, #fff 28%); +} + +.h5-card--pending:hover { + border-color: rgba(255, 125, 0, 0.45); + box-shadow: 0 8px 22px -10px rgba(255, 125, 0, 0.18); } .h5-card--done::before { - background: linear-gradient(180deg, #c9cdd4, #a9aeb8); + background: linear-gradient(180deg, var(--xll-green), #9fd14a); } -.h5-card:hover { +.h5-card--done { + border-color: var(--color-line); + background: var(--color-bg); +} + +.h5-card--done:hover { border-color: rgba(122, 185, 41, 0.35); box-shadow: 0 8px 22px -10px rgba(15, 23, 42, 0.14); } @@ -353,13 +385,25 @@ } .h5-tag--pending { - background: rgba(255, 125, 0, 0.12); - color: #d97706; + background: rgba(255, 125, 0, 0.14); + color: #c05600; } .h5-tag--done { - background: rgba(0, 180, 42, 0.12); - color: #0f9d3a; + background: rgba(122, 185, 41, 0.16); + color: var(--xll-green-deep); +} + +.h5-tag--muted { + background: #f2f3f5; + color: var(--color-muted); +} + +.h5-tag-row { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; } .h5-card-meta { @@ -381,23 +425,35 @@ padding: 10px 12px; } +.h5-card-metrics span { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--color-muted); + margin-bottom: 3px; +} + .h5-card-metrics strong { - font-size: 16px; + display: block; + font-size: 17px; font-weight: 700; + line-height: 1.2; color: var(--color-text); } -.h5-card-metrics span { - display: block; +.h5-card-metrics strong em { + font-style: normal; font-size: 12px; + font-weight: 600; color: var(--color-muted); - margin-top: 2px; + margin-left: 2px; } .h5-card-metrics > div:last-child { text-align: right; } +.h5-card-metrics__money, .h5-card-metrics > div:last-child strong { color: var(--xll-green-deep); } @@ -410,9 +466,16 @@ color: var(--color-muted); display: flex; justify-content: space-between; + align-items: center; gap: 8px; } +.h5-card-foot__verify { + color: var(--xll-green-deep); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + /* —— 空态 —— */ .h5-empty { text-align: center; @@ -441,24 +504,31 @@ margin-bottom: 6px; } -/* —— FAB —— */ +/* —— 列表底栏新增(固定,不遮挡卡片) —— */ +.h5-fab-dock { + flex-shrink: 0; + padding: 10px 14px calc(12px + env(safe-area-inset-bottom, 0px)); + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(14px); + border-top: 1px solid var(--color-line); + box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.04); + z-index: 5; +} + .h5-fab { - position: absolute; - right: 16px; - bottom: calc(18px + env(safe-area-inset-bottom, 0px)); - min-width: 56px; - height: 56px; + width: 100%; + min-height: 48px; + height: 48px; padding: 0 18px; - border-radius: 999px; + border-radius: 14px; border: none; background: linear-gradient(145deg, #8bc73a 0%, var(--xll-green-deep) 100%); color: #fff; - font-size: 15px; + font-size: 16px; font-weight: 700; line-height: 1; cursor: pointer; - box-shadow: 0 10px 24px var(--xll-green-glow), 0 2px 0 rgba(255, 255, 255, 0.25) inset; - z-index: 5; + box-shadow: 0 6px 16px var(--xll-green-glow), 0 2px 0 rgba(255, 255, 255, 0.25) inset; display: inline-flex; align-items: center; justify-content: center; @@ -468,11 +538,11 @@ .h5-fab:hover { filter: brightness(1.03); - box-shadow: 0 14px 28px var(--xll-green-glow); + box-shadow: 0 10px 22px var(--xll-green-glow); } .h5-fab:active { - transform: scale(0.94); + transform: scale(0.98); } .h5-fab:focus-visible { @@ -484,6 +554,348 @@ letter-spacing: 0.02em; } +.h5-fab-dock__hint { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + color: #c05600; + text-align: center; + line-height: 1.4; +} + +.h5-tabbar { + flex-shrink: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + padding: 6px 10px calc(8px + env(safe-area-inset-bottom, 0px)); + background: rgba(255, 255, 255, 0.98); + border-top: 1px solid var(--color-line); +} + +.h5-tabbar-item { + position: relative; + min-height: 48px; + border: none; + background: transparent; + border-radius: 12px; + color: var(--color-muted); + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; +} + +.h5-tabbar-item.is-active { + color: var(--xll-green-deep); + background: var(--xll-green-soft); +} + +.h5-tabbar-dot { + position: absolute; + top: 8px; + right: calc(50% - 28px); + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-warn); +} + +.h5-banner-warn { + width: 100%; + margin-bottom: 12px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(255, 125, 0, 0.35); + background: rgba(255, 125, 0, 0.1); + color: #c05600; + font-size: 13px; + font-weight: 600; + text-align: left; + cursor: pointer; + line-height: 1.45; +} + +.h5-manual-status { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 12px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 125, 0, 0.1); + border: 1px solid rgba(255, 125, 0, 0.28); + color: #c05600; +} + +.h5-manual-status--ok { + background: rgba(122, 185, 41, 0.12); + border-color: rgba(122, 185, 41, 0.28); + color: var(--xll-green-deep); +} + +.h5-manual-status strong { + display: block; + font-size: 15px; + margin-bottom: 2px; +} + +.h5-manual-status p { + margin: 0; + font-size: 12px; + font-weight: 500; + line-height: 1.45; + opacity: 0.92; +} + +.h5-manual-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.h5-manual-thumb { + position: relative; + aspect-ratio: 1; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--color-line); + background: #f7f8fa; +} + +.h5-manual-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.h5-manual-thumb__ph { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--color-muted); +} + +.h5-manual-thumb__del { + position: absolute; + left: 4px; + right: 4px; + bottom: 4px; + min-height: 28px; + border: none; + border-radius: 8px; + background: rgba(15, 23, 42, 0.72); + color: #fff; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.h5-manual-thumb__badge { + position: absolute; + left: 4px; + right: 4px; + bottom: 4px; + min-height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: rgba(15, 23, 42, 0.55); + color: #fff; + font-size: 11px; + font-weight: 600; + pointer-events: none; +} + +.h5-manual-thumb.is-archived { + border-color: #cbd5e1; +} + +.h5-manual-add { + aspect-ratio: 1; + border-radius: 12px; + border: 1.5px dashed rgba(122, 185, 41, 0.45); + background: rgba(122, 185, 41, 0.06); + color: var(--xll-green-deep); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.h5-manual-history { + list-style: none; + margin: 0; + padding: 0; +} + +.h5-manual-history li { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 10px 0; + border-bottom: 1px solid #f2f3f5; + font-size: 13px; +} + +.h5-manual-history li:last-child { + border-bottom: none; +} + +.h5-manual-history span { + color: var(--color-muted); +} + +.h5-manual-empty { + margin: 0; + font-size: 13px; + color: var(--color-muted); +} + +/* —— 手工台账日历 —— */ +.h5-cal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.h5-cal-nav { + min-width: 44px; + min-height: 44px; + border: none; + border-radius: 12px; + background: #f7f8fa; + color: var(--color-text); + font-size: 22px; + font-weight: 600; + cursor: pointer; +} + +.h5-cal-nav:active { + background: var(--xll-green-soft); +} + +.h5-cal-summary { + margin: 0 0 10px; + font-size: 12px; + font-weight: 600; + color: var(--color-text-sec); +} + +.h5-cal-week { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + margin-bottom: 4px; + text-align: center; + font-size: 11px; + font-weight: 600; + color: var(--color-muted); +} + +.h5-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.h5-cal-day { + position: relative; + min-height: 44px; + border: 1px solid transparent; + border-radius: 10px; + background: #f7f8fa; + color: var(--color-text); + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 4px 0 6px; +} + +.h5-cal-day.is-out { + opacity: 0.35; +} + +.h5-cal-day.is-future { + opacity: 0.4; + cursor: not-allowed; +} + +.h5-cal-day.is-today { + border-color: rgba(122, 185, 41, 0.45); +} + +.h5-cal-day.is-before-start { + color: var(--color-muted); + background: #f7f8fa; +} + +.h5-cal-day.is-uploaded { + background: rgba(122, 185, 41, 0.14); + color: var(--xll-green-deep); +} + +.h5-cal-day.is-missing { + background: rgba(255, 125, 0, 0.12); + color: #c05600; +} + +.h5-cal-day.is-selected { + outline: 2px solid var(--xll-green); + outline-offset: 1px; +} + +.h5-cal-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #ff9a2e; + display: inline-block; +} + +.h5-cal-dot.is-on { + background: var(--xll-green); +} + +.h5-cal-legend { + display: flex; + gap: 16px; + margin-top: 10px; + font-size: 12px; + color: var(--color-muted); + font-weight: 600; +} + +.h5-cal-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.h5-toast--above-dock { + bottom: calc(120px + env(safe-area-inset-bottom, 0px)); +} + /* —— 表单面板 —— */ .h5-panel { background: var(--color-bg); @@ -623,6 +1035,80 @@ margin-bottom: 6px; } +.h5-req { + color: var(--color-danger); + margin-right: 3px; + font-weight: 700; +} + +.h5-field-trigger { + width: 100%; + min-height: 46px; + border-radius: 12px; + border: 1px solid var(--color-line); + background: var(--color-page); + padding: 0 12px; + font-size: 16px; + color: var(--color-text); + box-sizing: border-box; + font: inherit; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out), background 160ms var(--ease-out); +} + +.h5-field-trigger:hover { + border-color: #c9cdd4; +} + +.h5-field-trigger:focus-visible { + outline: none; + border-color: var(--xll-green); + box-shadow: 0 0 0 3px var(--xll-green-soft); + background: #fff; +} + +.h5-field-trigger:active { + transform: scale(0.995); +} + +.h5-field-trigger__ph { + color: var(--color-muted); + font-weight: 500; +} + +.h5-plate-row { + display: flex; + align-items: center; + gap: 8px; +} + +.h5-plate-row .h5-field-trigger { + flex: 1; + min-width: 0; +} + +.h5-vehicle-tag { + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + padding: 6px 9px; + border-radius: 999px; + white-space: nowrap; +} + +.h5-vehicle-tag--in { + background: rgba(122, 185, 41, 0.16); + color: var(--xll-green-deep); +} + +.h5-vehicle-tag--out { + background: rgba(255, 125, 0, 0.14); + color: #c05600; +} + .h5-field input { width: 100%; min-height: 46px; @@ -778,16 +1264,72 @@ color: var(--color-text); } +.h5-panel--detail { + padding-top: 14px; +} + .h5-detail-hero { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 10px; - margin-bottom: 4px; - padding-bottom: 12px; + margin-bottom: 12px; + padding-bottom: 14px; border-bottom: 1px solid #f2f3f5; } +.h5-detail-hero__time { + margin-top: 4px; + font-size: 13px; + font-weight: 500; + color: var(--color-text-sec); +} + +.h5-detail-metrics { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 8px; + margin-bottom: 4px; + padding: 12px; + border-radius: 12px; + background: + linear-gradient(135deg, rgba(122, 185, 41, 0.12) 0%, rgba(122, 185, 41, 0.04) 50%, #f7f8fa 100%); + border: 1px solid rgba(122, 185, 41, 0.18); + font-variant-numeric: tabular-nums; +} + +.h5-detail-metrics span { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--color-muted); + margin-bottom: 4px; +} + +.h5-detail-metrics strong { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.2; + color: var(--xll-green-deep); +} + +.h5-detail-metrics > div:last-child { + text-align: right; +} + +.h5-detail-metrics > div:last-child strong { + color: var(--color-text); +} + +.h5-detail-metrics strong em { + font-style: normal; + font-size: 12px; + font-weight: 600; + color: var(--color-muted); + margin-left: 2px; +} + .h5-loading { display: flex; align-items: center; @@ -813,9 +1355,11 @@ margin: 8px 0 0; padding: 12px 14px; border-radius: 12px; - background: #f7f8fa; - color: var(--color-text-sec); + background: rgba(122, 185, 41, 0.1); + border: 1px solid rgba(122, 185, 41, 0.2); + color: var(--xll-green-deep); font-size: 13px; + font-weight: 500; line-height: 1.5; display: flex; align-items: flex-start; @@ -834,6 +1378,202 @@ border: 0; } +.tabular-nums { + font-variant-numeric: tabular-nums; +} + +/* —— 底部弹层:时间 / 车牌 —— */ +.h5-sheet-root { + position: absolute; + inset: 0; + z-index: 30; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.h5-sheet-mask { + position: absolute; + inset: 0; + border: none; + padding: 0; + margin: 0; + background: rgba(15, 23, 42, 0.45); + cursor: pointer; +} + +.h5-sheet { + position: relative; + z-index: 1; + background: #fff; + border-radius: 18px 18px 0 0; + padding: 0 14px calc(14px + env(safe-area-inset-bottom, 0px)); + box-shadow: 0 -12px 32px rgba(15, 23, 42, 0.16); + animation: h5SheetIn 220ms var(--ease-out); +} + +.h5-sheet--plate { + max-height: min(72%, 560px); + overflow: auto; +} + +@keyframes h5SheetIn { + from { + opacity: 0.6; + transform: translateY(24px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.h5-sheet-bar { + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border-bottom: 1px solid #f2f3f5; +} + +.h5-sheet-bar strong { + font-size: 15px; + font-weight: 700; +} + +.h5-sheet-bar-btn { + min-width: 52px; + min-height: 44px; + border: none; + background: transparent; + color: var(--color-text-sec); + font-size: 15px; + font-weight: 600; + cursor: pointer; + border-radius: 10px; +} + +.h5-sheet-bar-btn--ok { + color: var(--xll-green-deep); +} + +.h5-sheet-bar-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.h5-sheet-bar-btn:focus-visible { + outline: 2px solid var(--xll-green); + outline-offset: 2px; +} + +.h5-sheet-preview { + text-align: center; + font-size: 20px; + font-weight: 700; + padding: 14px 0 8px; + color: var(--color-text); +} + +.h5-dt-grid { + display: grid; + grid-template-columns: 1.4fr 0.8fr 0.8fr; + gap: 8px; + padding-bottom: 8px; +} + +.h5-dt-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--color-muted); +} + +.h5-dt-field input, +.h5-dt-field select { + min-height: 46px; + border-radius: 12px; + border: 1px solid var(--color-line); + background: var(--color-page); + padding: 0 10px; + font-size: 16px; + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.h5-dt-field input:focus, +.h5-dt-field select:focus { + outline: none; + border-color: var(--xll-green); + box-shadow: 0 0 0 3px var(--xll-green-soft); + background: #fff; +} + +.h5-plate-draft { + min-height: 48px; + margin: 12px 0; + border-radius: 12px; + border: 1px solid rgba(122, 185, 41, 0.28); + background: rgba(122, 185, 41, 0.08); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.12em; + font-variant-numeric: tabular-nums; +} + +.h5-plate-draft__ph { + letter-spacing: 0; + font-size: 14px; + font-weight: 500; + color: var(--color-muted); +} + +.h5-plate-keys { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 6px; + padding-bottom: 4px; +} + +.h5-plate-key { + min-height: 40px; + border-radius: 10px; + border: 1px solid var(--color-line); + background: #f7f8fa; + color: var(--color-text); + font-size: 14px; + font-weight: 700; + cursor: pointer; + touch-action: manipulation; +} + +.h5-plate-key:active { + background: var(--xll-green-soft); + border-color: rgba(122, 185, 41, 0.35); +} + +.h5-plate-key--soft { + font-size: 12px; + color: var(--color-text-sec); +} + +.h5-plate-key--wide { + grid-column: span 4; + background: #eceef1; +} + +@media (prefers-reduced-motion: reduce) { + .h5-sheet { + animation: none !important; + } +} + @media (prefers-reduced-motion: reduce) { .h5-fab, .h5-card, @@ -866,11 +1606,11 @@ } .h5-fab-label { - display: none; + display: inline; } .h5-fab { - width: 56px; - padding: 0; + width: 100%; + padding: 0 18px; } } diff --git a/src/prototypes/oneos-h5-h2-order/types.ts b/src/prototypes/oneos-h5-h2-order/types.ts index 4c1f27d..4194327 100644 --- a/src/prototypes/oneos-h5-h2-order/types.ts +++ b/src/prototypes/oneos-h5-h2-order/types.ts @@ -1,4 +1,5 @@ export type ReconcileStatus = 'pending' | 'reconciled'; +export type VerifyStatus = 'unverified' | 'verified'; export type StationOption = { stationId: string; @@ -11,9 +12,13 @@ export type H5OrderRecord = { stationName: string; hydrogenTime: string; plateNo: string; + /** 里程 km;未填为 null */ + mileageKm: number | null; unitPrice: number; hydrogenKg: number; totalAmount: number; + verifyStatus: VerifyStatus; + verifiedAt: string; reconcileStatus: ReconcileStatus; reconcileTime: string; }; @@ -22,6 +27,7 @@ export type CreateDraft = { id?: string; hydrogenTime: string; plateNo: string; + mileageKm: string; unitPrice: string; hydrogenKg: string; totalAmount: string; diff --git a/src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts b/src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts new file mode 100644 index 0000000..6b88e27 --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts @@ -0,0 +1,47 @@ +/** 原型本地「系统车辆列表」车牌(不含尾缀 F),用于羚牛/非羚牛判定 */ +const FLEET_PLATES = new Set( + [ + '浙A55666', + '浙A77888', + '浙A99001', + '浙A12345', + '浙A67890', + '浙A88888', + '浙A03561', + '浙B23456', + '浙B99999', + '浙B58888', + '沪A88888', + '沪BDB9161', + '沪ADB9161', + '苏E33333', + '浙A88H201', + '浙F00688', + '浙F07588', + '浙F06618', + '粤AGR8556', + '粤AGP5156', + ].map((p) => p.toUpperCase()), +); + +export function normalizePlateKey(plateNo: string): string { + return String(plateNo || '') + .trim() + .toUpperCase() + .replace(/F$/u, ''); +} + +/** 车牌是否在系统车辆列表中 → 羚牛车辆 */ +export function isLingniuVehicle(plateNo: string): boolean { + const key = normalizePlateKey(plateNo); + if (!key) return false; + return FLEET_PLATES.has(key); +} + +export type VehicleTagKind = 'lingniu' | 'external' | null; + +export function getVehicleTag(plateNo: string): VehicleTagKind { + const key = normalizePlateKey(plateNo); + if (!key) return null; + return isLingniuVehicle(key) ? 'lingniu' : 'external'; +} diff --git a/src/prototypes/oneos-h5-h2-order/utils/format.ts b/src/prototypes/oneos-h5-h2-order/utils/format.ts index 4693f0f..1a0888e 100644 --- a/src/prototypes/oneos-h5-h2-order/utils/format.ts +++ b/src/prototypes/oneos-h5-h2-order/utils/format.ts @@ -30,3 +30,11 @@ export function formatKg(n: number): string { if (!Number.isFinite(n)) return '—'; return n.toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 2 }); } + +/** 里程展示;未填显示 — */ +export function formatMileage(n: number | null | undefined): string { + if (n == null) return '—'; + const v = Number(n); + if (!Number.isFinite(v)) return '—'; + return Math.round(v).toLocaleString('zh-CN'); +} diff --git a/src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts b/src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts new file mode 100644 index 0000000..a35ce18 --- /dev/null +++ b/src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts @@ -0,0 +1,202 @@ +export type ManualLedgerImage = { + id: string; + name: string; + dataUrl: string; + /** 已确认入库:界面不可删除,仅可补传新图 */ + archived?: boolean; + placeholder?: boolean; + uploadedAt: string; +}; + +export function isArchivedManualImage(img: ManualLedgerImage): boolean { + return Boolean(img.archived || img.placeholder); +} + +export type ManualLedgerEntry = { + id: string; + stationId: string; + stationName: string; + ledgerDate: string; + images: ManualLedgerImage[]; + updatedAt: string; +}; + +export type ManualCalendarCell = { + dateKey: string; + day: number; + inMonth: boolean; + isToday: boolean; + isFuture: boolean; + /** 是否纳入手工台账补传范围(≥本站首笔加氢日 且 ≤今日) */ + needsLedger: boolean; + uploaded: boolean; +}; + +function bridge() { + return typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; +} + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +function dateKeyFromHydrogenTime(hydrogenTime: unknown): string { + const m = String(hydrogenTime || '').match(/^(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : ''; +} + +/** 本站首笔加氢记录的自然日;无记录时返回 null(日历不要求历史补传) */ +export function getFirstHydrogenDateKey(stationIdOrName: string): string | null { + const b = bridge(); + const rows = b?.getRows?.() || []; + const key = String(stationIdOrName || '').trim(); + if (!key) return null; + let earliest: string | null = null; + rows.forEach((row) => { + const sid = String(row.stationId || '').trim(); + const sn = String(row.stationName || '').trim(); + if (sid !== key && sn !== key) return; + const dk = dateKeyFromHydrogenTime(row.hydrogenTime); + if (!dk) return; + if (!earliest || dk < earliest) earliest = dk; + }); + return earliest; +} + +export function todayDateKey(): string { + const b = bridge(); + if (b?.todayDateKey) return b.todayDateKey(); + const d = new Date(); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +export function hasManualLedgerForDate( + stationIdOrName: string, + ledgerDate?: string, +): boolean { + const b = bridge(); + if (!b?.hasManualLedgerForDate) return false; + return Boolean(b.hasManualLedgerForDate(stationIdOrName, ledgerDate || todayDateKey())); +} + +export function getManualLedger( + stationIdOrName: string, + ledgerDate?: string, +): ManualLedgerEntry | null { + const b = bridge(); + if (!b?.getManualLedger) return null; + return b.getManualLedger(stationIdOrName, ledgerDate || todayDateKey()) || null; +} + +export function listManualLedgersByStation(stationIdOrName: string): ManualLedgerEntry[] { + const b = bridge(); + if (!b?.listManualLedgersByStation) return []; + return b.listManualLedgersByStation(stationIdOrName) || []; +} + +export function upsertManualLedger(input: { + stationId: string; + stationName: string; + ledgerDate: string; + images: Array<{ id?: string; name: string; dataUrl: string; placeholder?: boolean }>; +}): ManualLedgerEntry | null { + const b = bridge(); + if (!b?.upsertManualLedger) return null; + return b.upsertManualLedger(input) || null; +} + +function cellNeedsLedger( + dateKey: string, + todayKey: string, + firstHydrogenDateKey: string | null, +): boolean { + if (dateKey > todayKey) return false; + if (!firstHydrogenDateKey) return false; + return dateKey >= firstHydrogenDateKey; +} + +/** 构建某月日历格(含上月末 / 本月 / 下月头,共 6×7) */ +export function buildManualMonthCells( + year: number, + month1to12: number, + uploadedDateSet: Set, + todayKey: string = todayDateKey(), + firstHydrogenDateKey: string | null = null, +): ManualCalendarCell[] { + const first = new Date(year, month1to12 - 1, 1); + const startWeekday = first.getDay(); + const daysInMonth = new Date(year, month1to12, 0).getDate(); + const prevDays = new Date(year, month1to12 - 1, 0).getDate(); + const cells: ManualCalendarCell[] = []; + + for (let i = 0; i < 42; i++) { + let y = year; + let m = month1to12; + let d: number; + let inMonth = true; + if (i < startWeekday) { + d = prevDays - startWeekday + i + 1; + m = month1to12 - 1; + if (m < 1) { + m = 12; + y -= 1; + } + inMonth = false; + } else if (i >= startWeekday + daysInMonth) { + d = i - startWeekday - daysInMonth + 1; + m = month1to12 + 1; + if (m > 12) { + m = 1; + y += 1; + } + inMonth = false; + } else { + d = i - startWeekday + 1; + } + const dateKey = `${y}-${pad2(m)}-${pad2(d)}`; + const needsLedger = cellNeedsLedger(dateKey, todayKey, firstHydrogenDateKey); + cells.push({ + dateKey, + day: d, + inMonth, + isToday: dateKey === todayKey, + isFuture: dateKey > todayKey, + needsLedger, + uploaded: uploadedDateSet.has(dateKey), + }); + } + return cells; +} + +/** 本月内需补传且未上传的天数(从首笔加氢日起算,不含未来) */ +export function countMissingInMonth( + year: number, + month1to12: number, + uploadedDateSet: Set, + todayKey: string = todayDateKey(), + firstHydrogenDateKey: string | null = null, +): number { + const daysInMonth = new Date(year, month1to12, 0).getDate(); + let n = 0; + for (let d = 1; d <= daysInMonth; d++) { + const key = `${year}-${pad2(month1to12)}-${pad2(d)}`; + if (key > todayKey) break; + if (!cellNeedsLedger(key, todayKey, firstHydrogenDateKey)) continue; + if (!uploadedDateSet.has(key)) n += 1; + } + return n; +} + +declare global { + interface Window { + H2VehicleLedgerBridge?: { + todayDateKey?: () => string; + getRows?: () => Array<{ stationId?: string; stationName?: string; hydrogenTime?: string }>; + hasManualLedgerForDate?: (stationIdOrName: string, ledgerDate?: string) => boolean; + getManualLedger?: (stationIdOrName: string, ledgerDate?: string) => ManualLedgerEntry | null; + listManualLedgersByStation?: (stationIdOrName: string) => ManualLedgerEntry[]; + upsertManualLedger?: (input: unknown) => ManualLedgerEntry | null; + subscribe?: (fn: () => void) => () => void; + }; + } +} diff --git a/src/prototypes/oneos-h5-h2-order/utils/orders.ts b/src/prototypes/oneos-h5-h2-order/utils/orders.ts index 3eec2e8..fe3e2e5 100644 --- a/src/prototypes/oneos-h5-h2-order/utils/orders.ts +++ b/src/prototypes/oneos-h5-h2-order/utils/orders.ts @@ -8,11 +8,22 @@ declare global { upsertRow: (row: Record) => Record | null; removeRow: (id: string) => boolean; subscribe: (fn: (rows: Array>) => void) => () => void; + findDuplicateRow?: ( + candidate: { stationName?: string; plateNo?: string; hydrogenTime?: string }, + excludeId?: string, + ) => Record | null; + totalAmountMismatch?: ( + unitPrice: number, + hydrogenKg: number, + totalAmount: number, + toleranceYuan?: number, + ) => { expected: number; actual: number; diff: number } | null; }; } } const RECONCILED = 'reconciled'; +const VERIFIED = 'verified'; function asNumber(value: unknown, fallback = 0): number { const n = Number(value); @@ -23,34 +34,63 @@ function asString(value: unknown): string { return value == null ? '' : String(value); } +/** 已对账必须有对账时间;缺省时按加氢日次日 10:00:00 推导(原型演示) */ +function deriveReconcileTime(hydrogenTime: string): string { + const m = String(hydrogenTime || '').match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!m) return '2026-01-01 10:00:00'; + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1, 10, 0, 0); + const pad = (n: number) => (n < 10 ? `0${n}` : String(n)); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 10:00:00`; +} + export function mapRowToOrder(row: Record): H5OrderRecord { - const reconcileTime = asString(row.reconcileDate || row.reconciledAt); + /* 对账时间仅认 reconcileDate,勿用 reconciledAt(完成核对会写) */ + let reconcileTime = asString(row.reconcileDate); const reconcileStatus = asString(row.reconcileStatus) === RECONCILED || Boolean(reconcileTime) ? 'reconciled' : 'pending'; + if (reconcileStatus === 'reconciled' && !reconcileTime) { + reconcileTime = deriveReconcileTime(asString(row.hydrogenTime)); + } + const verifyStatus = + asString(row.verifyStatus) === VERIFIED || Boolean(row.verifiedAt) ? 'verified' : 'unverified'; + const verifiedAt = verifyStatus === 'verified' ? asString(row.verifiedAt) : ''; const unitPrice = asNumber(row.costUnitPrice, asNumber(row.customerUnitPrice)); const hydrogenKg = asNumber(row.hydrogenKg); const totalAmount = asNumber(row.costTotal, asNumber(row.costAmount, asNumber(row.customerAmount))); + const mileageRaw = row.mileageKm; + let mileageKm: number | null = null; + if (mileageRaw != null && mileageRaw !== '') { + const n = Number(mileageRaw); + if (Number.isFinite(n) && n >= 0) mileageKm = Math.round(n); + } return { id: asString(row.id || row.key), stationId: asString(row.stationId), stationName: asString(row.stationName), hydrogenTime: asString(row.hydrogenTime), plateNo: asString(row.plateNo), + mileageKm, unitPrice, hydrogenKg, totalAmount, + verifyStatus, + verifiedAt, reconcileStatus, reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '', }; } +export function isOrderLocked(order: H5OrderRecord): boolean { + return order.verifyStatus === 'verified' || order.reconcileStatus === 'reconciled'; +} + export function listStations(): StationOption[] { const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; if (!bridge) { return [ - { stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)' }, + { stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站' }, { stationId: 'HZ-H2-002', stationName: '杭州临平加氢站' }, { stationId: 'SH-H2-003', stationName: '上海宝山加氢站' }, { stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站' }, @@ -83,6 +123,7 @@ export function upsertStationOrder(input: { stationName: string; hydrogenTime: string; plateNo: string; + mileageKm?: number | null; unitPrice: number; hydrogenKg: number; totalAmount: number; @@ -90,12 +131,18 @@ export function upsertStationOrder(input: { const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; if (!bridge?.upsertRow) return null; const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`; + const hydrogenTime = + input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime; + const mileageKm = + input.mileageKm != null && Number.isFinite(input.mileageKm) && input.mileageKm >= 0 + ? Math.round(input.mileageKm) + : null; const saved = bridge.upsertRow({ id: input.id, key: input.id, stationId: input.stationId, stationName: input.stationName, - hydrogenTime: input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime, + hydrogenTime, plateNo: plate, customerName: `${input.stationName}·站端上报`, hydrogenKg: input.hydrogenKg, @@ -104,13 +151,48 @@ export function upsertStationOrder(input: { customerUnitPrice: input.unitPrice, customerAmount: input.totalAmount, settlementStatus: 'customer', - mileageKm: 0, + mileageKm, creatorName: '站端账号', reconcileStatus: 'pending', + verifyStatus: 'unverified', }); return saved ? mapRowToOrder(saved) : null; } +export function findDuplicateStationOrder(input: { + id?: string; + stationName: string; + plateNo: string; + hydrogenTime: string; +}): boolean { + const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; + if (!bridge?.findDuplicateRow) return false; + const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`; + const hydrogenTime = + input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime; + return Boolean( + bridge.findDuplicateRow( + { stationName: input.stationName, plateNo: plate, hydrogenTime }, + input.id, + ), + ); +} + +export function getTotalAmountMismatch( + unitPrice: number, + hydrogenKg: number, + totalAmount: number, +): { expected: number; actual: number; diff: number } | null { + const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; + if (bridge?.totalAmountMismatch) { + return bridge.totalAmountMismatch(unitPrice, hydrogenKg, totalAmount, 0.05); + } + const expected = Math.round(unitPrice * hydrogenKg * 100) / 100; + const actual = Math.round(totalAmount * 100) / 100; + const diff = Math.round(Math.abs(actual - expected) * 100) / 100; + return diff > 0.05 ? { expected, actual, diff } : null; +} + export function removeStationOrder(id: string): boolean { const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; if (!bridge?.removeRow) return false; diff --git a/src/prototypes/oneos-prototype-nav/prototype-registry.json b/src/prototypes/oneos-prototype-nav/prototype-registry.json index ad3ed0a..421a55b 100644 --- a/src/prototypes/oneos-prototype-nav/prototype-registry.json +++ b/src/prototypes/oneos-prototype-nav/prototype-registry.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-07-16T01:04:29.814Z", + "updatedAt": "2026-07-17T05:30:12.068Z", "prototypes": { "oneos-web-workbench": { "title": "工作台", @@ -60,10 +60,10 @@ }, "vehicle-management": { "title": "车辆资产", - "version": "v1.28", - "revision": 28, - "lastUpdated": "2026-07-14T13:06:43.246Z", - "contentHash": "fecd39c2b405ede54b9d73fa1b26fc02434a5aaef6de4d217f8971a27dd30c7d", + "version": "v1.29", + "revision": 29, + "lastUpdated": "2026-07-17T03:51:01.923Z", + "contentHash": "025a6adcdbd2f263d63e9df026fd6a299fe450a33fdf25b31687c62a2eab8647", "trackedFiles": [ ".spec/prototype-comments.json", ".spec/prototype-review.md", @@ -149,6 +149,26 @@ "utils/violationRecords.ts" ], "changelog": [ + { + "version": "v1.29", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-detail.md", + ".spec/requirements-prd-list.md", + ".spec/ui-review.md", + "components/DateRangeCalendarPanel.tsx", + "components/DateRangeFilterField.tsx", + "components/DetailAccidentRecordsTab.tsx", + "components/DetailAnnualReviewRecordsTab.tsx", + "components/DetailBasicInfoTab.tsx", + "components/DetailExpireField.tsx", + "components/DetailFaultRecordsTab.tsx" + ] + }, { "version": "v1.28", "date": "2026-07-14", @@ -1025,10 +1045,10 @@ }, "lease-contract-management": { "title": "租赁合同", - "version": "v1.12", - "revision": 12, - "lastUpdated": "2026-07-14T13:06:43.763Z", - "contentHash": "280580fc37db71f53969e214295aa471bbfdb323ecea14e2101a5482223d2ec5", + "version": "v1.13", + "revision": 13, + "lastUpdated": "2026-07-17T03:51:03.325Z", + "contentHash": "aff8ad3ebcdd07fa07ba8f3c20aa4a445236d539eaaae8afcc5bac6b839a3984", "trackedFiles": [ ".spec/prototype-comments.json", ".spec/requirements-annotation-context.md", @@ -1062,6 +1082,26 @@ "styles/lease-contract.css" ], "changelog": [ + { + "version": "v1.13", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/requirements-annotation-context.md", + ".spec/requirements-flow-operations.md", + ".spec/requirements-prd-create.md", + ".spec/requirements-prd-list.md", + "contract-word-preview.js", + "index.tsx", + "lease-business-org-data.js", + "lease-contract-approval-rules.js", + "lease-contract-edit-bridge.js", + "lease-contract-flow-bridge.js", + "lease-contract-form-validation.js" + ] + }, { "version": "v1.12", "date": "2026-07-14", @@ -1337,10 +1377,10 @@ }, "insurance-procurement": { "title": "保险采购", - "version": "v1.14", - "revision": 14, - "lastUpdated": "2026-07-14T13:07:34.139Z", - "contentHash": "8977a80d6545f6e4f3a4df4e7d2b1d1548b65e61368275056261e58c7e5b6c18", + "version": "v1.15", + "revision": 15, + "lastUpdated": "2026-07-17T03:53:40.990Z", + "contentHash": "009327e1188652e0d99a6e4044acbcbba333c203978af4654fc71327bf62e6e4", "trackedFiles": [ ".spec/prototype-comments.json", "index.tsx", @@ -1348,6 +1388,18 @@ "styles/index.css" ], "changelog": [ + { + "version": "v1.15", + "date": "2026-07-17", + "time": "11:53", + "summary": "更新页面样式", + "files": [ + ".spec/prototype-comments.json", + "index.tsx", + "InsuranceProcurement.jsx", + "styles/index.css" + ] + }, { "version": "v1.14", "date": "2026-07-14", @@ -1815,11 +1867,12 @@ }, "oneos-web-h2-station-site": { "title": "站点信息", - "version": "v1.4", - "revision": 4, - "lastUpdated": "2026-07-12T14:45:00.000Z", - "contentHash": "f77326526aa62f234abdaa573a67c752f71e0874b1930daf3fb27baf0478258d", + "version": "v1.21", + "revision": 21, + "lastUpdated": "2026-07-17T05:30:12.067Z", + "contentHash": "cc22425e22c30fa28e7d3c7d1473b7d76f8a85167c60837b1fccde7a36998e64", "trackedFiles": [ + ".spec/period-end-balance.md", ".spec/prototype-comments.json", ".spec/prototype-review.md", ".spec/requirements-prd-kpi-balance-alert.md", @@ -1827,9 +1880,305 @@ ".spec/requirements-prd-statement.md", ".spec/requirements-prd.md", ".spec/requirements.md", - "index.tsx" + "index.tsx", + "pages/03-站点信息.jsx" ], "changelog": [ + { + "version": "v1.21", + "date": "2026-07-17", + "time": "13:30", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.20", + "date": "2026-07-17", + "time": "13:30", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.19", + "date": "2026-07-17", + "time": "13:21", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.18", + "date": "2026-07-17", + "time": "11:52", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.17", + "date": "2026-07-17", + "time": "11:52", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.16", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.15", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.14", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.13", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.12", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.11", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.10", + "date": "2026-07-17", + "time": "11:50", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.9", + "date": "2026-07-17", + "time": "11:50", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.8", + "date": "2026-07-17", + "time": "11:50", + "summary": "更新需求说明与标注", + "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.7", + "date": "2026-07-16", + "time": "19:50", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.6", + "date": "2026-07-16", + "time": "10:19", + "summary": "站点信息页迁入本包 pages/", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", + "index.tsx", + "pages/03-站点信息.jsx" + ] + }, + { + "version": "v1.5", + "date": "2026-07-16", + "time": "10:18", + "summary": "更新页面逻辑与交互", + "files": [ + "pages/03-站点信息.jsx" + ] + }, { "version": "v1.4", "date": "2026-07-12", @@ -2443,12 +2792,13 @@ }, "lease-business-detail": { "title": "租赁业务明细", - "version": "v1.41", - "revision": 41, - "lastUpdated": "2026-07-13T08:46:44.531Z", - "contentHash": "4aadddc27f9978ee936c161e2fb689413026892b7d14d9ac5a6d18ba1bcd5c99", + "version": "v1.42", + "revision": 42, + "lastUpdated": "2026-07-17T05:24:50.292Z", + "contentHash": "5dea68ab4a234edc3f7e76ac58eb2e2e8baaafe0150ff622a35ef1bae14e49ce", "trackedFiles": [ ".spec/field-checks.md", + ".spec/import-rules.md", ".spec/prototype-comments.json", ".spec/prototype-review.md", ".spec/requirements-prd.md", @@ -2487,6 +2837,26 @@ "utils/validate-detail.ts" ], "changelog": [ + { + "version": "v1.42", + "date": "2026-07-17", + "time": "13:24", + "summary": "更新需求说明与标注", + "files": [ + ".spec/field-checks.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd.md", + "columnHeaderTips.ts", + "components/DetailChangeLogModal.tsx", + "components/DetailDeleteConfirmModal.tsx", + "components/DetailEditModal.tsx", + "components/DetailImportModal.tsx", + "components/DetailKpiRow.tsx", + "components/DetailMainTabs.tsx", + "components/DetailTable.tsx" + ] + }, { "version": "v1.41", "date": "2026-07-13", @@ -3062,26 +3432,6 @@ "components/FieldCheckIcon.tsx", "components/FilterPanel.tsx" ] - }, - { - "version": "v1.12", - "date": "2026-07-13", - "time": "09:39", - "summary": "更新需求说明与标注", - "files": [ - ".spec/prototype-comments.json", - ".spec/prototype-review.md", - ".spec/requirements-prd.md", - "columnHeaderTips.ts", - "components/DetailChangeLogModal.tsx", - "components/DetailDeleteConfirmModal.tsx", - "components/DetailEditModal.tsx", - "components/DetailImportModal.tsx", - "components/DetailKpiRow.tsx", - "components/DetailTable.tsx", - "components/FieldCheckIcon.tsx", - "components/FilterPanel.tsx" - ] } ] }, @@ -3327,18 +3677,61 @@ }, "vehicle-h2-fee-ledger": { "title": "车辆氢费明细", - "version": "v1.5", - "revision": 5, - "lastUpdated": "2026-07-12T14:45:00.000Z", - "contentHash": "ee0d8d6dfc26c9116c41f5928dcd8c7c6b0cde7909859862024ed7a99f6e1985", + "version": "v1.8", + "revision": 8, + "lastUpdated": "2026-07-16T11:53:44.219Z", + "contentHash": "8d4a6e6bb2b8e4a84c2dfb0a01ea0ff7db6ab80deea6dfcda367251b0ab72dde", "trackedFiles": [ ".spec/PRD.md", ".spec/prototype-comments.json", + ".spec/verify-reconcile.md", "globals.ts", "H2LedgerPage.jsx", "index.tsx" ], "changelog": [ + { + "version": "v1.8", + "date": "2026-07-16", + "time": "19:53", + "summary": "更新需求说明与标注", + "files": [ + ".spec/PRD.md", + ".spec/prototype-comments.json", + ".spec/verify-reconcile.md", + "globals.ts", + "H2LedgerPage.jsx", + "index.tsx" + ] + }, + { + "version": "v1.7", + "date": "2026-07-16", + "time": "18:57", + "summary": "更新需求说明与标注", + "files": [ + ".spec/PRD.md", + ".spec/prototype-comments.json", + ".spec/verify-reconcile.md", + "globals.ts", + "H2LedgerPage.jsx", + "index.tsx" + ] + }, + { + "version": "v1.6", + "date": "2026-07-16", + "time": "18:57", + "summary": "更新需求说明与标注", + "files": [ + ".spec/PRD.md", + ".spec/prototype-comments.json", + ".spec/verify-reconcile.md", + "globals.ts", + "H2LedgerPage.jsx", + "index.tsx" + ] + }, { "version": "v1.5", "date": "2026-07-12", @@ -5217,264 +5610,519 @@ }, "oneos-web-h2-station": { "title": "加氢记录", - "version": "v1.19", - "revision": 19, - "lastUpdated": "2026-07-14T21:21:55.533Z", - "contentHash": "ab2645f725f027fec6d00f4c27806878c0aab35c1b6c082f2953e4cec5768922", + "version": "v1.106", + "revision": 106, + "lastUpdated": "2026-07-17T05:02:12.013Z", + "contentHash": "13609131a9ba3bf1ccb593460e1ff683a8848c242c8d82ccdb7151e54f09bf20", "trackedFiles": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", ".spec/record-data-model.md", ".spec/requirements-prd.md", + ".spec/ui-review.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ], "changelog": [ { - "version": "v1.19", - "date": "2026-07-15", - "time": "05:21", - "summary": "Web加氢记录字段与H5订单对齐:同Store/对账口径;保留导出与预约加氢", + "version": "v1.106", + "date": "2026-07-17", + "time": "13:02", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.105", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.104", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.103", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.102", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.101", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.100", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.99", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.98", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.97", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.96", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.95", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.94", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.93", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.92", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.91", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.90", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.89", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "version": "v1.88", + "date": "2026-07-17", + "time": "12:52", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", ".spec/record-data-model.md", ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.18", - "date": "2026-07-14", - "time": "23:49", + "version": "v1.87", + "date": "2026-07-17", + "time": "12:52", "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", ".spec/record-data-model.md", ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.17", - "date": "2026-07-14", - "time": "23:49", + "version": "v1.86", + "date": "2026-07-17", + "time": "12:51", "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", ".spec/record-data-model.md", - ".spec/requirements-prd.md" - ] - }, - { - "version": "v1.16", - "date": "2026-07-14", - "time": "23:47", - "summary": "更新页面样式", - "files": [ + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.15", - "date": "2026-07-14", - "time": "23:47", - "summary": "更新页面样式", + "version": "v1.85", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.14", - "date": "2026-07-14", - "time": "23:46", - "summary": "更新页面样式", + "version": "v1.84", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.13", - "date": "2026-07-14", - "time": "23:45", - "summary": "更新页面样式", + "version": "v1.83", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.12", - "date": "2026-07-14", - "time": "23:44", - "summary": "更新页面样式", + "version": "v1.82", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.11", - "date": "2026-07-14", - "time": "23:44", - "summary": "更新页面样式", + "version": "v1.81", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.10", - "date": "2026-07-14", - "time": "23:15", - "summary": "更新页面样式", + "version": "v1.80", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.9", - "date": "2026-07-14", - "time": "23:04", - "summary": "更新页面样式", + "version": "v1.79", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.8", - "date": "2026-07-14", - "time": "23:04", - "summary": "更新页面样式", + "version": "v1.78", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { - "version": "v1.7", - "date": "2026-07-14", - "time": "22:38", - "summary": "更新页面样式", + "version": "v1.77", + "date": "2026-07-17", + "time": "10:32", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] - }, - { - "version": "v1.6", - "date": "2026-07-13", - "time": "11:09", - "summary": "更新页面样式", - "files": [ - "index.tsx", - "pages/01-加氢订单.jsx", - "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "styles/h2-station-vm-filter.css" - ] - }, - { - "version": "v1.5", - "date": "2026-07-12", - "time": "22:45", - "summary": "加氢站合包:站点信息编辑提升为主操作", - "details": [ - "加氢订单/记录/站点信息 3 页 OperationActions;站点编辑不再藏在「更多」" - ], - "commit": "131b963", - "files": [ - "index.tsx" - ] - }, - { - "version": "v1.4", - "date": "2026-07-08", - "time": "11:44", - "summary": "样式与布局:h2-station-vm-filter.css", - "details": [], - "commit": "6d14c58", - "files": [ - "styles/h2-station-vm-filter.css" - ] - }, - { - "version": "v1.3", - "date": "2026-07-02", - "time": "03:35", - "summary": "页面逻辑与交互:index.tsx", - "details": [ - "样式与布局:h2-station-vm-filter.css" - ], - "commit": "f5e3126", - "files": [ - "index.tsx", - "styles/h2-station-vm-filter.css" - ] - }, - { - "version": "v1.2", - "date": "2026-06-30", - "time": "16:54", - "summary": "页面逻辑与交互:index.tsx", - "details": [], - "commit": "0982837", - "files": [ - "index.tsx" - ] - }, - { - "version": "v1.1", - "date": "2026-06-30", - "time": "16:36", - "summary": "Import desktop web originals into dedicated prototypes with a shared legacy shell, sync script, and a new「ONE-OS Web 端」sidebar section for review alongside existing refactored pages.", - "details": [ - "页面逻辑与交互:index.tsx" - ], - "commit": "d71ebc5", - "files": [ - "index.tsx" - ] } ] }, @@ -5664,11 +6312,11 @@ ] }, "oneos-web-business": { - "title": "oneos web business", - "version": "v1.2", - "revision": 2, - "lastUpdated": "2026-07-12T14:45:00.000Z", - "contentHash": "81527424b7c28091f51aeaa1c530e0517eca0025fa5a79711a907e2f52edb127", + "title": "业务管理", + "version": "v1.3", + "revision": 3, + "lastUpdated": "2026-07-17T03:51:02.350Z", + "contentHash": "e92802237c9714f6c476d9e69a912494c2d6a574e29e129e36e8969d65ac82aa", "trackedFiles": [ "index.tsx", "pages/01-保险采购.jsx", @@ -5691,6 +6339,26 @@ "pages/18-ETC管理.jsx" ], "changelog": [ + { + "version": "v1.3", + "date": "2026-07-17", + "time": "11:51", + "summary": "更新页面逻辑与交互", + "files": [ + "index.tsx", + "pages/01-保险采购.jsx", + "pages/02-编辑交车任务.jsx", + "pages/03-查看交车任务.jsx", + "pages/04-车辆成本维护.jsx", + "pages/05-供应商管理-编辑.jsx", + "pages/06-供应商管理-查看.jsx", + "pages/07-供应商管理-新增.jsx", + "pages/08-供应商管理.jsx", + "pages/09-交车任务.jsx", + "pages/10-客户管理-编辑.jsx", + "pages/11-客户管理-查看.jsx" + ] + }, { "version": "v1.2", "date": "2026-07-12", @@ -5860,14 +6528,23 @@ }, "vm-shared": { "title": "vm-shared", - "version": "v1.4", - "revision": 4, - "lastUpdated": "2026-07-12T14:44:00.000Z", - "contentHash": "4a8d78c8ffaa1ffbf48164c0a77651919681b3e89b7b5ed700a2acf9c7f00e91", + "version": "v1.5", + "revision": 5, + "lastUpdated": "2026-07-16T02:18:41.766Z", + "contentHash": "e818f6907d33452be1fe92f1f6162751af374856aa0fe464a06353c6e831752b", "trackedFiles": [ "DESIGN.md" ], "changelog": [ + { + "version": "v1.5", + "date": "2026-07-16", + "time": "10:18", + "summary": "更新 DESIGN.md", + "files": [ + "DESIGN.md" + ] + }, { "version": "v1.4", "date": "2026-07-12", @@ -6448,528 +7125,627 @@ }, "oneos-h5-h2-order": { "title": "加氢订单", - "version": "v1.32", - "revision": 32, - "lastUpdated": "2026-07-14T21:20:58.778Z", - "contentHash": "d9ca35fccd0128c19f27d78542b5a80cf74ea0e7c2a91149676c67ef39c5493e", + "version": "v1.106", + "revision": 106, + "lastUpdated": "2026-07-17T03:57:09.269Z", + "contentHash": "c960fdbeeedc96c0fbd32f30a0346033552615522a12e4a9be07f5d2843bbc25", "trackedFiles": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", "components/Icons.tsx", + "components/ManualLedgerPanel.tsx", "components/OrderCard.tsx", "components/OrderDetail.tsx", "components/PhoneShell.tsx", + "components/PlateKeyboardSheet.tsx", "index.tsx", "styles/index.css", "types.ts", + "utils/fleet-plates.ts", "utils/format.ts", + "utils/manual-ledger.ts", "utils/ocr-mock.ts", "utils/orders.ts" ], "changelog": [ { - "version": "v1.32", - "date": "2026-07-15", - "time": "05:20", - "summary": "H5加氢订单与Web加氢记录同源数据;站端上报与对账联动", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - ".spec/reconcile-linkage.md", - ".spec/requirements-prd.md", - "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "version": "v1.31", - "date": "2026-07-14", - "time": "23:28", + "version": "v1.106", + "date": "2026-07-17", + "time": "11:57", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.30", - "date": "2026-07-14", - "time": "23:28", + "version": "v1.105", + "date": "2026-07-17", + "time": "11:57", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.29", - "date": "2026-07-14", - "time": "23:28", + "version": "v1.104", + "date": "2026-07-17", + "time": "11:56", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.28", - "date": "2026-07-14", - "time": "23:27", + "version": "v1.103", + "date": "2026-07-17", + "time": "11:56", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.27", - "date": "2026-07-14", - "time": "23:25", + "version": "v1.102", + "date": "2026-07-17", + "time": "11:56", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.26", - "date": "2026-07-14", - "time": "23:25", + "version": "v1.101", + "date": "2026-07-17", + "time": "11:56", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.25", - "date": "2026-07-14", - "time": "23:24", + "version": "v1.100", + "date": "2026-07-17", + "time": "11:56", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.24", - "date": "2026-07-14", - "time": "23:24", + "version": "v1.99", + "date": "2026-07-17", + "time": "11:56", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.23", - "date": "2026-07-14", - "time": "23:23", + "version": "v1.98", + "date": "2026-07-17", + "time": "10:42", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/Icons.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts" + "components/DateTimePickerSheet.tsx" ] }, { - "version": "v1.22", - "date": "2026-07-14", - "time": "23:23", - "summary": "更新页面逻辑与交互", + "version": "v1.97", + "date": "2026-07-17", + "time": "10:42", + "summary": "更新需求说明与标注", "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.96", + "date": "2026-07-17", + "time": "10:37", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.95", + "date": "2026-07-17", + "time": "10:37", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.94", + "date": "2026-07-17", + "time": "10:32", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.93", + "date": "2026-07-17", + "time": "10:32", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.92", + "date": "2026-07-17", + "time": "10:31", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.91", + "date": "2026-07-17", + "time": "10:30", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.90", + "date": "2026-07-17", + "time": "10:28", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.89", + "date": "2026-07-17", + "time": "10:28", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "version": "v1.88", + "date": "2026-07-17", + "time": "10:22", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", "components/Icons.tsx" ] }, { - "version": "v1.21", - "date": "2026-07-14", - "time": "23:22", + "version": "v1.87", + "date": "2026-07-17", + "time": "10:22", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx" ] }, { - "version": "v1.20", - "date": "2026-07-14", - "time": "23:22", + "version": "v1.86", + "date": "2026-07-17", + "time": "10:22", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx" ] }, { - "version": "v1.19", - "date": "2026-07-14", - "time": "23:22", + "version": "v1.85", + "date": "2026-07-17", + "time": "10:22", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx" ] }, { - "version": "v1.18", - "date": "2026-07-14", - "time": "23:21", + "version": "v1.84", + "date": "2026-07-17", + "time": "10:21", "summary": "更新需求说明与标注", "files": [ ".spec/2026-07-14-implementation-plan.md", ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", ".spec/reconcile-linkage.md", ".spec/requirements-prd.md", "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx" ] }, { - "version": "v1.17", - "date": "2026-07-14", - "time": "23:18", - "summary": "更新需求说明与标注", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - ".spec/reconcile-linkage.md", - ".spec/requirements-prd.md", - "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" - ] - }, - { - "version": "v1.16", - "date": "2026-07-14", - "time": "23:17", - "summary": "更新需求说明与标注", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - ".spec/reconcile-linkage.md", - ".spec/requirements-prd.md", - "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" - ] - }, - { - "version": "v1.15", - "date": "2026-07-14", - "time": "23:17", - "summary": "更新需求说明与标注", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - ".spec/reconcile-linkage.md", - ".spec/requirements-prd.md", - "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" - ] - }, - { - "version": "v1.14", - "date": "2026-07-14", - "time": "23:09", - "summary": "更新需求说明与标注", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - ".spec/reconcile-linkage.md", - ".spec/requirements-prd.md", - "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" - ] - }, - { - "version": "v1.13", - "date": "2026-07-14", - "time": "23:09", - "summary": "更新需求说明与标注", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - ".spec/reconcile-linkage.md", - ".spec/requirements-prd.md", - "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts" - ] - }, - { - "version": "v1.12", - "date": "2026-07-14", - "time": "23:08", - "summary": "更新需求说明与标注", - "files": [ - ".spec/reconcile-linkage.md", - ".spec/requirements-prd.md" - ] - }, - { - "version": "v1.11", - "date": "2026-07-14", - "time": "23:07", - "summary": "更新需求说明与标注", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts", - "utils/ocr-mock.ts", - "utils/orders.ts" - ] - }, - { - "version": "v1.10", - "date": "2026-07-14", - "time": "23:07", - "summary": "更新需求说明与标注", - "files": [ - ".spec/2026-07-14-implementation-plan.md", - ".spec/2026-07-14-requirements-design.md", - "components/CreateWizard.tsx", - "components/OrderCard.tsx", - "components/OrderDetail.tsx", - "components/PhoneShell.tsx", - "index.tsx", - "styles/index.css", - "types.ts", - "utils/format.ts", - "utils/ocr-mock.ts", - "utils/orders.ts" - ] - }, - { - "version": "v1.9", - "date": "2026-07-14", - "time": "23:07", + "version": "v1.83", + "date": "2026-07-17", + "time": "10:21", "summary": "更新页面逻辑与交互", "files": [ - "index.tsx" + "components/ManualLedgerPanel.tsx", + "utils/manual-ledger.ts" ] }, { - "version": "v1.8", - "date": "2026-07-14", - "time": "23:07", - "summary": "更新页面逻辑与交互", + "version": "v1.82", + "date": "2026-07-17", + "time": "10:10", + "summary": "更新需求说明与标注", "files": [ - "components/OrderDetail.tsx" + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx" ] }, { - "version": "v1.7", - "date": "2026-07-14", - "time": "23:06", - "summary": "更新页面逻辑与交互", + "version": "v1.81", + "date": "2026-07-17", + "time": "10:10", + "summary": "更新需求说明与标注", "files": [ - "components/CreateWizard.tsx" + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx" ] }, { - "version": "v1.6", - "date": "2026-07-14", - "time": "23:06", - "summary": "更新页面逻辑与交互", + "version": "v1.80", + "date": "2026-07-17", + "time": "10:10", + "summary": "更新需求说明与标注", "files": [ - "components/OrderCard.tsx" + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx" ] }, { - "version": "v1.5", - "date": "2026-07-14", - "time": "23:06", - "summary": "更新页面逻辑与交互", + "version": "v1.79", + "date": "2026-07-17", + "time": "10:09", + "summary": "更新需求说明与标注", "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx", + "components/OrderCard.tsx", + "components/OrderDetail.tsx", "components/PhoneShell.tsx" ] }, { - "version": "v1.4", - "date": "2026-07-14", - "time": "23:06", - "summary": "更新页面样式", + "version": "v1.78", + "date": "2026-07-17", + "time": "10:04", + "summary": "更新需求说明与标注", "files": [ - "styles/index.css" + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx", + "components/OrderCard.tsx", + "components/OrderDetail.tsx", + "components/PhoneShell.tsx" ] }, { - "version": "v1.3", - "date": "2026-07-14", - "time": "23:06", - "summary": "更新 ocr-mock.ts", + "version": "v1.77", + "date": "2026-07-17", + "time": "10:03", + "summary": "更新需求说明与标注", "files": [ - "utils/ocr-mock.ts" + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx", + "components/Icons.tsx", + "components/OrderCard.tsx", + "components/OrderDetail.tsx", + "components/PhoneShell.tsx" ] } ] @@ -7279,592 +8055,780 @@ }, "recentUpdates": [ { - "prototypeId": "oneos-web-approval-cc", - "title": "我的抄送", - "version": "v1.1", - "date": "2026-07-16", - "time": "09:04", - "summary": "同步我的抄送注册", - "files": [ - "index.tsx" - ] - }, - { - "prototypeId": "oneos-web-approval-initiated", - "title": "我发起的", - "version": "v1.1", - "date": "2026-07-16", - "time": "09:04", - "summary": "同步我发起的注册", - "files": [ - "index.tsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.17", - "date": "2026-07-16", - "time": "09:04", - "summary": "同步审批中心注册", - "files": [ - "index.tsx" - ] - }, - { - "prototypeId": "oneos-prototype-nav", - "title": "oneos-prototype-nav", - "version": "v1.6", - "date": "2026-07-16", - "time": "09:04", - "summary": "合并远程审批中心:保留加氢订单/记录与数量统计,同步导航注册表", - "files": [ - ".spec/prototype-comments.json", - "index.tsx", - "nav-data.ts", - "style.css", - "xll-nav-menu.json", - "xll-nav-source.json" - ] - }, - { - "prototypeId": "oneos-web-workbench", - "title": "工作台", - "version": "v1.6", - "date": "2026-07-15", - "time": "08:51", - "summary": "更新页面逻辑与交互", - "files": [ - "index.tsx", - "pages/01-工作台.jsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.16", - "date": "2026-07-14", - "time": "14:17", - "summary": "更新页面逻辑与交互", - "files": [ - ".spec/prototype-comments.json", - "index.tsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.15", - "date": "2026-07-14", - "time": "14:09", - "summary": "更新页面逻辑与交互", - "files": [ - "ApprovalApp.tsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.14", - "date": "2026-07-14", - "time": "14:03", - "summary": "更新页面样式", - "files": [ - ".spec/prototype-comments.json", - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.13", - "date": "2026-07-14", - "time": "14:03", - "summary": "更新页面样式", - "files": [ - ".spec/prototype-comments.json", - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.12", - "date": "2026-07-14", - "time": "13:59", - "summary": "更新页面样式", - "files": [ - ".spec/prototype-comments.json", - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.11", - "date": "2026-07-14", - "time": "13:55", - "summary": "更新页面样式", - "files": [ - ".spec/prototype-comments.json", - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.10", - "date": "2026-07-14", - "time": "13:54", - "summary": "更新页面样式", - "files": [ - ".spec/prototype-comments.json", - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.9", - "date": "2026-07-14", - "time": "13:19", - "summary": "更新页面样式", - "files": [ - ".spec/prototype-comments.json", - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "审批中心", - "version": "v1.8", - "date": "2026-07-14", - "time": "10:58", - "summary": "更新页面样式", - "files": [ - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-workbench", - "title": "工作台", - "version": "v1.5", - "date": "2026-07-14", - "time": "10:58", - "summary": "更新页面逻辑与交互", - "files": [ - "index.tsx", - "pages/01-工作台.jsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.7", - "date": "2026-07-14", - "time": "10:56", - "summary": "更新页面样式", - "files": [ - "components/ApprovalCard.tsx", - "components/ApprovalCardList.tsx", - "components/ApprovalDetailPlaceholder.tsx", - "config/approvalTypeConfig.ts", - "data/mockApprovalCases.ts", - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css", - "types.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.6", - "date": "2026-07-14", - "time": "10:56", - "summary": "更新页面样式", - "files": [ - "index.tsx", - "pages/ApprovalCenterPage.tsx", - "styles/index.css" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.5", - "date": "2026-07-14", - "time": "10:56", - "summary": "更新页面逻辑与交互", - "files": [ - "components/ApprovalDetailPlaceholder.tsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.4", - "date": "2026-07-14", - "time": "10:56", - "summary": "更新页面逻辑与交互", - "files": [ - "components/ApprovalCardList.tsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.3", - "date": "2026-07-14", - "time": "10:56", - "summary": "更新页面逻辑与交互", - "files": [ - "components/ApprovalCard.tsx" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.2", - "date": "2026-07-14", - "time": "10:55", - "summary": "更新 mockApprovalCases.ts", - "files": [ - "data/mockApprovalCases.ts" - ] - }, - { - "prototypeId": "oneos-web-approval", - "title": "oneos-web-approval", - "version": "v1.1", - "date": "2026-07-14", - "time": "10:55", - "summary": "更新 approvalTypeConfig.ts", - "files": [ - "config/approvalTypeConfig.ts" - ] - }, - { - "prototypeId": "oneos-web-h2-station-analysis", - "title": "oneos-web-h2-station-analysis", - "version": "v1.6", - "date": "2026-07-13", - "time": "15:20", - "summary": "更新页面逻辑与交互", - "files": [ - "index.tsx", - "pages/01-加氢站分析.jsx", - "shared/h2-analysis-charts.jsx", - "shared/h2-analysis-mock.js" - ] - }, - { - "prototypeId": "oneos-web-h2-station-analysis", - "title": "oneos-web-h2-station-analysis", - "version": "v1.5", - "date": "2026-07-13", - "time": "15:20", - "summary": "更新页面逻辑与交互", - "files": [ - "index.tsx", - "pages/01-加氢站分析.jsx", - "shared/h2-analysis-charts.jsx", - "shared/h2-analysis-mock.js" - ] - }, - { - "prototypeId": "oneos-web-h2-station-analysis", - "title": "oneos-web-h2-station-analysis", - "version": "v1.4", - "date": "2026-07-13", - "time": "15:19", - "summary": "更新页面逻辑与交互", - "files": [ - "index.tsx", - "pages/01-加氢站分析.jsx", - "shared/h2-analysis-charts.jsx", - "shared/h2-analysis-mock.js" - ] - }, - { - "prototypeId": "oneos-web-h2-station-analysis", - "title": "oneos-web-h2-station-analysis", - "version": "v1.3", - "date": "2026-07-13", - "time": "15:18", - "summary": "更新页面逻辑与交互", - "files": [ - "index.tsx" - ] - }, - { - "prototypeId": "oneos-web-h2-station-analysis", - "title": "oneos-web-h2-station-analysis", - "version": "v1.2", - "date": "2026-07-13", - "time": "15:18", - "summary": "更新页面逻辑与交互", - "files": [ - "pages/01-加氢站分析.jsx" - ] - }, - { - "prototypeId": "oneos-web-h2-station-analysis", - "title": "oneos-web-h2-station-analysis", - "version": "v1.1", - "date": "2026-07-13", - "time": "15:18", - "summary": "更新页面逻辑与交互", - "files": [ - "shared/h2-analysis-charts.jsx" - ] - }, - { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.46", - "date": "2026-07-13", - "time": "14:55", + "prototypeId": "oneos-web-h2-station-site", + "title": "站点信息", + "version": "v1.21", + "date": "2026-07-17", + "time": "13:30", "summary": "更新需求说明与标注", "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", "index.tsx", - "pages/01-加氢订单.jsx", - "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "pages/站点周报统计-需求内容.js", - "shared/h2-station-view-shared.jsx", - "styles/h2-station-vm-filter.css" + "pages/03-站点信息.jsx" ] }, { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.45", - "date": "2026-07-13", - "time": "14:55", + "prototypeId": "oneos-web-h2-station-site", + "title": "站点信息", + "version": "v1.20", + "date": "2026-07-17", + "time": "13:30", "summary": "更新需求说明与标注", "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", "index.tsx", - "pages/01-加氢订单.jsx", - "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "pages/站点周报统计-需求内容.js", - "shared/h2-station-view-shared.jsx", - "styles/h2-station-vm-filter.css" + "pages/03-站点信息.jsx" ] }, { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.44", - "date": "2026-07-13", - "time": "14:55", - "summary": "更新页面逻辑与交互", - "files": [ - "shared/h2-station-view-shared.jsx" - ] - }, - { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.43", - "date": "2026-07-13", - "time": "14:30", - "summary": "更新需求说明与标注", - "files": [ - "index.tsx", - "pages/01-加氢订单.jsx", - "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "pages/站点周报统计-需求内容.js", - "styles/h2-station-vm-filter.css" - ] - }, - { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", + "prototypeId": "lease-business-detail", + "title": "租赁业务明细", "version": "v1.42", - "date": "2026-07-13", - "time": "14:30", + "date": "2026-07-17", + "time": "13:24", "summary": "更新需求说明与标注", "files": [ - "index.tsx", - "pages/01-加氢订单.jsx", - "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "pages/站点周报统计-需求内容.js", - "styles/h2-station-vm-filter.css" + ".spec/field-checks.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd.md", + "columnHeaderTips.ts", + "components/DetailChangeLogModal.tsx", + "components/DetailDeleteConfirmModal.tsx", + "components/DetailEditModal.tsx", + "components/DetailImportModal.tsx", + "components/DetailKpiRow.tsx", + "components/DetailMainTabs.tsx", + "components/DetailTable.tsx" ] }, { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.41", - "date": "2026-07-13", - "time": "14:30", + "prototypeId": "oneos-web-h2-station-site", + "title": "站点信息", + "version": "v1.19", + "date": "2026-07-17", + "time": "13:21", "summary": "更新需求说明与标注", "files": [ + ".spec/period-end-balance.md", + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/requirements-prd-kpi-balance-alert.md", + ".spec/requirements-prd-list.md", + ".spec/requirements-prd-statement.md", + ".spec/requirements-prd.md", + ".spec/requirements.md", "index.tsx", - "pages/01-加氢订单.jsx", - "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "pages/站点周报统计-需求内容.js", - "styles/h2-station-vm-filter.css" + "pages/03-站点信息.jsx" ] }, { "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.40", - "date": "2026-07-13", - "time": "14:30", + "title": "加氢记录", + "version": "v1.106", + "date": "2026-07-17", + "time": "13:02", "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "pages/站点周报统计-需求内容.js", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.39", - "date": "2026-07-13", - "time": "14:30", + "title": "加氢记录", + "version": "v1.105", + "date": "2026-07-17", + "time": "13:01", "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", - "pages/站点周报统计-需求内容.js", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.38", - "date": "2026-07-13", - "time": "14:29", + "title": "加氢记录", + "version": "v1.104", + "date": "2026-07-17", + "time": "13:01", "summary": "更新需求说明与标注", "files": [ - "pages/站点周报统计-需求内容.js" - ] - }, - { - "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.37", - "date": "2026-07-13", - "time": "13:50", - "summary": "更新页面样式", - "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.36", - "date": "2026-07-13", - "time": "13:50", - "summary": "更新页面样式", + "title": "加氢记录", + "version": "v1.103", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] }, { "prototypeId": "oneos-web-h2-station", - "title": "加氢站管理", - "version": "v1.35", - "date": "2026-07-13", - "time": "13:50", - "summary": "更新页面样式", + "title": "加氢记录", + "version": "v1.102", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", "index.tsx", - "pages/01-加氢订单.jsx", "pages/02-加氢记录.jsx", - "pages/03-站点信息.jsx", - "pages/04-站点周报统计.jsx", + "pages/README.md", "styles/h2-station-vm-filter.css" ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.101", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.100", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.99", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.98", + "date": "2026-07-17", + "time": "13:01", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.97", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.96", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.95", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.94", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.93", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.92", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.91", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.90", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.89", + "date": "2026-07-17", + "time": "13:00", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + ".spec/ui-review.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.88", + "date": "2026-07-17", + "time": "12:52", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.87", + "date": "2026-07-17", + "time": "12:52", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.86", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.85", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.84", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.83", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.82", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.81", + "date": "2026-07-17", + "time": "12:51", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-h5-h2-order", + "title": "加氢订单", + "version": "v1.106", + "date": "2026-07-17", + "time": "11:57", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "prototypeId": "oneos-h5-h2-order", + "title": "加氢订单", + "version": "v1.105", + "date": "2026-07-17", + "time": "11:57", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.80", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.79", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-web-h2-station", + "title": "加氢记录", + "version": "v1.78", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/prototype-comments.json", + ".spec/prototype-review.md", + ".spec/record-data-model.md", + ".spec/requirements-prd.md", + "index.tsx", + "pages/02-加氢记录.jsx", + "pages/README.md", + "styles/h2-station-vm-filter.css" + ] + }, + { + "prototypeId": "oneos-h5-h2-order", + "title": "加氢订单", + "version": "v1.104", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "prototypeId": "oneos-h5-h2-order", + "title": "加氢订单", + "version": "v1.103", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "prototypeId": "oneos-h5-h2-order", + "title": "加氢订单", + "version": "v1.102", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "prototypeId": "oneos-h5-h2-order", + "title": "加氢订单", + "version": "v1.101", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] + }, + { + "prototypeId": "oneos-h5-h2-order", + "title": "加氢订单", + "version": "v1.100", + "date": "2026-07-17", + "time": "11:56", + "summary": "更新需求说明与标注", + "files": [ + ".spec/2026-07-14-implementation-plan.md", + ".spec/2026-07-14-requirements-design.md", + ".spec/feature-create.md", + ".spec/feature-detail.md", + ".spec/feature-list.md", + ".spec/feature-manual-ledger.md", + ".spec/fleet-plate-tag.md", + ".spec/prototype-comments.json", + ".spec/reconcile-linkage.md", + ".spec/requirements-prd.md", + "components/CreateWizard.tsx", + "components/DateTimePickerSheet.tsx" + ] } ] } diff --git a/src/prototypes/oneos-prototype-nav/xll-nav-menu.json b/src/prototypes/oneos-prototype-nav/xll-nav-menu.json index 5d6f5f2..e9ba024 100644 --- a/src/prototypes/oneos-prototype-nav/xll-nav-menu.json +++ b/src/prototypes/oneos-prototype-nav/xll-nav-menu.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-07-16T01:04:29.807Z", + "updatedAt": "2026-07-17T03:52:22.620Z", "title": "小羚羚", "sectionId": "folder-prototypes-xll-miniapp", "description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。", diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md b/src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md new file mode 100644 index 0000000..01a1ea8 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md @@ -0,0 +1,171 @@ +# 期末余额设置 · 需求说明 + +| 项 | 内容 | +|---|---| +| 模块 | 加氢站管理 → 站点信息 | +| 功能名称 | 期末余额设置 | +| 交互原型 | `/prototypes/oneos-web-h2-station-site` | +| 关联功能 | 预付余额列表展示、预付余额下钻、车辆氢费明细、生成对账单 | +| 文档状态 | 已对齐原型 | + +--- + +## 1. 需求背景 + +运营与加氢站(如嘉兴站)对账后,账面「期末余额」与系统内车辆氢费明细之间常有**时间差**:对账时点之后,站点仍可能继续产生加氢支出或充值收入,导致列表「预付余额」无法直接等于对账结果。 + +需要提供能力:在站点侧登记一次对账时的**期末余额**与**期末时间**,并以此为锚点推算当前可用余额。 + +--- + +## 2. 需求目标 + +1. 支持按站点登记期末余额、期末时间。 +2. 保存时按统一公式重算「当前预付余额」并写回列表。 +3. 预付余额下钻流水以期末时间为锚点,重新展示余额滚动。 +4. 保留完整变更记录,便于追溯。 + +--- + +## 3. 用户与入口 + +| 角色 | 诉求 | +|---|---| +| 财务 / 结算 | 对账后登记期末余额,得到可解释的当前预付余额 | +| 加氢站运营 | 查看当前余额及下钻流水是否与期末锚点一致 | + +**入口**:站点信息列表 → 操作列 **更多(⋮)** → **期末余额设置** +(菜单位置:余额提醒设置 与 生成对账单 之间) + +--- + +## 4. 功能描述 + +### 4.1 弹窗内容 + +| 区域 | 内容 | 说明 | +|---|---|---| +| 站点摘要 | 站点名称、当前预付余额 | 只读 | +| 表单 | 期末余额(元)*、期末时间 * | 必填 | +| 操作 | 取消 / 保存 | 保存触发校验与重算 | +| 变更记录 | 期末余额、生效时间、操作人、操作时间 | 只追加;支持分页 | + +### 4.2 主流程 + +```text +打开期末余额设置 + → 填写期末余额、期末时间 + → 点击保存 + → 校验通过 + → 追加变更记录 + → 按规则重算当前预付余额并写回列表 + → 提示保存成功 +``` + +打开该站点「预付余额」下钻时,若已存在生效期末配置,流水按锚点规则重算展示(见 §6)。 + +--- + +## 5. 业务规则(核心) + +### 5.1 字段规则 + +| 规则 ID | 规则 | 说明 | +|---|---|---| +| R1 | 期末余额必填 | 须为有效数字;**允许负数**(欠费场景) | +| R2 | 期末时间必填 | 精确到分钟(`YYYY-MM-DD HH:mm`) | +| R3 | 期末时间不可晚于当前时刻 | 日期不可选今天之后;保存时若时刻晚于现在,阻止保存 | +| R4 | 以最近一次保存为生效锚点 | 站点仅保留一套「当前生效」的期末余额 / 期末时间;历史见变更记录 | + +### 5.2 当前预付余额计算公式 + +**仅在点击「保存」时计算一次**(不监听之后新产生的流水自动刷新)。 + +```text +当前预付余额 = 期末余额 − 期末后支出合计 + 期末后收入合计 +``` + +| 规则 ID | 规则 | 说明 | +|---|---|---| +| R5 | 支出范围 | 该站点、业务时间 **严格晚于** 期末时间 `T` 的车辆氢费明细,按**成本总价**合计 | +| R6 | 收入范围 | 该站点、变更时间 **严格晚于** `T` 的充值类收入合计 | +| R7 | 取整 | 结果保留 **2 位小数** 后写回「预付余额(元)」 | +| R8 | 时间边界 | 等于期末时间 `T` 当刻的流水 **不计入** 期末后收支(只统计 `> T`) | + +### 5.3 变更记录规则 + +| 规则 ID | 规则 | 说明 | +|---|---|---| +| R9 | 只追加 | 每次保存成功新增一条记录 | +| R10 | 不可删除 | 界面不提供删除 / 清空 | +| R11 | 不可编辑 | 历史行不可改 | +| R12 | 记录字段 | 期末余额、生效时间(= 本次期末时间)、操作人、操作时间(保存时刻) | +| R13 | 分页 | 变更记录支持分页(紧凑页码:5 / 10 / 20 / 50) | + +### 5.4 与其他功能的关系 + +| 规则 ID | 规则 | 说明 | +|---|---|---| +| R14 | KPI / 余额提醒 | 仍读取「预付余额」;期末保存写回后,预警 / 欠费 KPI 随之变化 | +| R15 | 生成对账单 | 对账单提交仍可扣减 / 写回预付余额;与期末重算冲突时,**以后发生的写操作为准** | +| R16 | 未配置期末 | 未保存过期末余额时,预付余额逻辑保持原状(对账扣减、手工/其他入口) | + +--- + +## 6. 预付余额下钻规则 + +当站点已配置生效的期末余额、期末时间时: + +| 规则 ID | 规则 | 说明 | +|---|---|---| +| R17 | 插入锚点行 | 在流水中增加类型为「期末余额」的行:时间为期末时间,余额列 = 期末余额 | +| R18 | 锚点前流水 | 时间 ≤ 期末时间的历史流水可继续展示 | +| R19 | 锚点后流水 | 时间 > 期末时间的收入 / 支出,从期末余额起按时间顺序累加,重算每行余额 | +| R20 | 下钻不写回 | 打开下钻只重算展示,**不再次写回**列表预付余额(列表值仍以最近一次保存结果为准) | + +**锚点后单行余额推算:** + +```text +行余额 = 上一行余额 + 本行收入 − 本行支出 +(起点上一行余额 = 期末余额) +``` + +--- + +## 7. 校验与异常 + +| 场景 | 系统行为 | +|---|---| +| 期末余额为空 / 非数字 | 阻止保存,提示填写有效期末余额 | +| 期末时间为空 | 阻止保存,提示选择期末时间 | +| 期末时间晚于当前时刻 | 阻止保存,提示「期末时间不能晚于今天」 | +| 期末后无收支流水 | 允许保存;当前预付余额 = 期末余额 | +| 变更记录为空 | 展示「暂无变更记录」 | + +--- + +## 8. 验收标准 + +- [ ] 更多菜单可见「期末余额设置」 +- [ ] 必填与「期末时间不可晚于今天」校验生效 +- [ ] 保存后列表预付余额 = 公式计算结果 +- [ ] 变更记录追加成功,无删除入口,分页可用 +- [ ] 余额下钻出现「期末余额」锚点,其后余额连续正确 +- [ ] 未配置期末时,原有预付余额 / 下钻行为不受影响 + +--- + +## 9. 非目标(本期不做) + +- 变更记录编辑、删除、回滚到某一历史锚点 +- 保存后对新流水的自动实时重算 +- 多套期末锚点同时生效 +- 真实充值流水 API、审批流(原型可用 Mock) + +--- + +## 10. 修订记录 + +| 版本 | 日期 | 说明 | +|---|---|---| +| v1.0 | 2026-07-17 | 首版需求:口径、写回时机、不可删日志、时间上限、下钻锚点 | diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/prototype-review.md b/src/prototypes/oneos-web-h2-station-site/.spec/prototype-review.md index 9b7ea59..162c7f4 100644 --- a/src/prototypes/oneos-web-h2-station-site/.spec/prototype-review.md +++ b/src/prototypes/oneos-web-h2-station-site/.spec/prototype-review.md @@ -4,7 +4,7 @@ - 用户资料/参考资料: - `src/prototypes/oneos-web-h2-station-site/.spec/requirements.md` - `src/resources/oneos-web-legacy/加氢站管理/站点管理使用说明书.md` - - 页面实现:`src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` + - 页面实现:`src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` - 氢费共享 Store:`src/common/h2VehicleLedgerBridge.js` - 生成时间:2026-07-01(复审) diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md index 9e41d5e..01003e5 100644 --- a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md +++ b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md @@ -203,4 +203,4 @@ flowchart TD | 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` | | 打开弹窗 | `openAlertStationModal` | | 批量充值 | `handleBatchRechargeFromAlert` → `openRechargeModal` | -| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | +| 页面文件 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` | diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md index fc591df..c90d124 100644 --- a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md +++ b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md @@ -100,7 +100,7 @@ | 联系方式 | 联系人 + 电话 | | 操作 | 查看 + 更多菜单 | -**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。 +**更多菜单**:编辑、删除、营业状态、价格配置、余额提醒、期末余额设置、生成对账单、查看对账记录等(按状态与权限显隐)。 **分页**:默认 10 条,可选 5/10/20/50。 diff --git a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md index 6db1342..cac2102 100644 --- a/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md +++ b/src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md @@ -118,10 +118,21 @@ 3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。 4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。 -### 5.3 下钻分析 +### 5.3 期末余额设置 + +> 完整规则见 [period-end-balance.md](./period-end-balance.md) + +与加氢站对账存在时间差时,登记**期末余额**与**期末时间**作为锚点: + +- **入口**:更多 → 期末余额设置 +- **计算**:`当前预付余额 = 期末余额 − 期末后支出 + 期末后收入`(仅保存时重算一次) +- **约束**:期末时间不可晚于今天;变更记录只追加、不可删除 +- **下钻**:余额流水从期末锚点行起重新累加 + +### 5.4 下钻分析 - **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细 -- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出 +- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出;若已配置期末余额,自期末时间锚点重算后续余额 --- @@ -143,7 +154,7 @@ |---|---| | 氢费明细 | `src/common/h2VehicleLedgerBridge.js` | | 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` | -| 页面实现 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | +| 页面实现 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` | | 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` | --- @@ -163,7 +174,13 @@ - [ ] 两阶段交互与必填校验完整 - [ ] 提交后回写车辆氢费明细可跨页验收 -### 8.3 标注与文档 +### 8.3 期末余额设置 + +- [ ] 更多菜单可打开期末余额设置;期末时间不可晚于今天 +- [ ] 保存后写回预付余额,变更记录只追加不可删 +- [ ] 余额下钻出现期末锚点且后续余额连续推算 + +### 8.4 标注与文档 - [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id` - [ ] 右侧工具栏目录可阅 PRD 全文与页面标注 diff --git a/src/prototypes/oneos-web-h2-station-site/annotation-source.json b/src/prototypes/oneos-web-h2-station-site/annotation-source.json index ff5ba65..95184a4 100644 --- a/src/prototypes/oneos-web-h2-station-site/annotation-source.json +++ b/src/prototypes/oneos-web-h2-station-site/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "oneos-web-h2-station-site", "pageId": "site-info", - "updatedAt": 1783800844968, + "updatedAt": 1784265670222, "nodes": [ { "id": "h2-site-filter", @@ -25,8 +25,8 @@ "hasMarkdown": true, "color": "#2563eb", "images": [], - "createdAt": 1783584823347, - "updatedAt": 1783584823347, + "createdAt": 1784265670222, + "updatedAt": 1784265670222, "controls": [] }, { @@ -47,8 +47,8 @@ "hasMarkdown": true, "color": "#2563eb", "images": [], - "createdAt": 1783584823347, - "updatedAt": 1783584823347, + "createdAt": 1784265670222, + "updatedAt": 1784265670222, "controls": [] }, { @@ -69,8 +69,8 @@ "hasMarkdown": true, "color": "#d97706", "images": [], - "createdAt": 1783584823347, - "updatedAt": 1783584823347, + "createdAt": 1784265670222, + "updatedAt": 1784265670222, "controls": [] }, { @@ -91,8 +91,8 @@ "hasMarkdown": true, "color": "#32a06e", "images": [], - "createdAt": 1783584823347, - "updatedAt": 1783584823347, + "createdAt": 1784265670222, + "updatedAt": 1784265670222, "controls": [] }, { @@ -112,8 +112,8 @@ "hasMarkdown": true, "color": "#d97706", "images": [], - "createdAt": 1783584823347, - "updatedAt": 1783584823347, + "createdAt": 1784265670222, + "updatedAt": 1784265670222, "controls": [] }, { @@ -133,8 +133,8 @@ "hasMarkdown": true, "color": "#7c3aed", "images": [], - "createdAt": 1783584823347, - "updatedAt": 1783584823347, + "createdAt": 1784265670222, + "updatedAt": 1784265670222, "controls": [] }, { @@ -155,8 +155,29 @@ "hasMarkdown": true, "color": "#7c3aed", "images": [], - "createdAt": 1783584823347, - "updatedAt": 1783584823347, + "createdAt": 1784265670222, + "updatedAt": 1784265670222, + "controls": [] + }, + { + "id": "h2-site-period-end-balance", + "index": 8, + "title": "期末余额设置", + "pageId": "site-info", + "locator": { + "selectors": [ + "[data-annotation-id=\"h2-site-period-end-balance\"]" + ], + "fingerprint": "modal|period-end-balance", + "path": [] + }, + "aiPrompt": "期末余额锚点、重算预付余额与变更记录。", + "annotationText": "保存时按期末余额−支出+收入重算预付余额;变更记录只追加;期末时间不可晚于今天", + "hasMarkdown": true, + "color": "#0891b2", + "images": [], + "createdAt": 1784265670222, + "updatedAt": 1784265670222, "controls": [] } ] @@ -165,10 +186,11 @@ "h2-site-filter": "## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---", "h2-site-kpi": "## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 |\n| 已欠费站点 | 预付余额 < 0;点击打开站点列表弹窗,可一键发起充值单 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换。\n\n> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。\n\n### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要)\n\n这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。\n\n| 步骤 | 用户操作 | 系统反馈 |\n|---|---|---|\n| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 |\n| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 |\n| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 |\n| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) |\n| — | 点击「关闭」 | 关闭弹窗,KPI 选中态取消 |\n\n**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。\n\n**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。\n\n---", "h2-site-statement": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---", - "h2-site-table": "## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---", + "h2-site-table": "## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、余额提醒、期末余额设置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---", "h2-site-price-cost-input": "### 5.2 价格配置\n\n配置成本价格(元/kg)与生效时间;到达生效时间后刷新列表「当前成本价格」;展示价格调整记录。\n\n**固定价格 · 适用客户规则:**\n\n- 默认对**全部客户**生效(作为站点默认价格规则)\n- **取价优先级**:第一权重为单站单客户价格配置(若该客户有专属配置则按单客户配置生效);第二权重为站点「全部客户」价格配置(无单客户配置时生效)\n\n#### 阶梯价格 · 生成逻辑\n\n1. 设置阶梯价格后,在对应**阶梯价格区间**内展示并应用该档**成本价**。\n2. 累计加氢量达到下一档阶梯时,自该次起按新档成本价计算;到达**阶梯量重置日期**后的新记录,按当前所处区间的**最新成本价**更新。\n3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。\n4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。", "h2-site-price-tiered": "#### 阶梯价格 · 生成逻辑\n\n1. 设置阶梯价格后,在对应**阶梯价格区间**内展示并应用该档**成本价**。\n2. 累计加氢量达到下一档阶梯时,自该次起按新档成本价计算;到达**阶梯量重置日期**后的新记录,按当前所处区间的**最新成本价**更新。\n3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。\n4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。", - "h2-site-statement-form": "#### 生成对账单 · 业务逻辑\n\n1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点的加氢记录(已对账且尚未结算)。\n2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改。\n3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。\n4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为已对账/已结算状态,不可再修改。\n\n**阶段一 · 选期生成**\n\n- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)\n- 默认开始日期建议 = 上次结束日 + 1 天\n- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改\n- 无符合条件记录时提示调整日期\n\n**阶段二 · 结算提交**\n\n统计卡(明细表上方):\n\n| 指标 | 说明 |\n|---|---|\n| 加氢次数 | 本账单包含的已对账记录笔数 |\n| 加氢总量 | kg 合计 |\n| 成本总金额 | 成本总价合计 |\n\n明细表下方**必填**结算项:\n\n| 字段 | 规则 |\n|---|---|\n| 结算后加氢站预付款余额 | **只读**,= 当前预付余额 − 成本总金额;不足为负并标「已欠费」 |\n| 收票日期 | 必填 |\n| 收票金额 | 必填,默认填入成本总金额 |\n| 发票附件 | 必填,支持上传 |\n\n---" + "h2-site-statement-form": "#### 生成对账单 · 业务逻辑\n\n1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点的加氢记录(已对账且尚未结算)。\n2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改。\n3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。\n4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为已对账/已结算状态,不可再修改。\n\n**阶段一 · 选期生成**\n\n- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)\n- 默认开始日期建议 = 上次结束日 + 1 天\n- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改\n- 无符合条件记录时提示调整日期\n\n**阶段二 · 结算提交**\n\n统计卡(明细表上方):\n\n| 指标 | 说明 |\n|---|---|\n| 加氢次数 | 本账单包含的已对账记录笔数 |\n| 加氢总量 | kg 合计 |\n| 成本总金额 | 成本总价合计 |\n\n明细表下方**必填**结算项:\n\n| 字段 | 规则 |\n|---|---|\n| 结算后加氢站预付款余额 | **只读**,= 当前预付余额 − 成本总金额;不足为负并标「已欠费」 |\n| 收票日期 | 必填 |\n| 收票金额 | 必填,默认填入成本总金额 |\n| 发票附件 | 必填,支持上传 |\n\n---", + "h2-site-period-end-balance": "# 期末余额设置\n\n期末余额锚点、重算预付余额与变更记录。\n\n## 说明\n\n保存时按期末余额−支出+收入重算预付余额;变更记录只追加;期末时间不可晚于今天" }, "assetMap": {}, "directory": { @@ -189,14 +211,14 @@ "type": "link", "id": "h2-site-link-vh2", "title": "车辆氢费明细", - "href": "../vehicle-h2-fee-ledger/index.html", + "href": "/prototypes/vehicle-h2-fee-ledger", "target": "self" }, { "type": "link", "id": "h2-site-link-bundle", "title": "加氢站管理(合包)", - "href": "../oneos-web-h2-station/index.html", + "href": "/prototypes/oneos-web-h2-station", "target": "self" }, { @@ -209,21 +231,21 @@ "type": "markdown", "id": "h2-site-doc-prd-full", "title": "PRD 完整文档", - "markdown": "# 加氢站管理 · 站点信息 — 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.0 |\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢站合包](/prototypes/oneos-web-h2-station) |\n| 文档状态 | 已对齐原型 |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 业务背景\n\n加氢站是氢费成本结算的关键节点。运营侧需要维护站点主数据(签约、营业、成本价、预付余额),并在财务周期内按站点与加氢站完成对账、收票与付款闭环。\n\n### 1.2 本期目标\n\n- 站点台账的查询、新建、编辑、查看、删除与批量导入\n- 营业状态、成本价格、预付余额等运营配置\n- **按站点生成氢费对账单 → 填写收票信息 → 提交结算**,并与「车辆氢费明细」联动回写\n\n### 1.3 用户角色\n\n| 角色 | 典型诉求 |\n|---|---|\n| 加氢站运营 | 维护站点资料、营业与价格;查看加氢量与余额 |\n| 财务/结算 | 按周期生成对账单、登记收票、完成站点付款闭环 |\n| 管理员 | 批量导入站点、删除无效站点、配置系统账号 |\n\n### 1.4 非目标(本期不做)\n\n- 对账单审批流、ERP 自动过账\n- 发票 OCR 识别与验真\n- 加氢订单、加氢记录(仍在合包 `oneos-web-h2-station`)\n- 后端接口联调(原型使用 Mock + 共享 Store)\n\n---\n\n## 2. 信息架构\n\n```text\n站点信息\n├── 列表页\n│ ├── 筛选区(4 项)\n│ ├── KPI 卡片(6 张)\n│ ├── 工具栏\n│ └── 站点台账表 + 分页\n├── 新建 / 编辑(同页内嵌视图)\n│ ├── 基本信息\n│ ├── 系统账号绑定\n│ └── 供应商 / 付款信息\n└── 弹窗 / 抽屉\n ├── 查看详情\n ├── 营业状态 / 价格配置\n ├── 加氢量下钻 / 预付余额下钻\n ├── 预付余额预警 / 已欠费 KPI 站点列表 → 一键充值\n ├── 生成对账单(两阶段)\n └── 查看对账记录\n```\n\n---\n\n## 3. 列表与 KPI\n\n> 列表筛选与字段详见 `.spec/requirements-prd-list.md` \n> **预付余额预警 / 已欠费 KPI 点击交互**详见 `.spec/requirements-prd-kpi-balance-alert.md`\n\n- 筛选:名称、签约、地区、营业状态\n- KPI:全部 / 签约·普通 / **预付余额预警** / **已欠费** / 无加氢\n- 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作\n- 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体\n\n---\n\n## 4. 站点主数据\n\n### 4.1 新建站点\n\n同页内嵌视图:基本信息、系统账号绑定(可选)、供应商信息(新建或关联)。\n\n### 4.2 编辑 / 查看\n\n| 模式 | 交互 |\n|---|---|\n| 编辑 | 整页表单;系统账号仅 admin 可改 |\n| 查看 | 弹窗分块展示站点、账号、供应商、付款信息 |\n\n### 4.3 批量导入\n\n下载 CSV 模板 → 上传 → 解析预览 → 确认导入;站点名称不可重复。\n\n---\n\n## 5. 运营配置\n\n### 5.1 营业状态\n\n卡片式弹窗:营业状态、营业时间按钮组;非全天须配置时段;展示状态变更记录。\n\n### 5.2 价格配置\n\n配置成本价格(元/kg)与生效时间;到达生效时间后刷新列表「当前成本价格」;展示价格调整记录。\n\n**固定价格 · 适用客户规则:**\n\n- 默认对**全部客户**生效(作为站点默认价格规则)\n- **取价优先级**:第一权重为单站单客户价格配置(若该客户有专属配置则按单客户配置生效);第二权重为站点「全部客户」价格配置(无单客户配置时生效)\n\n#### 阶梯价格 · 生成逻辑\n\n1. 设置阶梯价格后,在对应**阶梯价格区间**内展示并应用该档**成本价**。\n2. 累计加氢量达到下一档阶梯时,自该次起按新档成本价计算;到达**阶梯量重置日期**后的新记录,按当前所处区间的**最新成本价**更新。\n3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。\n4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。\n\n### 5.3 下钻分析\n\n- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细\n- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出\n\n---\n\n## 6. 对账与结算(重点)\n\n> 详见 `.spec/requirements-prd-statement.md`\n\n与「车辆氢费明细」配合完成站点侧氢费结算闭环:\n\n1. 拉取已对账且未结算的记录\n2. 两阶段:选期生成 → 填收票提交\n3. 提交后更新预付余额、写对账历史、回写台账(对账日期、收票日期、已付款)\n\n---\n\n## 7. 数据与集成\n\n| 能力 | 实现 |\n|---|---|\n| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |\n| 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` |\n| 页面实现 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |\n| 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` |\n\n---\n\n## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---\n\n## 9. 修订记录\n\n| 版本 | 日期 | 说明 |\n|---|---|---|\n| v1.0 | 2026-07-01 | 独立入口、vm 规范对齐、标注目录与 PRD 接入 |\n", + "markdown": "# 加氢站管理 · 站点信息 — 产品需求文档(PRD)\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.0 |\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢记录](/prototypes/oneos-web-h2-station)、[加氢订单 H5](/prototypes/oneos-h5-h2-order) |\n| 文档状态 | 已对齐原型 |\n\n---\n\n## 1. 背景与目标\n\n### 1.1 业务背景\n\n加氢站是氢费成本结算的关键节点。运营侧需要维护站点主数据(签约、营业、成本价、预付余额),并在财务周期内按站点与加氢站完成对账、收票与付款闭环。\n\n### 1.2 本期目标\n\n- 站点台账的查询、新建、编辑、查看、删除与批量导入\n- 营业状态、成本价格、预付余额等运营配置\n- **按站点生成氢费对账单 → 填写收票信息 → 提交结算**,并与「车辆氢费明细」联动回写\n\n### 1.3 用户角色\n\n| 角色 | 典型诉求 |\n|---|---|\n| 加氢站运营 | 维护站点资料、营业与价格;查看加氢量与余额 |\n| 财务/结算 | 按周期生成对账单、登记收票、完成站点付款闭环 |\n| 管理员 | 批量导入站点、删除无效站点、配置系统账号 |\n\n### 1.4 非目标(本期不做)\n\n- 对账单审批流、ERP 自动过账\n- 发票 OCR 识别与验真\n- 加氢订单(独立 H5:`oneos-h5-h2-order`)、加氢记录(独立 Web:`oneos-web-h2-station`)本页不内嵌\n- 后端接口联调(原型使用 Mock + 共享 Store)\n\n---\n\n## 2. 信息架构\n\n```text\n站点信息\n├── 列表页\n│ ├── 筛选区(4 项)\n│ ├── KPI 卡片(6 张)\n│ ├── 工具栏\n│ └── 站点台账表 + 分页\n├── 新建 / 编辑(同页内嵌视图)\n│ ├── 基本信息\n│ ├── 系统账号绑定\n│ └── 供应商 / 付款信息\n└── 弹窗 / 抽屉\n ├── 查看详情\n ├── 营业状态 / 价格配置\n ├── 加氢量下钻 / 预付余额下钻\n ├── 预付余额预警 / 已欠费 KPI 站点列表 → 一键充值\n ├── 生成对账单(两阶段)\n └── 查看对账记录\n```\n\n---\n\n## 3. 列表与 KPI\n\n> 列表筛选与字段详见 `.spec/requirements-prd-list.md` \n> **预付余额预警 / 已欠费 KPI 点击交互**详见 `.spec/requirements-prd-kpi-balance-alert.md`\n\n- 筛选:名称、签约、地区、营业状态\n- KPI:全部 / 签约·普通 / **预付余额预警** / **已欠费** / 无加氢\n- 列表字段:名称地址、合作时间、营业、成本价、加氢次数/量、预付余额、联系方式、操作\n- 设计规范:与租赁合同管理一致的 vm 布局与 Inter 字体\n\n---\n\n## 4. 站点主数据\n\n### 4.1 新建站点\n\n同页内嵌视图:基本信息、系统账号绑定(可选)、供应商信息(新建或关联)。\n\n### 4.2 编辑 / 查看\n\n| 模式 | 交互 |\n|---|---|\n| 编辑 | 整页表单;系统账号仅 admin 可改 |\n| 查看 | 弹窗分块展示站点、账号、供应商、付款信息 |\n\n### 4.3 批量导入\n\n下载 CSV 模板 → 上传 → 解析预览 → 确认导入;站点名称不可重复。\n\n---\n\n## 5. 运营配置\n\n### 5.1 营业状态\n\n卡片式弹窗:营业状态、营业时间按钮组;非全天须配置时段;展示状态变更记录。\n\n### 5.2 价格配置\n\n配置成本价格(元/kg)与生效时间;到达生效时间后刷新列表「当前成本价格」;展示价格调整记录。\n\n**固定价格 · 适用客户规则:**\n\n- 默认对**全部客户**生效(作为站点默认价格规则)\n- **取价优先级**:第一权重为单站单客户价格配置(若该客户有专属配置则按单客户配置生效);第二权重为站点「全部客户」价格配置(无单客户配置时生效)\n\n#### 阶梯价格 · 生成逻辑\n\n1. 设置阶梯价格后,在对应**阶梯价格区间**内展示并应用该档**成本价**。\n2. 累计加氢量达到下一档阶梯时,自该次起按新档成本价计算;到达**阶梯量重置日期**后的新记录,按当前所处区间的**最新成本价**更新。\n3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。\n4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。\n\n### 5.3 期末余额设置\n\n> 完整规则见 [period-end-balance.md](./period-end-balance.md)\n\n与加氢站对账存在时间差时,登记**期末余额**与**期末时间**作为锚点:\n\n- **入口**:更多 → 期末余额设置\n- **计算**:`当前预付余额 = 期末余额 − 期末后支出 + 期末后收入`(仅保存时重算一次)\n- **约束**:期末时间不可晚于今天;变更记录只追加、不可删除\n- **下钻**:余额流水从期末锚点行起重新累加\n\n### 5.4 下钻分析\n\n- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细\n- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出;若已配置期末余额,自期末时间锚点重算后续余额\n\n---\n\n## 6. 对账与结算(重点)\n\n> 详见 `.spec/requirements-prd-statement.md`\n\n与「车辆氢费明细」配合完成站点侧氢费结算闭环:\n\n1. 拉取已对账且未结算的记录\n2. 两阶段:选期生成 → 填收票提交\n3. 提交后更新预付余额、写对账历史、回写台账(对账日期、收票日期、已付款)\n\n---\n\n## 7. 数据与集成\n\n| 能力 | 实现 |\n|---|---|\n| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |\n| 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` |\n| 页面实现 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |\n| 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` |\n\n---\n\n## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 期末余额设置\n\n- [ ] 更多菜单可打开期末余额设置;期末时间不可晚于今天\n- [ ] 保存后写回预付余额,变更记录只追加不可删\n- [ ] 余额下钻出现期末锚点且后续余额连续推算\n\n### 8.4 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---\n\n## 9. 修订记录\n\n| 版本 | 日期 | 说明 |\n|---|---|---|\n| v1.0 | 2026-07-01 | 独立入口、vm 规范对齐、标注目录与 PRD 接入 |\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd.md" }, { "type": "markdown", "id": "h2-site-doc-prd-list", "title": "列表页 PRD", - "markdown": "# 加氢站管理 · 站点信息 — 列表页 PRD\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标用户 | 加氢站运营、财务结算、平台管理员 |\n| 核心任务 | 查询站点台账、维护主数据、查看运营指标、发起对账与充值 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 设计规范 | 与租赁合同管理一致的 vm 布局(筛选 / KPI / 表格 / 分页) |\n\n---\n\n## 1. 页面结构\n\n用户进入「加氢站管理 → 站点信息」后,列表页自上而下为:\n\n1. **筛选区** — 名称、签约、地区、营业状态\n2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢\n3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点\n4. **站点列表** — 主数据 + 运营指标 + 操作列\n5. **分页栏** — 每页 5/10/20/50 条\n\n---\n\n## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---\n\n## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 |\n| 已欠费站点 | 预付余额 < 0;点击打开站点列表弹窗,可一键发起充值单 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换。\n\n> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。\n\n### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要)\n\n这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。\n\n| 步骤 | 用户操作 | 系统反馈 |\n|---|---|---|\n| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 |\n| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 |\n| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 |\n| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) |\n| — | 点击「关闭」 | 关闭弹窗,KPI 选中态取消 |\n\n**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。\n\n**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---\n\n## 7. 双入口说明\n\n| 入口 | 路径 |\n|---|---|\n| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |\n| 合包 | ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录 |\n", + "markdown": "# 加氢站管理 · 站点信息 — 列表页 PRD\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 加氢站管理 → 站点信息 |\n| 所属系统 | ONE-OS Web 端 |\n| 目标用户 | 加氢站运营、财务结算、平台管理员 |\n| 核心任务 | 查询站点台账、维护主数据、查看运营指标、发起对账与充值 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 设计规范 | 与租赁合同管理一致的 vm 布局(筛选 / KPI / 表格 / 分页) |\n\n---\n\n## 1. 页面结构\n\n用户进入「加氢站管理 → 站点信息」后,列表页自上而下为:\n\n1. **筛选区** — 名称、签约、地区、营业状态\n2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢\n3. **工具栏** — 全站余额提醒、批量导入、发起充值单、新建站点\n4. **站点列表** — 主数据 + 运营指标 + 操作列\n5. **分页栏** — 每页 5/10/20/50 条\n\n---\n\n## 2. 筛选区\n\n与租赁合同等模块一致的 **vm 筛选卡片** 布局:标题「筛选条件」+ 4 列栅格(标签在上、控件在下);底部分隔线 + 右对齐「重置」「查询」。\n\n| 字段 | 控件 | 说明 |\n|---|---|---|\n| 加氢站名称 | 输入框 | 模糊匹配,支持回车查询 |\n| 是否签约 | 下拉 | 签约站点 / 普通站点 |\n| 地区(省-市) | 级联 | 省、市两级,可只选省 |\n| 营业状态 | 下拉 | 营业中 / 暂停营业 / 停止营业 |\n\n**交互**:\n\n- **查询**:写入「已应用筛选」,列表与 KPI 统计联动,回到第 1 页\n- **重置**:清空筛选条件并刷新列表\n\n---\n\n## 3. KPI 统计卡片\n\n与租赁合同一致的 **vm-kpi-card**:左侧图标 + 标题 + 数值,右上角 `?` 悬停说明,选中态绿色描边。\n\n| 卡片 | 口径 |\n|---|---|\n| 全部加氢站 | 列表全部站点 |\n| 签约站点 | 已签约,点击快捷筛选 |\n| 普通站点 | 未签约,点击快捷筛选 |\n| 预付余额预警站点 | 已设提醒阈值,预付余额 ≥ 0 且低于阈值(不含已欠费);点击打开站点列表弹窗,可一键发起充值单 |\n| 已欠费站点 | 预付余额 < 0;点击打开站点列表弹窗,可一键发起充值单 |\n| 无加氢站点 | 加氢次数 = 0 |\n\n加氢次数来自「车辆氢费明细」共享 Store,按站点名称聚合。「无加氢」与签约 KPI 与筛选区互斥切换。\n\n> **预付余额预警 / 已欠费** 的完整点击交互见 [预付余额 KPI 交互说明](./requirements-prd-kpi-balance-alert.md)。\n\n### 3.1 预付余额预警 / 已欠费 — 点击交互(摘要)\n\n这两张 KPI 与「全部 / 无加氢」不同:**点击后不筛选主列表**,而是打开**站点列表弹窗**。\n\n| 步骤 | 用户操作 | 系统反馈 |\n|---|---|---|\n| 1 | 点击「预付余额预警站点」或「已欠费站点」 | 按口径汇总全量台账站点,打开居中弹窗;对应 KPI 卡片保持选中高亮 |\n| 2 | 查看弹窗内站点表 | 列与主列表一致(无操作列);展示站点数;预付余额负值标红「已欠费」 |\n| 3 | 点击「一键发起充值单」 | 关闭弹窗 → 打开「发起充值单」→ 为每个站点预填名称、当前余额、收款信息;付款金额由用户填写 |\n| 4 | 填写金额并提交 | 与工具栏「发起充值单」相同校验;成功后走付款流程(原型提示) |\n| — | 点击「关闭」 | 关闭弹窗,KPI 选中态取消 |\n\n**口径提醒**:预警 = 已设阈值且余额 ≥ 0 且低于阈值;欠费 = 余额 < 0。已欠费站点不计入预警。\n\n**阈值配置入口**:工具栏「全站余额提醒设置」、行内更多「余额提醒设置」。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 全站余额提醒设置 | 配置全局预付余额预警阈值 |\n| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |\n| 发起充值单 | 为站点发起预付充值流程(原型演示) |\n| 新建站点 | 进入同页内嵌新建视图 |\n\n---\n\n## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、余额提醒、期末余额设置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---\n\n## 6. 数据来源\n\n- 氢费/加氢明细:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」共享 Store\n- 先打开「车辆氢费明细」时台账全量同步;仅打开加氢站模块时使用 canonical seed\n- 对账单提交经 `H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细\n\n---\n\n## 7. 双入口说明\n\n| 入口 | 路径 |\n|---|---|\n| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |\n| 加氢订单 | OneOS → 加氢站管理 → 加氢订单(H5) |\n| 加氢记录 | OneOS → 加氢站管理 → 加氢记录(Web) |\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-list.md" }, { "type": "markdown", "id": "h2-site-doc-prd-kpi-balance", "title": "预付余额 KPI 交互", - "markdown": "# 加氢站管理 · 站点信息 — 预付余额预警 / 已欠费 KPI 交互说明\n\n| 项 | 说明 |\n|---|---|\n| 所属模块 | 加氢站管理 → 站点信息 |\n| 关联页面 | `/prototypes/oneos-web-h2-station-site` |\n| 目标用户 | 加氢站运营、财务结算 |\n| 关联能力 | 余额提醒设置、发起充值单、预付余额下钻 |\n\n---\n\n## 1. 业务背景\n\n运维与财务需要**快速发现预付余额不足或已欠费的加氢站**,并批量发起充值,避免氢费结算中断。列表页通过两张 KPI 卡片汇总风险站点,点击后进入专项处理流程,而不是像「全部 / 无加氢」那样仅筛选主列表。\n\n**与列表「加氢量」列高频/低频标签的区别**:\n\n| 维度 | KPI:预付余额预警 / 已欠费 | 列表列:加氢量旁高频/低频 |\n|---|---|---|\n| 依据 | 预付余额与提醒阈值 | 加氢次数(来自氢费明细) |\n| 点击行为 | 打开站点列表弹窗 → 可批量充值 | 无 KPI 级交互;加氢量可下钻明细 |\n| 业务目的 | 资金风险预警与充值 | 运营活跃度展示 |\n\n---\n\n## 2. KPI 统计口径\n\n两张卡片与主列表**并行展示**,统计基于当前台账内全部站点(不受列表筛选影响,但随站点数据实时重算)。\n\n| KPI 卡片 | 计入规则 | 不计入 |\n|---|---|---|\n| **预付余额预警站点** | 已设置 `balanceAlertThreshold`(> 0),且 `prepaidBalance ≥ 0`,且 `prepaidBalance < balanceAlertThreshold` | 未设阈值;已欠费(余额 < 0) |\n| **已欠费站点** | `prepaidBalance < 0` | 余额 ≥ 0 |\n\n**互斥关系**:同一站点若已欠费,只计入「已欠费」,不再计入「预付余额预警」。\n\n**阈值来源**(影响预警站点数量):\n\n| 入口 | 行为 |\n|---|---|\n| 工具栏 → **全站余额提醒设置** | 将同一阈值批量写入全部站点 |\n| 行内更多 → **余额提醒设置** | 仅更新当前站点阈值 |\n\n---\n\n## 3. 与其他 KPI 的交互差异\n\n| KPI 类型 | 点击后行为 | 选中态(绿色描边) |\n|---|---|---|\n| 全部加氢站 / 无加氢站点 | 筛选主列表,回到第 1 页;与签约筛选互斥 | `categoryTab` 匹配且未选签约筛选 |\n| 签约站点 / 普通站点 | 快捷签约筛选,重置 KPI 为「全部」 | `appliedFilters.signed` 匹配 |\n| **预付余额预警 / 已欠费** | **打开站点列表弹窗**,不筛选主列表 | 弹窗打开且 `type` 与卡片一致 |\n\n点击预警/欠费 KPI 时:\n\n- **不**修改主列表筛选条件\n- **不**切换 `categoryTab` 为列表筛选项\n- 弹窗打开期间,对应 KPI 卡片保持选中高亮\n\n---\n\n## 4. 点击 KPI → 站点列表弹窗\n\n### 4.1 触发\n\n用户点击「预付余额预警站点」或「已欠费站点」任一张 KPI 卡片。\n\n系统按口径从当前台账筛选站点,打开居中弹窗:\n\n| 属性 | 说明 |\n|---|---|\n| 标题 | 「预付余额预警站点」或「已欠费站点」 |\n| 宽度 | 约 1200px |\n| 数据范围 | 符合该 KPI 口径的全部站点(非仅当前页) |\n\n### 4.2 弹窗内容\n\n**顶部摘要区**\n\n- 标题与卡片名称一致\n- 副文案:`共 N 个站点 · 字段与列表页一致`\n- 主操作按钮:**一键发起充值单**(站点数为 0 时禁用)\n\n**站点表格**\n\n- 列与主列表一致(**不含操作列**):加氢站名称、合作时间、营业状态、营业时间、当前成本价格、加氢次数、加氢量、预付余额、联系方式等\n- 预付余额列:负值标红并显示「已欠费」(与主列表一致)\n- 超过 8 条时分页,每页 8 条\n- 无数据时展示「暂无站点」\n\n**底部按钮**\n\n| 按钮 | 行为 |\n|---|---|\n| 关闭 | 关闭弹窗,清除 KPI 选中态 |\n| 一键发起充值单 | 见下文第 5 节 |\n\n---\n\n## 5. 一键发起充值单\n\n### 5.1 流程\n\n```mermaid\nflowchart TD\n A[点击 KPI 卡片] --> B[打开站点列表弹窗]\n B --> C{用户操作}\n C -->|关闭| D[结束]\n C -->|一键发起充值单| E[关闭弹窗]\n E --> F[打开发起充值单弹窗]\n F --> G[预填弹窗内全部站点行]\n G --> H[用户填写付款金额]\n H --> I[发起付款流程]\n```\n\n### 5.2 预填规则\n\n关闭站点列表弹窗后,打开「发起充值单」弹窗,并为弹窗内**每一个站点**自动生成一行:\n\n| 预填字段 | 来源 |\n|---|---|\n| 站点 | 站点名称(不可为空) |\n| 当前余额 | 站点 `prepaidBalance` 格式化展示 |\n| 收款公司 / 银行账号 / 开户行 | 站点关联供应商付款信息 |\n| 转账用途 | 默认「羚牛预付氢费款 YYYY-MM-DD」 |\n| 付款金额 | **留空**,由用户填写 |\n\n**提示文案**:\n\n- 预警站点:`已载入 N 个预警站点,请填写付款金额后发起`\n- 欠费站点:`已载入 N 个欠费站点,请填写付款金额后发起`\n\n### 5.3 提交校验\n\n与工具栏「发起充值单」一致:\n\n- 每行须选择站点并填写付款金额\n- 站点须已关联供应商且收款信息完整;否则提示补全后再发起\n- 原型环境:校验通过后提示「已发起审批流程」\n\n### 5.4 与其他充值入口的关系\n\n| 入口 | 预填站点 |\n|---|---|\n| 工具栏 → 发起充值单 | 空白一行,用户自选 |\n| KPI 弹窗 → 一键发起充值单 | 预填当前弹窗内全部站点 |\n| 行内更多(无批量) | — |\n\n---\n\n## 6. 端到端场景示例\n\n### 场景 A:日常巡检余额\n\n1. 财务打开站点信息页,看到 KPI「预付余额预警站点 = 1」\n2. 点击该卡片,弹窗列出余额低于阈值但仍 ≥ 0 的站点\n3. 确认后点击「一键发起充值单」,填写各站付款金额并提交\n\n### 场景 B:处理欠费\n\n1. KPI「已欠费站点」显示 1,列表中对应行预付余额为红色「已欠费 -3,250.00」\n2. 点击 KPI,弹窗仅展示欠费站点\n3. 一键发起充值单,补足欠费金额\n\n### 场景 C:配置阈值后统计变化\n\n1. 工具栏「全站余额提醒设置」统一设为 50,000 元\n2. KPI 预警数量随 `prepaidBalance` 与阈值比较实时更新\n3. 某站余额扣减至阈值以下后自动出现在预警 KPI 中\n\n---\n\n## 7. 异常与边界\n\n| 场景 | 预期行为 |\n|---|---|\n| KPI 数值为 0 | 可点击;弹窗内表格为空,「一键发起充值单」禁用 |\n| 弹窗内点击关闭 | 弹窗关闭,KPI 选中态取消 |\n| 站点未设阈值 | 不计入预警 KPI(即使余额较低) |\n| 已欠费站点 | 只出现在「已欠费」,不出现在「预警」 |\n| 主列表正在筛选 | 不影响 KPI 统计与弹窗站点范围(口径为全量台账) |\n\n---\n\n## 8. 验收清单\n\n- [ ] 预警 KPI 仅统计:有阈值、余额 ≥ 0 且 < 阈值\n- [ ] 欠费 KPI 仅统计:余额 < 0;与预警互斥\n- [ ] 点击预警/欠费 KPI 打开弹窗,**主列表不筛选**\n- [ ] 弹窗打开时对应 KPI 卡片呈选中态\n- [ ] 弹窗表格字段与主列表一致(无操作列)\n- [ ] 「一键发起充值单」预填全部站点及当前余额、供应商信息\n- [ ] 预填后付款金额须用户填写;提交校验与工具栏充值一致\n- [ ] 全站/单站余额提醒设置保存后,预警 KPI 数量联动更新\n\n---\n\n## 9. 实现参考(原型)\n\n| 能力 | 位置 |\n|---|---|\n| KPI 卡片配置 | `H2_STATION_KPI_CARDS` |\n| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` |\n| 打开弹窗 | `openAlertStationModal` |\n| 批量充值 | `handleBatchRechargeFromAlert` → `openRechargeModal` |\n| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |\n", + "markdown": "# 加氢站管理 · 站点信息 — 预付余额预警 / 已欠费 KPI 交互说明\n\n| 项 | 说明 |\n|---|---|\n| 所属模块 | 加氢站管理 → 站点信息 |\n| 关联页面 | `/prototypes/oneos-web-h2-station-site` |\n| 目标用户 | 加氢站运营、财务结算 |\n| 关联能力 | 余额提醒设置、发起充值单、预付余额下钻 |\n\n---\n\n## 1. 业务背景\n\n运维与财务需要**快速发现预付余额不足或已欠费的加氢站**,并批量发起充值,避免氢费结算中断。列表页通过两张 KPI 卡片汇总风险站点,点击后进入专项处理流程,而不是像「全部 / 无加氢」那样仅筛选主列表。\n\n**与列表「加氢量」列高频/低频标签的区别**:\n\n| 维度 | KPI:预付余额预警 / 已欠费 | 列表列:加氢量旁高频/低频 |\n|---|---|---|\n| 依据 | 预付余额与提醒阈值 | 加氢次数(来自氢费明细) |\n| 点击行为 | 打开站点列表弹窗 → 可批量充值 | 无 KPI 级交互;加氢量可下钻明细 |\n| 业务目的 | 资金风险预警与充值 | 运营活跃度展示 |\n\n---\n\n## 2. KPI 统计口径\n\n两张卡片与主列表**并行展示**,统计基于当前台账内全部站点(不受列表筛选影响,但随站点数据实时重算)。\n\n| KPI 卡片 | 计入规则 | 不计入 |\n|---|---|---|\n| **预付余额预警站点** | 已设置 `balanceAlertThreshold`(> 0),且 `prepaidBalance ≥ 0`,且 `prepaidBalance < balanceAlertThreshold` | 未设阈值;已欠费(余额 < 0) |\n| **已欠费站点** | `prepaidBalance < 0` | 余额 ≥ 0 |\n\n**互斥关系**:同一站点若已欠费,只计入「已欠费」,不再计入「预付余额预警」。\n\n**阈值来源**(影响预警站点数量):\n\n| 入口 | 行为 |\n|---|---|\n| 工具栏 → **全站余额提醒设置** | 将同一阈值批量写入全部站点 |\n| 行内更多 → **余额提醒设置** | 仅更新当前站点阈值 |\n\n---\n\n## 3. 与其他 KPI 的交互差异\n\n| KPI 类型 | 点击后行为 | 选中态(绿色描边) |\n|---|---|---|\n| 全部加氢站 / 无加氢站点 | 筛选主列表,回到第 1 页;与签约筛选互斥 | `categoryTab` 匹配且未选签约筛选 |\n| 签约站点 / 普通站点 | 快捷签约筛选,重置 KPI 为「全部」 | `appliedFilters.signed` 匹配 |\n| **预付余额预警 / 已欠费** | **打开站点列表弹窗**,不筛选主列表 | 弹窗打开且 `type` 与卡片一致 |\n\n点击预警/欠费 KPI 时:\n\n- **不**修改主列表筛选条件\n- **不**切换 `categoryTab` 为列表筛选项\n- 弹窗打开期间,对应 KPI 卡片保持选中高亮\n\n---\n\n## 4. 点击 KPI → 站点列表弹窗\n\n### 4.1 触发\n\n用户点击「预付余额预警站点」或「已欠费站点」任一张 KPI 卡片。\n\n系统按口径从当前台账筛选站点,打开居中弹窗:\n\n| 属性 | 说明 |\n|---|---|\n| 标题 | 「预付余额预警站点」或「已欠费站点」 |\n| 宽度 | 约 1200px |\n| 数据范围 | 符合该 KPI 口径的全部站点(非仅当前页) |\n\n### 4.2 弹窗内容\n\n**顶部摘要区**\n\n- 标题与卡片名称一致\n- 副文案:`共 N 个站点 · 字段与列表页一致`\n- 主操作按钮:**一键发起充值单**(站点数为 0 时禁用)\n\n**站点表格**\n\n- 列与主列表一致(**不含操作列**):加氢站名称、合作时间、营业状态、营业时间、当前成本价格、加氢次数、加氢量、预付余额、联系方式等\n- 预付余额列:负值标红并显示「已欠费」(与主列表一致)\n- 超过 8 条时分页,每页 8 条\n- 无数据时展示「暂无站点」\n\n**底部按钮**\n\n| 按钮 | 行为 |\n|---|---|\n| 关闭 | 关闭弹窗,清除 KPI 选中态 |\n| 一键发起充值单 | 见下文第 5 节 |\n\n---\n\n## 5. 一键发起充值单\n\n### 5.1 流程\n\n```mermaid\nflowchart TD\n A[点击 KPI 卡片] --> B[打开站点列表弹窗]\n B --> C{用户操作}\n C -->|关闭| D[结束]\n C -->|一键发起充值单| E[关闭弹窗]\n E --> F[打开发起充值单弹窗]\n F --> G[预填弹窗内全部站点行]\n G --> H[用户填写付款金额]\n H --> I[发起付款流程]\n```\n\n### 5.2 预填规则\n\n关闭站点列表弹窗后,打开「发起充值单」弹窗,并为弹窗内**每一个站点**自动生成一行:\n\n| 预填字段 | 来源 |\n|---|---|\n| 站点 | 站点名称(不可为空) |\n| 当前余额 | 站点 `prepaidBalance` 格式化展示 |\n| 收款公司 / 银行账号 / 开户行 | 站点关联供应商付款信息 |\n| 转账用途 | 默认「羚牛预付氢费款 YYYY-MM-DD」 |\n| 付款金额 | **留空**,由用户填写 |\n\n**提示文案**:\n\n- 预警站点:`已载入 N 个预警站点,请填写付款金额后发起`\n- 欠费站点:`已载入 N 个欠费站点,请填写付款金额后发起`\n\n### 5.3 提交校验\n\n与工具栏「发起充值单」一致:\n\n- 每行须选择站点并填写付款金额\n- 站点须已关联供应商且收款信息完整;否则提示补全后再发起\n- 原型环境:校验通过后提示「已发起审批流程」\n\n### 5.4 与其他充值入口的关系\n\n| 入口 | 预填站点 |\n|---|---|\n| 工具栏 → 发起充值单 | 空白一行,用户自选 |\n| KPI 弹窗 → 一键发起充值单 | 预填当前弹窗内全部站点 |\n| 行内更多(无批量) | — |\n\n---\n\n## 6. 端到端场景示例\n\n### 场景 A:日常巡检余额\n\n1. 财务打开站点信息页,看到 KPI「预付余额预警站点 = 1」\n2. 点击该卡片,弹窗列出余额低于阈值但仍 ≥ 0 的站点\n3. 确认后点击「一键发起充值单」,填写各站付款金额并提交\n\n### 场景 B:处理欠费\n\n1. KPI「已欠费站点」显示 1,列表中对应行预付余额为红色「已欠费 -3,250.00」\n2. 点击 KPI,弹窗仅展示欠费站点\n3. 一键发起充值单,补足欠费金额\n\n### 场景 C:配置阈值后统计变化\n\n1. 工具栏「全站余额提醒设置」统一设为 50,000 元\n2. KPI 预警数量随 `prepaidBalance` 与阈值比较实时更新\n3. 某站余额扣减至阈值以下后自动出现在预警 KPI 中\n\n---\n\n## 7. 异常与边界\n\n| 场景 | 预期行为 |\n|---|---|\n| KPI 数值为 0 | 可点击;弹窗内表格为空,「一键发起充值单」禁用 |\n| 弹窗内点击关闭 | 弹窗关闭,KPI 选中态取消 |\n| 站点未设阈值 | 不计入预警 KPI(即使余额较低) |\n| 已欠费站点 | 只出现在「已欠费」,不出现在「预警」 |\n| 主列表正在筛选 | 不影响 KPI 统计与弹窗站点范围(口径为全量台账) |\n\n---\n\n## 8. 验收清单\n\n- [ ] 预警 KPI 仅统计:有阈值、余额 ≥ 0 且 < 阈值\n- [ ] 欠费 KPI 仅统计:余额 < 0;与预警互斥\n- [ ] 点击预警/欠费 KPI 打开弹窗,**主列表不筛选**\n- [ ] 弹窗打开时对应 KPI 卡片呈选中态\n- [ ] 弹窗表格字段与主列表一致(无操作列)\n- [ ] 「一键发起充值单」预填全部站点及当前余额、供应商信息\n- [ ] 预填后付款金额须用户填写;提交校验与工具栏充值一致\n- [ ] 全站/单站余额提醒设置保存后,预警 KPI 数量联动更新\n\n---\n\n## 9. 实现参考(原型)\n\n| 能力 | 位置 |\n|---|---|\n| KPI 卡片配置 | `H2_STATION_KPI_CARDS` |\n| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` |\n| 打开弹窗 | `openAlertStationModal` |\n| 批量充值 | `handleBatchRechargeFromAlert` → `openRechargeModal` |\n| 页面文件 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-kpi-balance-alert.md" }, { @@ -233,18 +255,25 @@ "markdown": "# 加氢站管理 · 站点信息 — 对账与结算 PRD\n\n> 本模块与「车辆氢费明细」配合完成**站点侧氢费结算闭环**。\n\n---\n\n## 1. 数据来源\n\n对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录:\n\n1. 对账状态 = **已对账**(台账已完成「完成对账」)\n2. **尚未**被本站点历史对账单结算过(未绑定 `statementRecordId`)\n3. 加氢时间落在用户选择的 **[账单开始日期, 账单结束日期]** 内\n4. 加氢站名称与当前操作站点一致\n\n> 对账单明细**仅展示成本字段**(加氢量、成本单价、成本总价等),不展示加氢单价、加氢总价。\n\n---\n\n## 2. 两阶段交互\n\n#### 生成对账单 · 业务逻辑\n\n1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点的加氢记录(已对账且尚未结算)。\n2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改。\n3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。\n4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为已对账/已结算状态,不可再修改。\n\n**阶段一 · 选期生成**\n\n- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)\n- 默认开始日期建议 = 上次结束日 + 1 天\n- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改\n- 无符合条件记录时提示调整日期\n\n**阶段二 · 结算提交**\n\n统计卡(明细表上方):\n\n| 指标 | 说明 |\n|---|---|\n| 加氢次数 | 本账单包含的已对账记录笔数 |\n| 加氢总量 | kg 合计 |\n| 成本总金额 | 成本总价合计 |\n\n明细表下方**必填**结算项:\n\n| 字段 | 规则 |\n|---|---|\n| 结算后加氢站预付款余额 | **只读**,= 当前预付余额 − 成本总金额;不足为负并标「已欠费」 |\n| 收票日期 | 必填 |\n| 收票金额 | 必填,默认填入成本总金额 |\n| 发票附件 | 必填,支持上传 |\n\n---\n\n## 3. 提交后的系统行为\n\n提交成功后需**原子性**完成:\n\n1. 写入对账历史(供「查看对账记录」查询)\n2. 扣减站点预付余额\n3. 标记台账记录已结算(绑定 `statementRecordId`)\n4. **回写车辆氢费明细**:\n\n| 车辆氢费明细字段 | 回写值 |\n|---|---|\n| 对账日期 | 操作完成时间 |\n| 收票日期 | 操作完成日期 |\n| 加氢站付款状态 | 已付款 |\n\n原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `h2VehicleLedgerBridge` 模拟跨页回写。\n\n---\n\n## 4. 查看对账记录\n\n入口:列表操作列 → 更多 → **查看对账记录**\n\n历史列表:对账日期、对账人、账单区间、加氢次数、加氢金额、结算后余额、查看明细。\n\n查看明细:账单区间、统计三卡、收票信息、发票附件、成本明细表。\n\n---\n\n## 5. 校验与异常\n\n| 场景 | 处理 |\n|---|---|\n| 日期未填 / 开始晚于结束 | 阻止生成 |\n| 区间内无已对账未结算记录 | 阻止生成,提示调整日期 |\n| 点击「生成对账单」未确认 | 不进入结算阶段 |\n| 收票日期/金额/附件未填 | 阻止提交 |\n| 收票金额 ≤ 0 | 阻止提交 |\n| 预付余额结算后为负 | 允许提交,标「已欠费」 |\n\n---\n\n## 6. 研发要点\n\n1. **对账单幂等**:同一笔已对账记录只能进入一次成功提交的对账单\n2. **上次结束时间**:取该站点已成功提交对账单的最大 `endDate`\n3. **余额计算**:`balanceAfter = prepaidBalance - sum(costTotal)`\n4. **跨模块同步**:生产对接统一台账;原型使用 Bridge Store\n5. **明细字段范围**:对账弹窗不展示客户侧加氢单价/总价\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-statement.md" }, + { + "type": "markdown", + "id": "h2-site-doc-prd-period-end", + "title": "期末余额设置", + "markdown": "# 期末余额设置 · 需求说明\n\n| 项 | 内容 |\n|---|---|\n| 模块 | 加氢站管理 → 站点信息 |\n| 功能名称 | 期末余额设置 |\n| 交互原型 | `/prototypes/oneos-web-h2-station-site` |\n| 关联功能 | 预付余额列表展示、预付余额下钻、车辆氢费明细、生成对账单 |\n| 文档状态 | 已对齐原型 |\n\n---\n\n## 1. 需求背景\n\n运营与加氢站(如嘉兴站)对账后,账面「期末余额」与系统内车辆氢费明细之间常有**时间差**:对账时点之后,站点仍可能继续产生加氢支出或充值收入,导致列表「预付余额」无法直接等于对账结果。\n\n需要提供能力:在站点侧登记一次对账时的**期末余额**与**期末时间**,并以此为锚点推算当前可用余额。\n\n---\n\n## 2. 需求目标\n\n1. 支持按站点登记期末余额、期末时间。\n2. 保存时按统一公式重算「当前预付余额」并写回列表。\n3. 预付余额下钻流水以期末时间为锚点,重新展示余额滚动。\n4. 保留完整变更记录,便于追溯。\n\n---\n\n## 3. 用户与入口\n\n| 角色 | 诉求 |\n|---|---|\n| 财务 / 结算 | 对账后登记期末余额,得到可解释的当前预付余额 |\n| 加氢站运营 | 查看当前余额及下钻流水是否与期末锚点一致 |\n\n**入口**:站点信息列表 → 操作列 **更多(⋮)** → **期末余额设置** \n(菜单位置:余额提醒设置 与 生成对账单 之间)\n\n---\n\n## 4. 功能描述\n\n### 4.1 弹窗内容\n\n| 区域 | 内容 | 说明 |\n|---|---|---|\n| 站点摘要 | 站点名称、当前预付余额 | 只读 |\n| 表单 | 期末余额(元)*、期末时间 * | 必填 |\n| 操作 | 取消 / 保存 | 保存触发校验与重算 |\n| 变更记录 | 期末余额、生效时间、操作人、操作时间 | 只追加;支持分页 |\n\n### 4.2 主流程\n\n```text\n打开期末余额设置\n → 填写期末余额、期末时间\n → 点击保存\n → 校验通过\n → 追加变更记录\n → 按规则重算当前预付余额并写回列表\n → 提示保存成功\n```\n\n打开该站点「预付余额」下钻时,若已存在生效期末配置,流水按锚点规则重算展示(见 §6)。\n\n---\n\n## 5. 业务规则(核心)\n\n### 5.1 字段规则\n\n| 规则 ID | 规则 | 说明 |\n|---|---|---|\n| R1 | 期末余额必填 | 须为有效数字;**允许负数**(欠费场景) |\n| R2 | 期末时间必填 | 精确到分钟(`YYYY-MM-DD HH:mm`) |\n| R3 | 期末时间不可晚于当前时刻 | 日期不可选今天之后;保存时若时刻晚于现在,阻止保存 |\n| R4 | 以最近一次保存为生效锚点 | 站点仅保留一套「当前生效」的期末余额 / 期末时间;历史见变更记录 |\n\n### 5.2 当前预付余额计算公式\n\n**仅在点击「保存」时计算一次**(不监听之后新产生的流水自动刷新)。\n\n```text\n当前预付余额 = 期末余额 − 期末后支出合计 + 期末后收入合计\n```\n\n| 规则 ID | 规则 | 说明 |\n|---|---|---|\n| R5 | 支出范围 | 该站点、业务时间 **严格晚于** 期末时间 `T` 的车辆氢费明细,按**成本总价**合计 |\n| R6 | 收入范围 | 该站点、变更时间 **严格晚于** `T` 的充值类收入合计 |\n| R7 | 取整 | 结果保留 **2 位小数** 后写回「预付余额(元)」 |\n| R8 | 时间边界 | 等于期末时间 `T` 当刻的流水 **不计入** 期末后收支(只统计 `> T`) |\n\n### 5.3 变更记录规则\n\n| 规则 ID | 规则 | 说明 |\n|---|---|---|\n| R9 | 只追加 | 每次保存成功新增一条记录 |\n| R10 | 不可删除 | 界面不提供删除 / 清空 |\n| R11 | 不可编辑 | 历史行不可改 |\n| R12 | 记录字段 | 期末余额、生效时间(= 本次期末时间)、操作人、操作时间(保存时刻) |\n| R13 | 分页 | 变更记录支持分页(紧凑页码:5 / 10 / 20 / 50) |\n\n### 5.4 与其他功能的关系\n\n| 规则 ID | 规则 | 说明 |\n|---|---|---|\n| R14 | KPI / 余额提醒 | 仍读取「预付余额」;期末保存写回后,预警 / 欠费 KPI 随之变化 |\n| R15 | 生成对账单 | 对账单提交仍可扣减 / 写回预付余额;与期末重算冲突时,**以后发生的写操作为准** |\n| R16 | 未配置期末 | 未保存过期末余额时,预付余额逻辑保持原状(对账扣减、手工/其他入口) |\n\n---\n\n## 6. 预付余额下钻规则\n\n当站点已配置生效的期末余额、期末时间时:\n\n| 规则 ID | 规则 | 说明 |\n|---|---|---|\n| R17 | 插入锚点行 | 在流水中增加类型为「期末余额」的行:时间为期末时间,余额列 = 期末余额 |\n| R18 | 锚点前流水 | 时间 ≤ 期末时间的历史流水可继续展示 |\n| R19 | 锚点后流水 | 时间 > 期末时间的收入 / 支出,从期末余额起按时间顺序累加,重算每行余额 |\n| R20 | 下钻不写回 | 打开下钻只重算展示,**不再次写回**列表预付余额(列表值仍以最近一次保存结果为准) |\n\n**锚点后单行余额推算:**\n\n```text\n行余额 = 上一行余额 + 本行收入 − 本行支出\n(起点上一行余额 = 期末余额)\n```\n\n---\n\n## 7. 校验与异常\n\n| 场景 | 系统行为 |\n|---|---|\n| 期末余额为空 / 非数字 | 阻止保存,提示填写有效期末余额 |\n| 期末时间为空 | 阻止保存,提示选择期末时间 |\n| 期末时间晚于当前时刻 | 阻止保存,提示「期末时间不能晚于今天」 |\n| 期末后无收支流水 | 允许保存;当前预付余额 = 期末余额 |\n| 变更记录为空 | 展示「暂无变更记录」 |\n\n---\n\n## 8. 验收标准\n\n- [ ] 更多菜单可见「期末余额设置」\n- [ ] 必填与「期末时间不可晚于今天」校验生效\n- [ ] 保存后列表预付余额 = 公式计算结果\n- [ ] 变更记录追加成功,无删除入口,分页可用\n- [ ] 余额下钻出现「期末余额」锚点,其后余额连续正确\n- [ ] 未配置期末时,原有预付余额 / 下钻行为不受影响\n\n---\n\n## 9. 非目标(本期不做)\n\n- 变更记录编辑、删除、回滚到某一历史锚点\n- 保存后对新流水的自动实时重算\n- 多套期末锚点同时生效\n- 真实充值流水 API、审批流(原型可用 Mock)\n\n---\n\n## 10. 修订记录\n\n| 版本 | 日期 | 说明 |\n|---|---|---|\n| v1.0 | 2026-07-17 | 首版需求:口径、写回时机、不可删日志、时间上限、下钻锚点 |\n", + "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md" + }, { "type": "markdown", "id": "h2-site-doc-scope", "title": "范围与决策", - "markdown": "# 站点信息独立原型 — 范围与决策\n\n## 背景\n\n「站点信息」从 ONE-OS Web 合包 `oneos-web-h2-station` 拆出,作为 OneOS → 加氢站管理下的独立 Axhub 入口,便于与正式菜单路径对齐。\n\n## 范围\n\n| 包含 | 不包含 |\n|------|--------|\n| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单、加氢记录(仍在合包) |\n| 营业状态、价格配置、余额下钻 | 后端接口联调 |\n| 两阶段对账单、充值单、批量导入 | |\n\n## 数据来源\n\n- **氢费/加氢明细**:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」原型共享 Store。\n- 先打开「车辆氢费明细」时,台账全量行会同步至 Store;仅打开加氢站模块时使用 canonical seed(10 条演示数据)。\n- 对账单提交 patch 经 `window.H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细。\n\n## KPI 口径(已确认)\n\n全部 / 签约·普通 / **预付余额预警** / **已欠费** / **无加氢(0 次)**,与 legacy《站点信息-产品需求说明》一致。\n\n预警/欠费 KPI 点击打开站点列表弹窗,支持一键发起充值单。详见 `.spec/requirements-prd-kpi-balance-alert.md`。\n\n## 角色演示\n\n页面右上角提供「平台管理员 / 加氢站账号」切换,影响编辑页系统账号字段可编辑性。\n\n## 双入口说明\n\n- **本原型**:OneOS → 加氢站管理 → 站点信息\n- **合包**:ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录\n", + "markdown": "# 站点信息独立原型 — 范围与决策\n\n## 背景\n\n「站点信息」从 ONE-OS Web 合包 `oneos-web-h2-station` 拆出,作为 OneOS → 加氢站管理下的独立 Axhub 入口,便于与正式菜单路径对齐。\n\n## 范围\n\n| 包含 | 不包含 |\n|------|--------|\n| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单(H5 独立)、加氢记录(Web 独立) |\n| 营业状态、价格配置、余额下钻 | 后端接口联调 |\n| 两阶段对账单、充值单、批量导入 | |\n\n## 数据来源\n\n- **氢费/加氢明细**:统一来自 `src/common/h2VehicleLedgerBridge.js`,与「车辆氢费明细」原型共享 Store。\n- 先打开「车辆氢费明细」时,台账全量行会同步至 Store;仅打开加氢站模块时使用 canonical seed(10 条演示数据)。\n- 对账单提交 patch 经 `window.H2_STATION_STATEMENT_LEDGER_UPDATES` 回写车辆氢费明细。\n\n## KPI 口径(已确认)\n\n全部 / 签约·普通 / **预付余额预警** / **已欠费** / **无加氢(0 次)**,与 legacy《站点信息-产品需求说明》一致。\n\n预警/欠费 KPI 点击打开站点列表弹窗,支持一键发起充值单。详见 `.spec/requirements-prd-kpi-balance-alert.md`。\n\n## 角色演示\n\n页面右上角提供「平台管理员 / 加氢站账号」切换,影响编辑页系统账号字段可编辑性。\n\n## 双入口说明\n\n- **本原型**:OneOS → 加氢站管理 → 站点信息\n- **加氢订单**:OneOS → 加氢站管理 → 加氢订单(`oneos-h5-h2-order`)\n- **加氢记录**:OneOS → 加氢站管理 → 加氢记录(`oneos-web-h2-station`)\n", "markdownPath": "src/prototypes/oneos-web-h2-station-site/.spec/requirements.md" }, { "type": "markdown", "id": "h2-site-doc-acceptance", "title": "验收标准", - "markdown": "## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---" + "markdown": "## 8. 验收标准\n\n### 8.1 列表页\n\n- [ ] 筛选与 KPI 六卡片联动正确\n- [ ] 高频/低频/无加氢口径与车辆氢费明细一致\n- [ ] 加氢量、预付余额下钻数据来自共享 Store\n- [ ] 布局与租赁合同 vm 规范一致\n\n### 8.2 对账流程\n\n- [ ] 仅已对账未结算记录可进入对账单\n- [ ] 两阶段交互与必填校验完整\n- [ ] 提交后回写车辆氢费明细可跨页验收\n\n### 8.3 期末余额设置\n\n- [ ] 更多菜单可打开期末余额设置;期末时间不可晚于今天\n- [ ] 保存后写回预付余额,变更记录只追加不可删\n- [ ] 余额下钻出现期末锚点且后续余额连续推算\n\n### 8.4 标注与文档\n\n- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`\n- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注\n\n---" } ] }, @@ -276,7 +305,7 @@ "type": "markdown", "id": "h2-site-doc-node-h2-site-table", "title": "站点列表", - "markdown": "## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---" + "markdown": "## 5. 列表字段说明\n\n| 字段 | 说明 |\n|---|---|\n| 加氢站名称 | 含签约标签;下一行展示省市区 + 详细地址 |\n| 合作时间 | 未签约可空 |\n| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 |\n| 当前成本价格 | 元/kg;受价格配置生效时间驱动;多价时展示警示 |\n| 加氢次数 / 加氢量 | 加氢量可点击下钻;表头支持排序 |\n| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 |\n| 联系方式 | 联系人 + 电话 |\n| 操作 | 查看 + 更多菜单 |\n\n**更多菜单**:编辑、删除、营业状态、价格配置、余额提醒、期末余额设置、生成对账单、查看对账记录等(按状态与权限显隐)。\n\n**分页**:默认 10 条,可选 5/10/20/50。\n\n---" }, { "type": "markdown", @@ -295,6 +324,12 @@ "id": "h2-site-doc-node-h2-site-statement-form", "title": "生成对账单", "markdown": "#### 生成对账单 · 业务逻辑\n\n1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点的加氢记录(已对账且尚未结算)。\n2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成对账单后,车辆氢费明细中对应记录将自动标记为已对账且无法修改。\n3. 确认后进入结算阶段,填写**收票日期**、**收票金额**及**发票附件**(对账日期在提交时由系统自动回写)。\n4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为已对账/已结算状态,不可再修改。\n\n**阶段一 · 选期生成**\n\n- 日期区下方展示:**上次对账单结束时间**(无历史则「暂无」)\n- 默认开始日期建议 = 上次结束日 + 1 天\n- 点击「生成对账单」时弹出**二次确认**:提示生成后车辆氢费明细对应记录将标记为已对账且无法修改;确认后进入阶段二,日期锁定不可改\n- 无符合条件记录时提示调整日期\n\n**阶段二 · 结算提交**\n\n统计卡(明细表上方):\n\n| 指标 | 说明 |\n|---|---|\n| 加氢次数 | 本账单包含的已对账记录笔数 |\n| 加氢总量 | kg 合计 |\n| 成本总金额 | 成本总价合计 |\n\n明细表下方**必填**结算项:\n\n| 字段 | 规则 |\n|---|---|\n| 结算后加氢站预付款余额 | **只读**,= 当前预付余额 − 成本总金额;不足为负并标「已欠费」 |\n| 收票日期 | 必填 |\n| 收票金额 | 必填,默认填入成本总金额 |\n| 发票附件 | 必填,支持上传 |\n\n---" + }, + { + "type": "markdown", + "id": "h2-site-doc-node-h2-site-period-end-balance", + "title": "期末余额设置", + "markdown": "# 期末余额设置\n\n期末余额锚点、重算预付余额与变更记录。\n\n## 说明\n\n保存时按期末余额−支出+收入重算预付余额;变更记录只追加;期末时间不可晚于今天" } ] } diff --git a/src/prototypes/oneos-web-h2-station-site/index.tsx b/src/prototypes/oneos-web-h2-station-site/index.tsx index 466d463..26d6df3 100644 --- a/src/prototypes/oneos-web-h2-station-site/index.tsx +++ b/src/prototypes/oneos-web-h2-station-site/index.tsx @@ -17,7 +17,7 @@ import { } from '@axhub/annotation'; import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host'; import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; -import SiteInfoPage from '../oneos-web-h2-station/pages/03-站点信息.jsx'; +import SiteInfoPage from './pages/03-站点信息.jsx'; import annotationSourceDocument from './annotation-source.json'; /** 与租赁合同管理 / vehicle-management 主题一致 */ diff --git a/src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx b/src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx similarity index 96% rename from src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx rename to src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx index fb6b1a6..f98ad0a 100644 --- a/src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx +++ b/src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx @@ -1137,6 +1137,28 @@ function h2FormatReceiptAmountInput(text) { return n.toFixed(2); } +/** 期末余额等可允许负数的金额输入 */ +function h2SanitizeSignedAmountInput(text) { + var raw = String(text || ''); + var negative = raw.trim().charAt(0) === '-'; + var s = raw.replace(/[^\d.]/g, ''); + var parts = s.split('.'); + if (parts.length > 2) s = parts[0] + '.' + parts.slice(1).join(''); + var dot = s.indexOf('.'); + if (dot >= 0 && s.length - dot - 1 > 2) s = s.slice(0, dot + 3); + if (negative && s) s = '-' + s; + else if (negative && !s) s = '-'; + return s; +} + +function h2FormatSignedAmountInput(text) { + var s = String(text || '').trim(); + if (!s || s === '-') return ''; + var n = parseFloat(s); + if (isNaN(n)) return ''; + return n.toFixed(2); +} + function h2ResolveCurrentCostPrice(record) { var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record); if (defaultCfg) { @@ -2996,7 +3018,8 @@ function h2FormatYuanSymbol(v, options) { var H2_BALANCE_BIZ_TYPE_MAP = { recharge: { key: 'recharge', label: '充值', color: 'success' }, refuel: { key: 'refuel', label: '车辆加氢', color: 'warning' }, - ending: { key: 'ending', label: '期末', color: 'default' } + ending: { key: 'ending', label: '期末', color: 'default' }, + period_end: { key: 'period_end', label: '期末余额', color: 'processing' } }; function h2Pad2(n) { @@ -3142,6 +3165,115 @@ function h2BuildMockPrepaidBalanceRows(record) { return rows; } +/** 期末余额变更日志(只追加,不可删除) */ +function h2CreatePeriodEndBalanceLog(periodEndBalance, effectiveTime, operator) { + return { + id: 'peb-' + Date.now() + '-' + Math.floor(Math.random() * 10000), + periodEndBalance: periodEndBalance, + effectiveTime: effectiveTime, + operator: operator || H2_CURRENT_OPERATOR, + operateTime: h2OperateTimestamp() + }; +} + +/** 期末时间之后该站氢费明细成本总价合计(支出) */ +function h2SumStationLedgerExpenseAfter(store, stationName, periodEndTime) { + var t0 = h2ParseDateTimeMs(periodEndTime); + if (isNaN(t0)) return 0; + var name = String(stationName || '').trim(); + var sum = 0; + var i; + for (i = 0; i < (store || []).length; i++) { + var r = store[i]; + if ((r.stationName || '') !== name) continue; + var t = h2ParseDateTimeMs(r.hydrogenTime); + if (isNaN(t) || t <= t0) continue; + sum += h2NumOrZero(r.costTotal); + } + return Math.round(sum * 100) / 100; +} + +/** 期末时间之后余额流水中的充值收入合计 */ +function h2SumBalanceIncomeAfter(rows, periodEndTime) { + var t0 = h2ParseDateTimeMs(periodEndTime); + if (isNaN(t0)) return 0; + var sum = 0; + var i; + for (i = 0; i < (rows || []).length; i++) { + var r = rows[i]; + if (r.bizType === 'period_end' || r.bizType === 'ending') continue; + var t = h2ParseChangeTimeMs(r.changeTime); + if (!t || t <= t0) continue; + if (r.incomeAmount != null && r.incomeAmount !== '') sum += h2NumOrZero(r.incomeAmount); + } + return Math.round(sum * 100) / 100; +} + +/** + * 保存期末余额时重算预付余额: + * 当前余额 = 期末余额 − 期末后支出 + 期末后收入 + */ +function h2RecalcPrepaidFromPeriodEnd(record, ledgerStore, periodEndBalance, periodEndTime) { + var rawRows = h2BuildMockPrepaidBalanceRows(Object.assign({}, record, { + periodEndBalance: null, + periodEndTime: null + })); + var expenseSum = h2SumStationLedgerExpenseAfter(ledgerStore, record && record.name, periodEndTime); + var incomeSum = h2SumBalanceIncomeAfter(rawRows, periodEndTime); + var prepaidBalance = Math.round((h2NumOrZero(periodEndBalance) - expenseSum + incomeSum) * 100) / 100; + return { prepaidBalance: prepaidBalance, expenseSum: expenseSum, incomeSum: incomeSum }; +} + +/** 余额下钻:从期末锚点起重算后续行余额 */ +function h2ApplyPeriodEndAnchorToBalanceRows(record, rows) { + var periodEndTime = record && record.periodEndTime; + var periodEndBalance = record && record.periodEndBalance; + if (periodEndTime == null || periodEndTime === '' || periodEndBalance == null || periodEndBalance === '') { + return (rows || []).slice(); + } + var t0 = h2ParseDateTimeMs(periodEndTime); + if (isNaN(t0)) return (rows || []).slice(); + var E = h2NumOrZero(periodEndBalance); + var before = []; + var after = []; + var i; + for (i = 0; i < (rows || []).length; i++) { + var r = rows[i]; + if (r.bizType === 'period_end') continue; + var t = h2ParseChangeTimeMs(r.changeTime); + if (t && t > t0) after.push(r); + else before.push(r); + } + after.sort(function (a, b) { + return h2ParseChangeTimeMs(a.changeTime) - h2ParseChangeTimeMs(b.changeTime); + }); + var timeText = String(periodEndTime).trim(); + if (timeText.length === 16) timeText += ':00'; + var anchor = { + key: String(record.id != null ? record.id : 'st') + '-period-end', + stationName: (record && record.name) || '', + changeTime: timeText, + bizType: 'period_end', + incomeAmount: null, + expenseAmount: null, + balance: E, + orderNo: 'PE' + String(record.id != null ? record.id : '') + }; + var bal = E; + var rebuiltAfter = after.map(function (row) { + var income = row.incomeAmount != null && row.incomeAmount !== '' ? h2NumOrZero(row.incomeAmount) : 0; + var expense = row.expenseAmount != null && row.expenseAmount !== '' ? h2NumOrZero(row.expenseAmount) : 0; + bal = Math.round((bal + income - expense) * 100) / 100; + return Object.assign({}, row, { balance: bal }); + }); + return before.concat([anchor]).concat(rebuiltAfter); +} + +function h2BuildPrepaidBalanceRowsForRecord(record) { + var base = h2BuildMockPrepaidBalanceRows(record); + return h2ApplyPeriodEndAnchorToBalanceRows(record, base); +} + function h2FormatBalanceTrendLabel(changeTime) { if (!changeTime) return '—'; var text = String(changeTime).trim(); @@ -3512,10 +3644,13 @@ function h2GetAvailableStatementLedgerRows(store, stationName, startDate, endDat if (!name || isNaN(startMs) || isNaN(endMs)) return []; var endInclusive = endMs + 86400000 - 1; return (store || []).filter(function (r) { - /* 站端 H5 上报多为 pending;完成对账后亦可能尚待结算 — 均纳入对账单来源 */ + /* 对账单仅纳入:已核对 ∧ 未对账 ∧ 未绑定其他对账单 */ if (r.statementRecordId) return false; + var verify = r.verifyStatus || 'unverified'; + if (verify !== 'verified') return false; var st = r.reconcileStatus || H2_RECONCILE_PENDING; - if (st !== H2_RECONCILE_RECONCILED && st !== H2_RECONCILE_PENDING) return false; + if (st === H2_RECONCILE_RECONCILED) return false; + if (st !== H2_RECONCILE_PENDING) return false; if ((r.stationName || '') !== name) return false; var t = h2ParseDateTimeMs(r.hydrogenTime); if (isNaN(t)) return false; @@ -4213,7 +4348,15 @@ flowchart LR 4. 保存后: - 若已到生效时间 → 列表「当前成本价格」**立即更新** - 若未到生效时间 → 到点后自动更新 -4. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。 +5. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。 + +### 5.3 期末余额设置 + +**入口**:更多 → **期末余额设置** + +1. 填写 **期末余额(元)** 与 **期末时间**(不可晚于今天)。 +2. 保存后按「期末余额 − 期末后支出 + 期末后收入」重算并写回「预付余额」(仅保存时计算一次)。 +3. 下方展示变更记录(期末余额、生效时间、操作人、操作时间),只追加不可删除,支持分页。 --- @@ -4237,6 +4380,7 @@ flowchart LR - 当前余额(负数显示「已欠费」) - 充值 / 车辆加氢流水明细 +- 若已配置期末余额:流水中出现「期末余额」锚点行,其后收入/支出从该余额连续推算 - **导出 CSV** 按钮 --- @@ -4347,6 +4491,8 @@ sequenceDiagram | 编辑 | 整页编辑站点主数据 | | 营业状态 | 维护营业状态与营业时间 | | 价格配置 | 设置成本价与生效时间 | +| 余额提醒设置 | 设置预付余额预警阈值 | +| 期末余额设置 | 登记期末余额与期末时间,重算预付余额 | | 生成对账单 | 两阶段对账结算 | | 查看对账记录 | 历史对账单与明细 | | 删除 | 删除站点(不可恢复) | @@ -4537,7 +4683,15 @@ flowchart TB **加氢量下钻**:统计卡(次数、加氢量、成本总价、加氢总价)+ 明细表 + 导出 CSV,字段对齐车辆氢费明细。 -**预付余额下钻**:充值/车辆加氢流水 + 导出 CSV;余额为负显示已欠费。 +**预付余额下钻**:充值/车辆加氢流水 + 导出 CSV;余额为负显示已欠费;若已配置期末余额,自期末时间锚点重算后续余额。 + +### 5.4 期末余额设置 + +**入口**:更多 → **期末余额设置** + +1. 填写 **期末余额(元)** 与 **期末时间**(不可晚于今天)。 +2. 保存后:「当前预付余额 = 期末余额 − 期末后支出 + 期末后收入」(仅保存时重算一次并写回列表)。 +3. 下方展示变更记录(期末余额、生效时间、操作人、操作时间),只追加不可删除,支持分页。 --- @@ -5772,6 +5926,17 @@ const Component = function () { var balanceAlertModal = balanceAlertModalState[0]; var setBalanceAlertModal = balanceAlertModalState[1]; + var periodEndBalanceModalState = useState({ + open: false, + record: null, + periodEndBalance: '', + periodEndTime: '', + logPage: 1, + logPageSize: 5 + }); + var periodEndBalanceModal = periodEndBalanceModalState[0]; + var setPeriodEndBalanceModal = periodEndBalanceModalState[1]; + var globalBalanceAlertModalState = useState({ open: false, threshold: '' }); var globalBalanceAlertModal = globalBalanceAlertModalState[0]; var setGlobalBalanceAlertModal = globalBalanceAlertModalState[1]; @@ -6277,6 +6442,9 @@ const Component = function () { costUnitPrice: createStation.costUnitPrice ? parseFloat(createStation.costUnitPrice) : undefined, customerUnitPrice: createStation.customerUnitPrice ? parseFloat(createStation.customerUnitPrice) : undefined, prepaidBalance: 0, + periodEndBalance: null, + periodEndTime: null, + periodEndBalanceLogs: [], businessStatusLogs: [], costPriceLogs: [], updateTime: now, @@ -6545,7 +6713,7 @@ const Component = function () { open: true, stationName: record.name || '', endingBalance: h2NumOrZero(record.prepaidBalance), - rows: h2BuildMockPrepaidBalanceRows(record) + rows: h2BuildPrepaidBalanceRowsForRecord(record) }); }, []); @@ -7179,6 +7347,92 @@ const Component = function () { setBalanceAlertModal({ open: false, record: null, threshold: '' }); }, []); + var openPeriodEndBalanceSetting = useCallback(function (record) { + var balanceText = record.periodEndBalance != null && record.periodEndBalance !== '' + ? h2FormatYuanNum(record.periodEndBalance) + : ''; + setPeriodEndBalanceModal({ + open: true, + record: record, + periodEndBalance: balanceText, + periodEndTime: record.periodEndTime || '', + logPage: 1, + logPageSize: 5 + }); + }, []); + + var closePeriodEndBalanceModal = useCallback(function () { + setPeriodEndBalanceModal({ + open: false, + record: null, + periodEndBalance: '', + periodEndTime: '', + logPage: 1, + logPageSize: 5 + }); + }, []); + + var handleSavePeriodEndBalance = useCallback(function () { + if (!periodEndBalanceModal.record) return; + var balanceText = h2FormatSignedAmountInput(periodEndBalanceModal.periodEndBalance); + if (balanceText === '' || balanceText == null) { + message.warning('请填写有效的期末余额'); + return; + } + var balanceNum = parseFloat(balanceText); + if (isNaN(balanceNum)) { + message.warning('请填写有效的期末余额'); + return; + } + var timeText = String(periodEndBalanceModal.periodEndTime || '').trim(); + if (!timeText) { + message.warning('请选择期末时间'); + return; + } + var timeMs = h2ParseDateTimeMs(timeText); + if (isNaN(timeMs)) { + message.warning('期末时间格式不正确'); + return; + } + if (timeMs > Date.now()) { + message.warning('期末时间不能晚于今天'); + return; + } + var recordId = periodEndBalanceModal.record.id; + var stationSnapshot = periodEndBalanceModal.record; + var recalc = h2RecalcPrepaidFromPeriodEnd(stationSnapshot, ledgerStore, balanceNum, timeText); + var newLog = h2CreatePeriodEndBalanceLog(balanceNum, timeText, H2_CURRENT_OPERATOR); + setListData(function (prev) { + return prev.map(function (r) { + if (r.id !== recordId) return r; + var logs = [newLog].concat(r.periodEndBalanceLogs || []); + return Object.assign({}, r, { + periodEndBalance: balanceNum, + periodEndTime: timeText, + periodEndBalanceLogs: logs, + prepaidBalance: recalc.prepaidBalance, + updateTime: h2OperateTimestamp() + }); + }); + }); + setPeriodEndBalanceModal(function (m) { + if (!m.record || m.record.id !== recordId) return m; + var nextRecord = Object.assign({}, m.record, { + periodEndBalance: balanceNum, + periodEndTime: timeText, + periodEndBalanceLogs: [newLog].concat(m.record.periodEndBalanceLogs || []), + prepaidBalance: recalc.prepaidBalance + }); + return Object.assign({}, m, { + record: nextRecord, + periodEndBalance: h2FormatYuanNum(balanceNum), + periodEndTime: timeText, + logPage: 1 + }); + }); + message.success('期末余额已保存,预付余额已按流水重算为 ¥' + h2FormatYuanNum(recalc.prepaidBalance) + '(原型)'); + }, [periodEndBalanceModal, ledgerStore]); + var handleSaveBalanceAlert = useCallback(function () { if (!balanceAlertModal.record) return; var thresholdText = h2FormatReceiptAmountInput(balanceAlertModal.threshold); @@ -7367,12 +7621,13 @@ const Component = function () { { key: 'business', label: '营业状态', onClick: function () { openBusinessSetting(record); } }, { key: 'price', label: '价格配置', onClick: function () { openPriceConfig(record); } }, { key: 'balanceAlert', label: '余额提醒设置', onClick: function () { openBalanceAlertSetting(record); } }, + { key: 'periodEndBalance', label: '期末余额设置', onClick: function () { openPeriodEndBalanceSetting(record); } }, { key: 'statement', label: '生成对账单', onClick: function () { openStatementModal(record); } }, { key: 'statementHistory', label: '查看对账记录', onClick: function () { openStatementHistoryModal(record); } }, { type: 'divider' }, { key: 'delete', label: '删除', danger: true, onClick: function () { setDeleteModal({ open: true, record: record }); } } ]; - }, [openBusinessSetting, openPriceConfig, openBalanceAlertSetting, openStatementModal, openStatementHistoryModal]); + }, [openBusinessSetting, openPriceConfig, openBalanceAlertSetting, openPeriodEndBalanceSetting, openStatementModal, openStatementHistoryModal]); var closeImportModal = useCallback(function () { setImportModalOpen(false); @@ -9873,6 +10128,170 @@ const Component = function () { : null ), + React.createElement(Modal, { + title: '期末余额设置', + open: periodEndBalanceModal.open, + onCancel: closePeriodEndBalanceModal, + footer: React.createElement(Space, null, + renderH2GhostButton('取消', closePeriodEndBalanceModal), + React.createElement(Button, { type: 'primary', onClick: handleSavePeriodEndBalance, style: H2_PRIMARY_BTN_STYLE }, '保存') + ), + centered: true, + width: 640, + destroyOnClose: true, + styles: { body: { maxHeight: '78vh', overflow: 'auto' } } + }, + (function () { + if (!periodEndBalanceModal.record) return null; + var logs = periodEndBalanceModal.record.periodEndBalanceLogs || []; + var logPage = periodEndBalanceModal.logPage || 1; + var logPageSize = periodEndBalanceModal.logPageSize || 5; + var logTotalPages = Math.max(1, Math.ceil(logs.length / logPageSize)); + var safePage = Math.min(logPage, logTotalPages); + var pagedLogs = logs.slice((safePage - 1) * logPageSize, safePage * logPageSize); + var periodEndTimeValue = h2ToDateTimeDayjs(periodEndBalanceModal.periodEndTime); + var periodEndPicker = dayjs && DatePicker + ? React.createElement(DatePicker, { + showTime: { format: 'HH:mm' }, + needConfirm: false, + style: { width: '100%', borderRadius: 8 }, + format: 'YYYY-MM-DD HH:mm', + placeholder: '请选择期末时间', + allowClear: true, + value: periodEndTimeValue, + disabledDate: function (current) { + if (!current || !dayjs) return false; + return current.isAfter(dayjs().endOf('day')); + }, + onChange: function (d) { + var text = d && d.isValid && d.isValid() ? d.format('YYYY-MM-DD HH:mm') : ''; + setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndTime: text }); }); + } + }) + : React.createElement(Input, { + style: { width: '100%', borderRadius: 8 }, + placeholder: 'YYYY-MM-DD HH:mm', + value: periodEndBalanceModal.periodEndTime || '', + onChange: function (e) { + setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndTime: e.target.value }); }); + } + }); + return React.createElement('div', { + 'data-annotation-id': 'h2-site-period-end-balance', + style: { display: 'flex', flexDirection: 'column', gap: 14 } + }, + React.createElement('div', { + style: { + padding: '12px 14px', + borderRadius: 10, + background: '#f8fafc', + border: '1px solid #e2e8f0', + fontSize: 13, + color: '#475569', + lineHeight: 1.55 + } + }, + React.createElement('div', { style: { fontWeight: 700, color: '#0f172a', marginBottom: 4 } }, periodEndBalanceModal.record.name || '—'), + '登记与加氢站对账时的期末余额与期末时间。保存后按「期末余额 − 期末后支出 + 期末后收入」重算并写回预付余额。当前预付余额:', + React.createElement('strong', { + style: { + color: h2NumOrZero(periodEndBalanceModal.record.prepaidBalance) < 0 ? '#dc2626' : '#059669', + fontVariantNumeric: 'tabular-nums' + } + }, h2FormatYuanNum(periodEndBalanceModal.record.prepaidBalance)), + ' 元' + ), + React.createElement(Form, { layout: 'vertical', requiredMark: false }, + React.createElement(Form.Item, { + label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '期末余额(元)') + }, + React.createElement(Input, { + className: 'h2-statement-receipt-amount-input', + inputMode: 'decimal', + prefix: '¥', + style: { width: '100%', borderRadius: 8 }, + placeholder: '请输入期末余额', + value: periodEndBalanceModal.periodEndBalance || '', + onChange: function (e) { + var next = h2SanitizeSignedAmountInput(e.target.value); + setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndBalance: next }); }); + }, + onBlur: function (e) { + var formatted = h2FormatSignedAmountInput(e.target.value); + setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndBalance: formatted }); }); + } + }) + ), + React.createElement(Form.Item, { + label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '期末时间'), + extra: '不可晚于今天;保存后作为余额计算与下钻流水的锚点' + }, periodEndPicker) + ), + React.createElement('div', null, + React.createElement('div', { + style: { fontSize: 13, fontWeight: 600, color: '#0f172a', marginBottom: 8 } + }, '变更记录'), + React.createElement(Table, { + className: 'h2-status-log-table', + size: 'small', + rowKey: 'id', + pagination: false, + locale: { emptyText: '暂无变更记录' }, + dataSource: pagedLogs, + columns: [ + { + title: '期末余额(元)', + dataIndex: 'periodEndBalance', + key: 'periodEndBalance', + width: 120, + render: function (v) { + return React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums', fontWeight: 600 } }, h2FormatYuanNum(v)); + } + }, + { + title: '生效时间', + dataIndex: 'effectiveTime', + key: 'effectiveTime', + width: 148, + render: function (v) { return v || '—'; } + }, + { + title: '操作人', + dataIndex: 'operator', + key: 'operator', + width: 100, + render: function (v) { return v || '—'; } + }, + { + title: '操作时间', + dataIndex: 'operateTime', + key: 'operateTime', + width: 148, + render: function (v) { return v || '—'; } + } + ] + }), + React.createElement('div', { className: 'vm-table-footer vm-table-footer--compact', style: { marginTop: 8, padding: '8px 0', borderTop: 'none' } }, + React.createElement(TablePagination, { + page: safePage, + pageSize: logPageSize, + total: logs.length, + pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS, + onPageChange: function (p) { + setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { logPage: p }); }); + }, + onPageSizeChange: function (size) { + setPeriodEndBalanceModal(function (m) { + return Object.assign({}, m, { logPageSize: size, logPage: 1 }); + }); + } + }) + ) + ) + ); + })() + ), + React.createElement(Modal, { title: '全站余额提醒设置', open: globalBalanceAlertModal.open, diff --git a/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs b/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs index e20f6cf..36df46e 100644 --- a/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs +++ b/src/prototypes/oneos-web-h2-station-site/scripts/build-annotation-source.mjs @@ -10,6 +10,7 @@ const fullPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd.md'), 'utf const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8'); const statementPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-statement.md'), 'utf8'); const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8'); +const periodEndPrd = fs.readFileSync(path.join(specRoot, 'period-end-balance.md'), 'utf8'); const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8'); const NODE_DEFS = [ @@ -97,6 +98,18 @@ const NODE_DEFS = [ fingerprint: 'form|statement', }, }, + { + id: 'h2-site-period-end-balance', + title: '期末余额设置', + pageId: 'site-info', + color: '#0891b2', + aiPrompt: '期末余额锚点、重算预付余额与变更记录。', + annotationText: '保存时按期末余额−支出+收入重算预付余额;变更记录只追加;期末时间不可晚于今天', + locator: { + selectors: ['[data-annotation-id="h2-site-period-end-balance"]'], + fingerprint: 'modal|period-end-balance', + }, + }, ]; const NODE_PRD_MAP = { @@ -107,9 +120,21 @@ const NODE_PRD_MAP = { 'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' }, 'h2-site-price-tiered': { file: 'full', heading: '#### 阶梯价格 · 生成逻辑' }, 'h2-site-statement-form': { file: 'statement', heading: '#### 生成对账单 · 业务逻辑' }, + 'h2-site-period-end-balance': { + file: 'periodEnd', + heading: '## 1. 背景与目标', + combine: [ + '## 1. 背景与目标', + '## 2. 已确认决策', + '## 3. 入口与交互', + '## 5. 重算逻辑(保存时)', + '## 6. 余额下钻联动', + '## 10. 验收清单', + ], + }, }; -const prdByKey = { list: listPrd, statement: statementPrd, full: fullPrd }; +const prdByKey = { list: listPrd, statement: statementPrd, full: fullPrd, periodEnd: periodEndPrd }; function extractSection(md, startHeading) { const lines = md.split('\n'); @@ -276,6 +301,13 @@ const source = { markdown: statementPrd, markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-statement.md', }, + { + type: 'markdown', + id: 'h2-site-doc-prd-period-end', + title: '期末余额设置', + markdown: periodEndPrd, + markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md', + }, { type: 'markdown', id: 'h2-site-doc-scope', diff --git a/src/prototypes/oneos-web-h2-station/.spec/prototype-review.md b/src/prototypes/oneos-web-h2-station/.spec/prototype-review.md new file mode 100644 index 0000000..29fae90 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station/.spec/prototype-review.md @@ -0,0 +1,51 @@ +# Prototype Review + +- 审查目标:`src/prototypes/oneos-web-h2-station`(加氢站管理 → **加氢记录** Web;入口 `index.tsx` → `pages/02-加氢记录.jsx`) +- 用户资料/参考资料: + - `.spec/requirements-prd.md`、`.spec/record-data-model.md` + - 关联:`oneos-h5-h2-order/.spec/requirements-prd.md`、`reconcile-linkage.md` + - 源码:`index.tsx`、`pages/02-加氢记录.jsx`、`src/common/h2VehicleLedgerBridge.js`、`annotation-source.json` + - 菜单:`.axhub/make/sidebar-tree.json`(加氢站管理分组) +- 生成时间:2026-07-16 09:56 +- 修复闭环:2026-07-16(按 Review 建议处理 P1/P2/P3) +- 资料冲突/待确认:**部分仍有**(对账权威入口叙事,见下) + +## 总体点评 + +主路径(筛选 → 近 30 日统计 → 列表增删改/导出 → 已对账只读 → 与 H5 共用 Bridge)完整可演示。按评审建议已补齐:**预约带站名(全站运营)**、**接受预约落账**、**重复键拦截**、**总额偏差确认**、**孤儿页清理与对账入口文档对齐**。剩余待确认项主要为「车辆氢费明细点完成」与「站点对账单提交」两条对账叙事并存,不阻塞本页演示。 + +## P0-P3 优先级问题 + +### P1 - 预约加氢未绑定站点 — **已修复** + +- 处理:预约种子补 `stationName`/`stationCode`;弹窗明细含站名;规格定为 **全站运营视角**。 + +### P1 - 接受预约不产生加氢记录 — **已修复** + +- 处理:接受后调用 `hrUpsertBridgeRow` 生成未对账流水(时间=预约时间,量=预约量,单价=该站近期单价);写 `linkedRecordId`;重复键时拒绝落账。 + +### P2 - 包内孤儿页与对账路径文档漂移 — **已修复** + +- 处理:删除未引用的 `01-加氢订单.jsx`;`03-站点信息.jsx` 迁至 `oneos-web-h2-station-site/pages/`;`reconcile-linkage.md` / PRD / DESIGN 引用统一为站点信息原型入口。 + +### P2 - 缺少同站同车牌同加氢时间重复录入防护 — **已修复** + +- 处理:Bridge.`findDuplicateRow`;Web / H5 保存前拦截;批量行内互查。规格见 `record-data-model.md` §5。 + +### P3 - 单价×量与总额可任意偏离无提示 — **已修复** + +- 处理:偏差 > 0.05 元时二次确认,仍可按手改总额保存。规格见 `record-data-model.md` §6。 + +## 完整性与项目对齐 + +| 维度 | 结论 | +|------|------| +| 核心任务 | 已覆盖:筛选、统计、CRUD、导出、预约受理并落账、重复键/总额校验 | +| 菜单对齐 | 站点信息 / 加氢订单(H5) / 加氢记录 / 数量统计 — 与 sidebar 一致;本包仅承载加氢记录 | +| 资料冲突/待确认 | 对账权威入口已拍板:见 [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md)(核对 ≠ 对账) | + +## 业务逻辑连贯性 + +- **流水生命周期**:新增 pending →(站点对账提交)reconciled → 禁编删。 +- **预约状态机**:pending → accepted(+ 未对账流水)/ rejected;徽标随全站 pending 递减。 +- **双端同步**:同会话 Bridge;重复键与总额规则 Web/H5 共用。 diff --git a/src/prototypes/oneos-web-h2-station/.spec/record-data-model.md b/src/prototypes/oneos-web-h2-station/.spec/record-data-model.md index 50b760f..e0010ee 100644 --- a/src/prototypes/oneos-web-h2-station/.spec/record-data-model.md +++ b/src/prototypes/oneos-web-h2-station/.spec/record-data-model.md @@ -4,6 +4,7 @@ |---|---| | 代码路径 | UI:`pages/02-加氢记录.jsx`;Store:`src/common/h2VehicleLedgerBridge.js` | | 对齐模块 | H5「加氢订单」`oneos-h5-h2-order`(同一业务、同一 Store;PC 多导出 / 预约加氢) | +| 对账入口 | 菜单原型 `oneos-web-h2-station-site`(实现:`oneos-web-h2-station-site/pages/03-站点信息.jsx`) | | 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) | --- @@ -17,48 +18,94 @@ | 加氢时间 | `YYYY-MM-DD HH:mm:ss` | | 加氢站名称 | 站名;写入时同步 `stationId`/`stationCode` | | 车牌号 | 展示去尾缀 `F`;写入 Store 补 `F` | +| 里程(km) | `mileageKm`;**非必填**;未填存 `null`,列表展示「—」 | | 氢气单价(元/kg) | 站端口径,映射 `costUnitPrice` / `customerUnitPrice` | | 加氢量(kg) | `hydrogenKg` | | 加氢总额(元) | 映射 `costTotal` / `customerAmount` | -| 对账状态 | `pending` / `reconciled` | -| 对账时间 | `reconcileDate` / `reconciledAt` 格式化展示 | +| 核对状态 | `verifyStatus`:`unverified` / `verified`;能源部「完成核对」写入 | +| 核对时间 | `verifiedAt`;未核对应「—」 | +| 对账状态 | `pending` / `reconciled`;**仅**站点对账单提交后变为已对账 | +| 对账时间 | **仅** `reconcileDate`(对账单回写);勿用 `reconciledAt` 判定对账 | -写入 Bridge 时默认:`settlementStatus=customer`、`mileageKm=0`、`creatorName=站端账号`(与 H5 `upsertStationOrder` 相同),不在 Web 表单暴露。 +写入 Bridge 时默认:`settlementStatus=customer`、`creatorName=站端账号`、`verifyStatus=unverified`、`reconcileStatus=pending`(与 H5 相同)。`mileageKm` 由表单选填写入,未填为 `null`。 + +> 双状态全文:[../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) --- -## 2. 对账判定优先级 +## 2. 核对 / 对账判定与锁定 | 优先级 | 条件 | 用户可见 | |---|---|---| -| 1 | `reconcileStatus === 'reconciled'` 或已有对账时间 | **已对账**;禁编辑、禁删除 | -| 2 | 否则 | **未对账**;可编辑、删除 | +| 1 | `reconcileStatus === 'reconciled'` 或有 `reconcileDate` | **已对账**;禁编辑、禁删除;对账时间必有值 | +| 2 | `verifyStatus === 'verified'` | **已核对**(可仍未对账);禁编辑、禁删除;展示核对时间 | +| 3 | 否则 | **未核对 + 未对账**;可编辑、删除 | ## 3. 前置条件 | 场景 | 行为 | |---|---| | 共享 Bridge 不可用 | 列表用本地 fallback;保存/删除提示失败 | -| 已对账点编辑/删除 | 警告「已对账记录不可编辑/删除」 | -| PC 独有 | **导出**当前筛选结果 CSV;**预约加氢**独立弹窗(不影响加氢记录数据结构) | +| 已核对或已对账点编辑/删除 | 对应警告文案 | +| PC 独有 | **导出**含核对/对账列;**预约加氢**接受后写入未核对流水 | ## 4. 数据流 ```text -Web 加氢记录 新增/编辑/批量 - → hrUpsertBridgeRow → Bridge.upsertRow(站端字段映射) - → Store subscribe → 列表刷新 +Web / H5 新增加氢记录 + → Bridge.upsertRow(unverified + pending) + → 车辆氢费明细可见 -H5 加氢订单 同上 upsert/remove - → 与 Web 读写同一 Store +车辆氢费明细 · 完成核对 + → verifyStatus=verified + verifiedAt + → Web / H5 同步「已核对」 -站点信息 · 对账单提交 - → applyPatches(reconciled + 对账时间) - → Web / H5 同步展示 +站点信息 · 对账单提交(仅已核对 ∧ 未对账) + → reconcileStatus=reconciled + reconcileDate + → Web / H5 / 台账同步「已对账」 ``` -## 5. 边界 +## 5. 重复录入防护(业务主键) -- Web 不做 OCR;录入单价×量可自动带出总额,总额仍可手改 -- 预约加氢为 PC 侧能力,数据与加氢记录 Store 分离 +| 项 | 规则 | +|---|---| +| 业务键 | **加氢站名称 + 车牌号(含 F)+ 加氢时间(精确到秒)** | +| 判定顺序 | 1)规范化车牌/时间 → 2)在共享 Store 查重(编辑时排除自身 id)→ 3)批量内行间互查 | +| 前置条件 | 站名、车牌、时间均非空才校验;已对账记录仍占键,不可再录入同键 | +| 用户可见 | **拦截保存**,提示「已存在相同加氢站、车牌号与加氢时间的记录」 | +| 代码 | Bridge.`findDuplicateRow`;Web / H5 共用 | + +## 6. 总额与单价×量偏差 + +| 项 | 规则 | +|---|---| +| 期望值 | `round(单价 × 加氢量, 2)` | +| 容差 | 绝对值 ≤ **0.05 元** 视为一致 | +| 超过容差 | 二次确认「仍要保存」;确认后按用户填写的总额写入(不强制改回) | +| 代码 | Bridge.`totalAmountMismatch`;Web Modal / H5 `confirm` | + +## 7. 预约加氢(PC 独有 · 全站运营视角) + +| 字段 | 说明 | +|---|---| +| 加氢站名称 | `stationName`(必有);列表与接受/拒绝确认文案展示 | +| 站点编码 | `stationCode` | +| 车牌 / 预约时间 / 量 / 司机 / 联系方式 | 司机预约信息 | +| 状态 | `pending` / `accepted` / `rejected` | +| 关联流水 | 接受成功后写 `linkedRecordId`(对应加氢记录 id) | + +| 规则 | 说明 | +|---|---| +| 运营视角 | **全站运营**:顶栏「预约加氢」Tab 列出全部站点预约;靠「加氢站名称」列区分归属;列表含处理情况 | +| 徽标 | 统计全部 `pending` 条数,不随主列表站点筛选收缩 | +| 接受落账 | 生成 **未对账** 流水:`hydrogenTime=预约时间`,`hydrogenKg=预约量`,`unitPrice=该站近期单价`(`lookupStationUnitPrice`,缺省 42.5),总额=单价×量;`creatorName=预约受理` | +| 拒绝 | 仅改预约状态,不写流水 | +| 重复键 | 若同站同车牌同预约时间已有流水,**拒绝落账**并提示 | + +代码:`HR_INITIAL_APPOINTMENTS`、`handleAcceptAppointment`(`pages/02-加氢记录.jsx`)。 + +## 8. 边界 + +- Web 不做 OCR;录入单价×量可自动带出总额,总额仍可手改(超容差需确认) +- 预约列表数据本地维护;落账写入共享 Store 后与 H5 / 对账单可见 - 跨页联动依赖同浏览器会话内共享 Store diff --git a/src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md b/src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md index 35243a3..4dc3fc7 100644 --- a/src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md +++ b/src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md @@ -15,29 +15,41 @@ 与 H5「加氢订单」为同一业务:站端上报加氢流水。Web 供 PC 操作;H5 供移动端。数据结构、共享账本与对账规则一致。Web **额外**提供导出、预约加氢处理。 +### 1.0 加氢站管理菜单分工 + +| 菜单 | 原型 | 职责 | +|---|---|---| +| 站点信息 | `oneos-web-h2-station-site` | 站点主数据、对账单生成与提交回写 | +| 加氢订单 | `oneos-h5-h2-order` | 站端手机浏览器录入本站流水 | +| 加氢记录 | **本原型** | PC 多站流水台账、导出、预约受理 | + ### 1.1 功能清单 | # | 功能 | 说明 | |---|---|---| | 1 | 列表 / 筛选 | 字段与 H5 一致;可按时间、车牌、站名、对账状态筛选 | -| 2 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、单价、量、总额;已对账只读 | -| 3 | 删除 | 仅未对账;写共享 Store | -| 4 | 导出 | 当前筛选结果 CSV(Web 独有) | -| 5 | 预约加氢 | 弹窗处理预约(Web 独有,与记录数据结构分离) | +| 2 | 手工台账 | 与 H5 同逻辑:月历标已传/未传、可补传过往日;未上传今日台账不可新增;见 [feature-manual-ledger](../oneos-h5-h2-order/.spec/feature-manual-ledger.md) | +| 3 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、**里程(选填)**、单价、量、总额;已对账只读;同站同车牌同时间拦截;总额偏离单价×量超 0.05 元需确认 | +| 4 | 删除 | 仅未对账;写共享 Store | +| 5 | 导出 | 当前筛选结果 CSV(Web 独有) | +| 6 | 预约加氢 | 列表工具栏左上角第三个 Tab;列表展示预约信息与处理情况;**接受后自动生成未对账加氢记录** | ### 1.2 列表字段 -加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间。 +加氢时间、加氢站名称、车牌号、里程(km)、氢气单价(元/kg)、加氢量、加氢总额、核对状态、核对时间、对账状态、对账时间。 --- ## 2. 验收项 -1. 列表/表单字段与 H5「加氢订单」一致(无公里数 / 充装员 / 承担方式录入) +1. 列表/表单字段与 H5「加氢订单」一致(含选填里程;无充装员 / 承担方式录入) 2. 与 H5 读写同一共享 Store;一端增删改后另一端可见(同会话) -3. 已对账不可编辑、删除 -4. 导出列与列表字段一致 -5. 预约加氢入口与弹窗仍可用 +3. 已核对或已对账不可编辑、删除;**已对账记录对账时间不为空**(对账单回写);核对由车辆氢费明细「完成核对」触发 +4. 导出列与列表字段一致(含核对/对账) +5. 列表左上角三个 Tab:加氢记录 / 手工台账 / 预约加氢;预约 Tab 列表展示预约信息与处理情况 +6. 预约徽标为全站待处理条数(全站运营视角,不随列表站点筛选收缩) +7. 接受预约后列表出现对应未对账流水(时间/站/车牌/量与预约一致) +8. 同站同车牌同加氢时间重复保存被拦截;总额与单价×量偏差 > 0.05 元时出现确认 --- @@ -45,4 +57,6 @@ | 主题 | 摘要 | 全文 | |---|---|---| -| 数据结构与对账 | 站端口径字段映射 Bridge;对账后禁改;PC 多导出/预约 | [record-data-model.md](./record-data-model.md) | +| 数据结构与对账 | 站端口径字段;**核对 vs 对账**双状态;PC 多导出/预约落账 | [record-data-model.md](./record-data-model.md)、[../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) | +| 重复键 / 总额偏差 | 业务主键拦截;总额偏差二次确认 | [record-data-model.md](./record-data-model.md) §5–6 | +| 预约接受落账 | 接受 → 未对账草稿流水 | [record-data-model.md](./record-data-model.md) §7 | diff --git a/src/prototypes/oneos-web-h2-station/.spec/ui-review.md b/src/prototypes/oneos-web-h2-station/.spec/ui-review.md new file mode 100644 index 0000000..abe8a54 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station/.spec/ui-review.md @@ -0,0 +1,81 @@ +# UI Review + +- 审查目标:`src/prototypes/oneos-web-h2-station`(加氢站管理 · 加氢记录) +- 使用设计依据:`src/prototypes/vm-shared/DESIGN.md` +- 生成时间:2026-07-17 12:55 +- 修复回写:2026-07-17(P0–P2 已按规范落地) + +## 总体点评 + +加氢记录页是典型中后台列表:筛选、近 30 日 KPI 轮播、三 Tab(加氢记录 / 手工台账 / 预约加氢)与明细表同屏协作,任务路径清晰。视觉上延续 emerald 主色、白卡片与 tabular-nums 数字排版,和站点信息等 OneOS Web 列表气质一致;列表工具栏左上角放 Tab、右侧放导出/新增,信息架构合理。 + +与 `vm-shared/DESIGN.md` 对照时,偏离曾集中在三条**强制规范**:底部分页、筛选日期区间、操作列。已改为 `TablePagination` / `DateRangeFilterField` / `OperationActions`,数字列去掉右对齐,预约统计卡去掉左侧色条。 + +亮点:每日统计卡片带 `aria-label`、预约待处理徽标、手工台账拦截提示可点击跳转;新增/编辑页底栏固定、主按钮约 44px。预约 Tab 列表化后比弹窗更易扫读处理情况。 + +## P0-P3 优先级问题 + +### P0 - 列表分页未使用 `TablePagination` — ✅ 已修复 + +- 证据(修复前):主表 `pagination: { current, pageSize, showTotal: '共 N 条', ... }`;预览为 Ant Design 分页。 +- 修复:主表与预约表均 `pagination: false` + `vm-table-footer` 内 `TablePagination`(`COMPACT_PAGE_SIZE_OPTIONS`);数据为筛选后切片 `pagedList` / `pagedAppointments`。 + +### P0 - 筛选「加氢时间」使用 Ant Design `RangePicker` — ✅ 已修复 + +- 证据(修复前):筛选区 `RangePicker`,文案「开始时间 / 结束时间」。 +- 修复:改为 `DateRangeFilterField`(`startDate` / `endDate`),按自然日过滤;展示「至」连接起止。 + +### P0 - 操作列未使用 `OperationActions` — ✅ 已修复 + +- 证据(修复前):主表 / 预约表自建 `Button type="link"` 横排。 +- 修复:主表 `OperationActions`(`edit` + `more` 含危险删除);预约待处理行 `more: [接受, 拒绝]`;无操作时组件渲染 `-`。 + +### P1 - 金额 / 数量列右对齐 — ✅ 已修复 + +- 证据(修复前):里程 / 单价 / 加氢量 / 加氢总额 / 预约加氢量 `align: 'right'`。 +- 修复:去掉右对齐,保留 `tabular-nums` / `hr-refuel-*` 等宽数字类名。 + +### P2 - 预约统计卡左侧色条过重 — ✅ 已修复 + +- 证据(修复前):`.h2-refuel-drill-stat--*` `border-left: 4px solid …`。 +- 修复:去掉单侧色条,保留全边框 + 浅底(kg 卡保留淡绿渐变底)。 + +## 核心元件 + +### 列表工具栏 / 三 Tab + +- 保留:Tab 落在列表工具栏左侧,导出/新增右对齐;预约待处理角标提升发现性。 + +### 筛选区 + +- 已对齐:`DateRangeFilterField`;四列网格 + 重置/查询底栏。 + +### 每日统计 KPI + +- 保留:近 30 天轮播、今日强调、每卡 `article` + 完整 `aria-label`;数字等宽。 + +### 数据表格 + +- 已对齐:`TablePagination`、`OperationActions`、数字列左对齐。 + +### 预约加氢列表 + +- 已对齐:统计卡无侧色条;操作列 `OperationActions`;底部分页同主表。 + +### 新增 / 编辑壳层 + +- 保留:顶栏返回 + 居中标题;底栏固定、主按钮 `min-height: 44px`。 + +## 响应式与可访问性 + +- 桌面:筛选 4→2→1 列断点存在;表格横向 `scroll.x` 可横向拖读。 +- 移动端:未做完整窄屏操作验收;长表格 + 多 Tab 在手机上会偏挤,建议后续专项压测。 +- 键盘 / 语义:筛选与按钮可聚焦;每日统计与预约统计有 `role`/`aria-label`;分页已用中文「共 N 条 / 每页」。 +- 对比度:正文 `#0f172a` / `#475569` 在白底上大致满足 AA。 +- 触控:底栏主按钮 ≥44px;操作列改 `OperationActions`(44×44 触控约定)。 + +## 证据与评估说明 + +- 浏览器/截图:已打开 `http://localhost:51720/prototypes/oneos-web-h2-station` 并截取列表首屏(审查当时为 Ant 分页)。 +- Scanner:审查时 `detect.mjs` 命中 `side-tab`;修复后已去掉对应 `border-left: 4px`。 +- 设计依据说明:原型未自带 `DESIGN.md`;按项目列表页强制规范选用 `src/prototypes/vm-shared/DESIGN.md`(与入口 `import '../vehicle-management/style.css'`、中后台列表形态一致)。 diff --git a/src/prototypes/oneos-web-h2-station/annotation-source.json b/src/prototypes/oneos-web-h2-station/annotation-source.json index b792861..f7fc7a7 100644 --- a/src/prototypes/oneos-web-h2-station/annotation-source.json +++ b/src/prototypes/oneos-web-h2-station/annotation-source.json @@ -2,10 +2,9 @@ "documentVersion": 1, "format": "axhub-annotation-source", "data": { - "version": 1, + "version": 2, "prototypeName": "oneos-web-h2-station", "pageId": "h2-record", - "updatedAt": 1784013600000, "nodes": [ { "id": "hr-filter", @@ -13,7 +12,9 @@ "title": "筛选条件", "pageId": "h2-record", "locator": { - "selectors": [".lc-filter-card"], + "selectors": [ + ".lc-filter-card" + ], "fingerprint": "filter|h2-record", "path": [] }, @@ -31,11 +32,13 @@ "title": "列表工具栏", "pageId": "h2-record", "locator": { - "selectors": [".lc-table-toolbar"], + "selectors": [ + ".lc-table-toolbar" + ], "fingerprint": "toolbar|h2-record", "path": [] }, - "annotationText": "Web 独有:预约加氢、导出;新增/编辑写入与 H5 同一共享账本。已对账不可改删。", + "annotationText": "有待处理预约时显示全站待处理条数。弹窗含加氢站名称(全站运营视角)。接受预约将自动生成一条未对账加氢记录(时间=预约时间、量=预约量);拒绝仅改预约状态。", "hasMarkdown": false, "color": "#059669", "images": [], @@ -49,39 +52,44 @@ "title": "加氢记录列表", "pageId": "h2-record", "locator": { - "selectors": [".lc-list-table"], + "selectors": [ + ".lc-list-table" + ], "fingerprint": "table|h2-record", "path": [] }, - "annotationText": "列:加氢时间、站名、车牌、单价、量、总额、对账状态、对账时间(与 H5 一致)。", + "annotationText": "列:加氢时间、站名、车牌、里程、单价、量、总额、核对状态/时间、对账状态/时间。已核对或已对账禁改删;对账仅对账单提交后出现。", "hasMarkdown": false, "color": "#FF7D00", "images": [], "createdAt": 1784013600000, "updatedAt": 1784013600000, "controls": [] + }, + { + "id": "hr-daily-stats", + "index": 4, + "title": "每日统计", + "pageId": "h2-record", + "locator": { + "selectors": [ + ".hr-daily-stats-head__meta", + ".hr-daily-stats-section" + ], + "fingerprint": "daily-stats|h2-record", + "path": [] + }, + "annotationText": "近30天(含今天,自今日前推):展示起止日期与加氢总量;每日卡片为 2×2:加氢数据条数 / 已对账条数 / 加氢量 / 加氢总额,随筛选结果实时汇总。", + "hasMarkdown": false, + "color": "#7C3AED", + "images": [], + "createdAt": 1784013600000, + "updatedAt": 1784165900000, + "controls": [] } ], - "directory": [ - { - "id": "hr-prd-root", - "title": "产品需求", - "type": "folder", - "children": [ - { - "id": "hr-prd-main", - "title": "加氢记录(Web)PRD", - "type": "markdown", - "markdownPath": "src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md" - }, - { - "id": "hr-prd-model", - "title": "数据结构与对账", - "type": "markdown", - "markdownPath": "src/prototypes/oneos-web-h2-station/.spec/record-data-model.md" - } - ] - } - ] - } + "updatedAt": 1784013600000 + }, + "markdownMap": {}, + "assetMap": {} } diff --git a/src/prototypes/oneos-web-h2-station/index.tsx b/src/prototypes/oneos-web-h2-station/index.tsx index cf68c18..2901929 100644 --- a/src/prototypes/oneos-web-h2-station/index.tsx +++ b/src/prototypes/oneos-web-h2-station/index.tsx @@ -8,6 +8,8 @@ import React from 'react'; import '../vehicle-management/style.css'; import Page2 from './pages/02-加氢记录.jsx'; import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell'; +import { AnnotationViewer, type AnnotationSourceDocument } from '@axhub/annotation'; +import annotationSourceDocument from './annotation-source.json'; const pages = [ { id: 'h2-record', title: '加氢记录', component: Page2 }, @@ -15,10 +17,23 @@ const pages = [ export default function OneosWebH2Station() { return ( - + <> + + + ); } diff --git a/src/prototypes/oneos-web-h2-station/pages/01-加氢订单.jsx b/src/prototypes/oneos-web-h2-station/pages/01-加氢订单.jsx deleted file mode 100644 index ceca633..0000000 --- a/src/prototypes/oneos-web-h2-station/pages/01-加氢订单.jsx +++ /dev/null @@ -1,194 +0,0 @@ -// 【重要】必须使用 const Component 作为组件变量名 -// 加氢站管理 - 加氢订单(列表 + 筛选)(2026年3月版) - -const Component = function () { - var useState = React.useState; - var useMemo = React.useMemo; - - var antd = window.antd; - var Card = antd.Card; - var Table = antd.Table; - var Button = antd.Button; - var Select = antd.Select; - var Input = antd.Input; - var DatePicker = antd.DatePicker; - var Space = antd.Space; - var Modal = antd.Modal; - var message = antd.message; - - var RangePicker = DatePicker.RangePicker || DatePicker.RangePicker; - - // 筛选状态 - var stationState = useState(undefined); - var timeRangeState = useState([]); - var plateNoState = useState(''); - var station = stationState[0]; - var setStation = stationState[1]; - var timeRange = timeRangeState[0]; - var setTimeRange = timeRangeState[1]; - var plateNo = plateNoState[0]; - var setPlateNo = plateNoState[1]; - - var requirementModalVisible = useState(false); - - var orderDataRevisionState = useState(0); - var orderDataRevision = orderDataRevisionState[0]; - var setOrderDataRevision = orderDataRevisionState[1]; - - React.useEffect(function () { - var bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null; - if (!bridge || typeof bridge.subscribe !== 'function') return undefined; - return bridge.subscribe(function () { - setOrderDataRevision(function (v) { return v + 1; }); - }); - }, []); - - // 模拟:加氢站与加氢记录数据(氢费明细来自车辆氢费明细共享 Store) - var stationList = useMemo(function () { - var bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null; - if (bridge && typeof bridge.getStationList === 'function') { - return bridge.getStationList(); - } - return [ - { value: 'JX-H2-001', label: '嘉兴加氢站(一期)' }, - { value: 'HZ-H2-002', label: '杭州临平加氢站' }, - { value: 'SH-H2-003', label: '上海宝山加氢站' } - ]; - }, [orderDataRevision]); - - var rawData = useMemo(function () { - var bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null; - if (bridge && typeof bridge.getOrderRecords === 'function') { - return bridge.getOrderRecords(); - } - return [ - { id: 1, stationCode: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', time: '2026-03-01 10:21', plateNo: '浙A12345', amountKg: 12.5, amountYuan: 625.00 }, - { id: 2, stationCode: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', time: '2026-03-01 14:08', plateNo: '浙A67890', amountKg: 10.0, amountYuan: 500.00 }, - { id: 3, stationCode: 'HZ-H2-002', stationName: '杭州临平加氢站', time: '2026-03-02 09:30', plateNo: '浙B23456', amountKg: 15.3, amountYuan: 765.00 }, - { id: 4, stationCode: 'SH-H2-003', stationName: '上海宝山加氢站', time: '2026-03-03 16:45', plateNo: '沪A88888', amountKg: 8.0, amountYuan: 400.00 }, - { id: 5, stationCode: 'HZ-H2-002', stationName: '杭州临平加氢站', time: '2026-03-03 18:10', plateNo: '浙B99999', amountKg: 18.2, amountYuan: 910.00 } - ]; - }, [orderDataRevision]); - - // 简单字符串时间过滤(示例用,实际可改为 dayjs 比较) - function inRange(timeStr, range) { - if (!range || !range.length || !range[0] || !range[1]) return true; - // timeStr: 'YYYY-MM-DD HH:mm',只做字符串比较以示意 - var t = timeStr.replace(/[-:\\s]/g, ''); - var s = range[0].format ? range[0].format('YYYYMMDDHHmm') : ''; - var e = range[1].format ? range[1].format('YYYYMMDDHHmm') : ''; - if (!s || !e) return true; - return t >= s && t <= e; - } - - var filteredData = useMemo(function () { - var list = rawData.slice(); - if (station) { - list = list.filter(function (r) { return r.stationCode === station; }); - } - if (plateNo && plateNo.trim()) { - var kw = plateNo.trim().toLowerCase(); - list = list.filter(function (r) { return (r.plateNo || '').toLowerCase().indexOf(kw) !== -1; }); - } - if (timeRange && timeRange.length === 2) { - list = list.filter(function (r) { return inRange(r.time, timeRange); }); - } - return list.map(function (r, idx) { - return Object.assign({}, r, { seq: idx + 1 }); - }); - }, [rawData, station, plateNo, timeRange]); - - var columns = [ - { title: '序号', dataIndex: 'seq', key: 'seq', width: 70, align: 'center' }, - { title: '加氢站', dataIndex: 'stationName', key: 'stationName', width: 200 }, - { title: '加氢时间', dataIndex: 'time', key: 'time', width: 180 }, - { title: '车牌号', dataIndex: 'plateNo', key: 'plateNo', width: 120 }, - { title: '加氢量(kg)', dataIndex: 'amountKg', key: 'amountKg', width: 120, align: 'right', render: function (v) { return (v != null ? v.toFixed(2) : '0.00'); } }, - { title: '金额(元)', dataIndex: 'amountYuan', key: 'amountYuan', width: 120, align: 'right', render: function (v) { var n = typeof v === 'number' ? v : parseFloat(v); return (isNaN(n) ? '0.00' : n.toFixed(2)); } } - ]; - - var layoutStyle = { padding: '16px 24px', background: '#f5f5f5', minHeight: '100vh' }; - var cardStyle = { marginBottom: 16 }; - var filterLabelStyle = { marginBottom: 6, fontSize: 14, color: 'rgba(0,0,0,0.65)' }; - var filterItemStyle = { marginBottom: 12 }; - - function handleReset() { - setStation(undefined); - setTimeRange([]); - setPlateNo(''); - } - - function handleExport() { - message.info('导出当前筛选条件下的加氢订单(示例)'); - } - - var requirementContent = '加氢订单:一个「数字化资产ONEOS运管平台」中的「加氢站管理-加氢订单」模块。\n\n#1.面包屑:加氢站管理-加氢订单\n\n#2.筛选:支持加氢站、加氢时间、车牌号等筛选;\n2.1.加氢站:选择器,选择加氢站;\n2.2.加氢时间:日期时间范围选择器,支持开始时间-结束时间;\n2.3.车牌号:输入框,支持模糊匹配;\n右侧为查询、重置按钮。\n\n#3.列表:加氢订单列表,展示序号、加氢站、加氢时间、车牌号、加氢量(kg)、金额(元);支持分页与导出。'; - - return React.createElement('div', { style: layoutStyle }, - React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button, { type: 'link', style: { padding: 0 }, onClick: function () { requirementModalVisible[1](true); } }, '查看需求说明') - ), - React.createElement(Card, { title: '筛选', style: cardStyle }, - React.createElement('div', { - style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px 24px', alignItems: 'flex-end' } - }, - React.createElement('div', { style: filterItemStyle }, - React.createElement('div', { style: filterLabelStyle }, '加氢站'), - React.createElement(Select, { - placeholder: '请选择加氢站', - allowClear: true, - style: { width: '100%' }, - value: station, - onChange: function (v) { setStation(v); }, - options: stationList - }) - ), - React.createElement('div', { style: filterItemStyle }, - React.createElement('div', { style: filterLabelStyle }, '加氢时间'), - React.createElement(RangePicker, { - style: { width: '100%' }, - showTime: true, - placeholder: ['开始时间', '结束时间'], - value: timeRange, - onChange: function (v) { setTimeRange(v || []); } - }) - ), - React.createElement('div', { style: filterItemStyle }, - React.createElement('div', { style: filterLabelStyle }, '车牌号'), - React.createElement(Input, { - placeholder: '请输入车牌号', - value: plateNo, - onChange: function (e) { setPlateNo(e.target.value); } - }) - ) - ), - React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 } }, - React.createElement(Button, { onClick: handleReset }, '重置'), - React.createElement(Button, { type: 'primary' }, '查询') - ) - ), - React.createElement(Card, { title: '加氢订单列表', style: cardStyle }, - React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, - React.createElement(Button, { onClick: handleExport }, '导出') - ), - React.createElement(Table, { - rowKey: 'id', - columns: columns, - dataSource: filteredData, - pagination: { pageSize: 10, showSizeChanger: true, showTotal: function (t) { return '共 ' + t + ' 条'; } }, - bordered: true, - size: 'middle', - scroll: { x: 700 } - }) - ), - React.createElement(Modal, { - title: '需求说明', - open: requirementModalVisible[0], - onCancel: function () { requirementModalVisible[1](false); }, - width: 560, - footer: React.createElement(Button, { onClick: function () { requirementModalVisible[1](false); } }, '关闭'), - bodyStyle: { maxHeight: '70vh', overflow: 'auto' } - }, React.createElement('div', { style: { padding: '8px 0', whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.6 } }, requirementContent)) - ); -}; - -export default Component; diff --git a/src/prototypes/oneos-web-h2-station/pages/02-加氢记录.jsx b/src/prototypes/oneos-web-h2-station/pages/02-加氢记录.jsx index 5686782..1c32746 100644 --- a/src/prototypes/oneos-web-h2-station/pages/02-加氢记录.jsx +++ b/src/prototypes/oneos-web-h2-station/pages/02-加氢记录.jsx @@ -1,7 +1,10 @@ // 【重要】必须使用 const Component 作为组件变量名 // 加氢站管理 - 加氢记录(与 H5 加氢订单同 Store/字段;PC 另含导出 + 预约加氢) +import React from 'react'; +import { TablePagination, COMPACT_PAGE_SIZE_OPTIONS } from '../../../common/TablePagination'; import { OperationActions } from '../../../common/OperationActions'; +import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField'; function hrSvgIcon(paths, size) { return React.createElement('svg', { @@ -93,8 +96,8 @@ var HR_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-station-page .lc-filter-field-control .ant-select-single .ant-select-selector .ant-select-selection-item, .h2-station-page .lc-filter-field-control .ant-select-single .ant-select-selector .ant-select-selection-placeholder { line-height: 30px !important; }', '.h2-station-page .lc-filter-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9; }', '.h2-station-page .lc-table-section { margin-bottom: 0; flex: 1; display: flex; flex-direction: column; min-height: 0; }', - '.h2-station-page .lc-table-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 8px; min-height: 32px; }', - '.h2-station-page .lc-table-toolbar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }', + '.h2-station-page .lc-table-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 8px; min-height: 32px; overflow: visible; }', + '.h2-station-page .lc-table-toolbar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; overflow: visible; padding-top: 4px; }', '.h2-station-page .lc-table-toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-left: auto; }', '.h2-station-page .lc-table-card { background: #fff; border-radius: 16px; border: 1px solid #e2e8f0; box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03); overflow: hidden; flex: 1; overflow-y: auto; display: flex; flex-direction: column; }', '.h2-station-page .lc-list-table .ant-table-wrapper, .h2-station-page .lc-list-table .ant-table { width: 100% !important; }', @@ -105,15 +108,30 @@ var HR_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-station-page .lc-table-card .ant-pagination { margin: 0 !important; padding: 12px 16px !important; border-top: 1px solid #f1f5f9; }', '.h2-station-page .lc-action-btn { font-weight: 600 !important; color: #10b981 !important; padding: 0 !important; }', '.h2-station-page .lc-action-btn-danger { color: #ef4444 !important; }', - '.h2-station-page .h2-row-actions { display: inline-flex; align-items: center; gap: 4px; }', - '.h2-station-page .hr-refuel-plate { font-weight: 700; color: #0f172a; font-variant-numeric: tabular-nums; }', - '.h2-station-page .hr-refuel-kg { font-weight: 700; color: #059669; font-variant-numeric: tabular-nums; }', - '.h2-station-page .hr-refuel-money { font-weight: 600; color: #4f46e5; font-variant-numeric: tabular-nums; }', - '.h2-station-page .hr-refuel-km { font-variant-numeric: tabular-nums; color: #334155; }', - '.h2-station-page .hr-refuel-time { font-variant-numeric: tabular-nums; color: #334155; font-size: 13px; white-space: nowrap; }', + '/* 数字字体:对齐租赁业务明细「项目盈亏情况」tabular-nums + JetBrains Mono */', + '.h2-station-page .hr-num, .h2-station-page .hr-refuel-plate, .h2-station-page .hr-refuel-kg, .h2-station-page .hr-refuel-money, .h2-station-page .hr-refuel-time, .h2-station-page .hr-daily-stat-card__value, .h2-station-page .hr-daily-stats-head__meta, .h2-refuel-drill-modal .h2-refuel-drill-stat__value, .h2-refuel-drill-modal .h2-refuel-drill-kg, .h2-refuel-drill-modal .h2-refuel-drill-time, .h2-refuel-drill-modal .h2-refuel-drill-plate, .h2-refuel-drill-modal .h2-refuel-drill-contact, .h2-refuel-drill-modal .h2-refuel-drill-table-head__count, .h2-refuel-drill-modal .h2-refuel-drill-station-card__meta, .h2-station-page .hr-appointment-contact, .h2-station-page .hr-appointment-badge-wrap .ant-badge-count { 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; }', + '.h2-station-page .hr-refuel-plate { font-weight: 700; color: #0f172a; 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; }', + '.h2-station-page .hr-refuel-kg { font-weight: 700; color: #059669; 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; }', + '.h2-station-page .hr-refuel-money { font-weight: 600; color: #4f46e5; 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; }', + '.h2-station-page .hr-refuel-km { 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; color: #334155; }', + '.h2-station-page .hr-refuel-time { 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; color: #334155; font-size: 13px; white-space: nowrap; }', '.h2-station-page .hr-appointment-driver { font-weight: 600; color: #0f172a; }', - '.h2-station-page .hr-appointment-contact { font-variant-numeric: tabular-nums; color: #475569; }', + '.h2-station-page .hr-appointment-contact { 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; color: #475569; }', '.h2-station-page .hr-appointment-reject-reason { font-size: 12px; color: #94a3b8; margin-top: 4px; max-width: 220px; }', + '.h2-station-page .hr-appointment-badge-wrap { display: inline-flex; position: relative; overflow: visible; }', + '.h2-station-page .hr-appointment-badge-wrap .ant-badge { overflow: visible; }', + '.h2-station-page .hr-appointment-badge-wrap .ant-badge-count { box-shadow: 0 0 0 1px #fff; font-weight: 700; }', + '.h2-station-page .h2-refuel-drill-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }', + '@media (max-width: 860px) { .h2-station-page .h2-refuel-drill-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }', + '@media (max-width: 520px) { .h2-station-page .h2-refuel-drill-stats { grid-template-columns: 1fr; } }', + '.h2-station-page .h2-refuel-drill-stat { display: flex; flex-direction: column; justify-content: center; min-height: 78px; padding: 12px 14px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); min-width: 0; box-sizing: border-box; }', + '.h2-station-page .h2-refuel-drill-stat--kg { background: linear-gradient(180deg, #fff 0%, #f0fdf4 100%); }', + '.h2-station-page .h2-refuel-drill-stat__label { font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 8px; line-height: 1.2; }', + '.h2-station-page .h2-refuel-drill-stat__value { font-size: 20px; font-weight: 800; 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; line-height: 1.2; color: #0f172a; word-break: break-all; }', + '.h2-station-page .h2-refuel-drill-stat__value--count { color: #2563eb; }', + '.h2-station-page .h2-refuel-drill-stat__value--kg { color: #059669; }', + '.h2-station-page .h2-refuel-drill-stat__value--cost { color: #ea580c; }', + '.h2-station-page .h2-refuel-drill-stat__value--customer { color: #4f46e5; }', '.h2-refuel-drill-modal .ant-modal-content { border-radius: 16px !important; overflow: hidden; box-shadow: 0 24px 48px -12px rgba(15, 23, 42, 0.18) !important; }', '.h2-refuel-drill-modal .ant-modal-header { padding: 18px 24px 14px !important; border-bottom: 1px solid #f1f5f9 !important; margin-bottom: 0 !important; }', '.h2-refuel-drill-modal .ant-modal-title { font-size: 17px !important; font-weight: 700 !important; color: #0f172a !important; }', @@ -122,17 +140,14 @@ var HR_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-refuel-drill-modal .h2-refuel-drill-panel { display: flex; flex-direction: column; gap: 14px; }', '.h2-refuel-drill-modal .h2-refuel-drill-station-card { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 16px; background: linear-gradient(135deg, #eff6ff 0%, #ecfdf5 50%, #f8fafc 100%); border: 1px solid #bfdbfe; border-radius: 12px; }', '.h2-refuel-drill-modal .h2-refuel-drill-station-card__name { font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.35; }', - '.h2-refuel-drill-modal .h2-refuel-drill-station-card__meta { font-size: 12px; color: #64748b; font-variant-numeric: tabular-nums; white-space: nowrap; }', + '.h2-refuel-drill-modal .h2-refuel-drill-station-card__meta { font-size: 12px; color: #64748b; 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; white-space: nowrap; }', '.h2-refuel-drill-modal .h2-refuel-drill-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }', '@media (max-width: 860px) { .h2-refuel-drill-modal .h2-refuel-drill-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }', '@media (max-width: 520px) { .h2-refuel-drill-modal .h2-refuel-drill-stats { grid-template-columns: 1fr; } }', '.h2-refuel-drill-modal .h2-refuel-drill-stat { display: flex; flex-direction: column; justify-content: center; min-height: 78px; padding: 12px 14px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); min-width: 0; box-sizing: border-box; }', - '.h2-refuel-drill-modal .h2-refuel-drill-stat--count { border-left: 4px solid #3b82f6; }', - '.h2-refuel-drill-modal .h2-refuel-drill-stat--kg { border-left: 4px solid #10b981; background: linear-gradient(180deg, #fff 0%, #f0fdf4 100%); }', - '.h2-refuel-drill-modal .h2-refuel-drill-stat--cost { border-left: 4px solid #f97316; }', - '.h2-refuel-drill-modal .h2-refuel-drill-stat--customer { border-left: 4px solid #6366f1; }', + '.h2-refuel-drill-modal .h2-refuel-drill-stat--kg { background: linear-gradient(180deg, #fff 0%, #f0fdf4 100%); }', '.h2-refuel-drill-modal .h2-refuel-drill-stat__label { font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 8px; line-height: 1.2; }', - '.h2-refuel-drill-modal .h2-refuel-drill-stat__value { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; color: #0f172a; word-break: break-all; }', + '.h2-refuel-drill-modal .h2-refuel-drill-stat__value { font-size: 20px; font-weight: 800; 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; line-height: 1.2; color: #0f172a; word-break: break-all; }', '.h2-refuel-drill-modal .h2-refuel-drill-stat__value--count { color: #2563eb; }', '.h2-refuel-drill-modal .h2-refuel-drill-stat__value--kg { color: #059669; }', '.h2-refuel-drill-modal .h2-refuel-drill-stat__value--cost { color: #ea580c; }', @@ -141,7 +156,7 @@ var HR_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-refuel-drill-modal .h2-refuel-drill-table-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #f1f5f9; background: #fafbfc; }', '.h2-refuel-drill-modal .h2-refuel-drill-table-head__left { display: flex; align-items: center; gap: 10px; min-width: 0; }', '.h2-refuel-drill-modal .h2-refuel-drill-table-head__title { font-size: 13px; font-weight: 700; color: #334155; }', - '.h2-refuel-drill-modal .h2-refuel-drill-table-head__count { font-size: 12px; color: #64748b; font-variant-numeric: tabular-nums; }', + '.h2-refuel-drill-modal .h2-refuel-drill-table-head__count { font-size: 12px; color: #64748b; 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; }', '.h2-refuel-drill-modal .h2-refuel-record-table { border-radius: 0 !important; }', '.h2-refuel-drill-modal .h2-refuel-record-table .ant-table { background: transparent !important; }', '.h2-refuel-drill-modal .h2-refuel-record-table .ant-table-container { border: none !important; }', @@ -154,32 +169,35 @@ var HR_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-refuel-drill-modal .h2-refuel-record-table .ant-table-tbody > tr:not(.ant-table-measure-row):last-child > td { border-bottom: none !important; }', '.h2-refuel-drill-modal .h2-refuel-record-table .ant-table-tbody > tr:hover > td { background: #eff6ff !important; }', '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-seq { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; border-radius: 6px; background: #f1f5f9; color: #64748b; font-size: 12px; font-weight: 700; }', - '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-time { color: #334155; font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; }', - '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-plate { font-weight: 700; color: #0f172a; font-variant-numeric: tabular-nums; }', - '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-kg { font-weight: 700; color: #059669; font-variant-numeric: tabular-nums; }', - '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-contact { font-variant-numeric: tabular-nums; color: #475569; font-size: 13px; }', + '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-time { color: #334155; 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; font-size: 12px; white-space: nowrap; }', + '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-plate { font-weight: 700; color: #0f172a; 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; }', + '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-kg { font-weight: 700; color: #059669; 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; }', + '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-contact { 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; color: #475569; font-size: 13px; }', '.h2-refuel-drill-modal .h2-refuel-record-table .h2-refuel-drill-driver { font-weight: 600; color: #0f172a; }', '.h2-refuel-drill-modal .h2-refuel-record-table .hr-appointment-reject-reason { font-size: 12px; color: #94a3b8; margin-top: 4px; max-width: 220px; }', '.h2-station-page .hr-daily-stats-section { margin-bottom: 16px; }', '.h2-station-page .hr-daily-stats-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }', '.h2-station-page .hr-daily-stats-head__title { font-size: 15px; font-weight: 700; color: #0f172a; }', - '.h2-station-page .hr-daily-stats-head__meta { font-size: 12px; color: #64748b; font-variant-numeric: tabular-nums; }', + '.h2-station-page .hr-daily-stats-head__meta { font-size: 12px; color: #64748b; 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; }', '.h2-station-page .hr-daily-stats-carousel { display: flex; align-items: stretch; gap: 8px; }', - '.h2-station-page .hr-daily-stats-nav-btn { flex: 0 0 36px; width: 36px !important; min-width: 36px !important; height: auto !important; min-height: 88px !important; padding: 0 !important; border-radius: 10px !important; font-size: 20px !important; font-weight: 700 !important; color: #475569 !important; border: 1px solid #e2e8f0 !important; background: #fff !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }', + '.h2-station-page .hr-daily-stats-nav-btn { flex: 0 0 36px; width: 36px !important; min-width: 36px !important; height: auto !important; min-height: 118px !important; padding: 0 !important; border-radius: 10px !important; font-size: 20px !important; font-weight: 700 !important; color: #475569 !important; border: 1px solid #e2e8f0 !important; background: #fff !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }', '.h2-station-page .hr-daily-stats-nav-btn:hover:not(:disabled) { color: #059669 !important; border-color: #10b981 !important; background: #f0fdf4 !important; }', '.h2-station-page .hr-daily-stats-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }', '.h2-station-page .hr-daily-stats-track { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; }', - '.h2-station-page .hr-daily-stat-card { display: flex; flex-direction: column; gap: 8px; min-height: 88px; padding: 12px 10px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); box-sizing: border-box; min-width: 0; }', + '.h2-station-page .hr-daily-stat-card { display: flex; flex-direction: column; gap: 8px; min-height: 118px; padding: 12px 10px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); box-sizing: border-box; min-width: 0; }', '.h2-station-page .hr-daily-stat-card--today { border-color: #86efac; background: linear-gradient(180deg, #fff 0%, #f0fdf4 100%); box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12); }', - '.h2-station-page .hr-daily-stat-card__date { font-size: 12px; font-weight: 700; color: #0f172a; font-variant-numeric: tabular-nums; padding-bottom: 8px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 4px; }', + '.h2-station-page .hr-daily-stat-card__date { font-size: 12px; font-weight: 700; color: #0f172a; 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; padding-bottom: 8px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 4px; }', '.h2-station-page .hr-daily-stat-card__today-tag { font-size: 10px; font-weight: 700; color: #059669; background: #ecfdf5; padding: 1px 6px; border-radius: 999px; flex-shrink: 0; }', - '.h2-station-page .hr-daily-stat-card__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }', - '.h2-station-page .hr-daily-stat-card__label { color: #64748b; font-weight: 600; white-space: nowrap; }', - '.h2-station-page .hr-daily-stat-card__value { font-weight: 800; font-variant-numeric: tabular-nums; }', + '.h2-station-page .hr-daily-stat-card__metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; }', + '.h2-station-page .hr-daily-stat-card__metric { display: flex; flex-direction: column; gap: 2px; min-width: 0; }', + '.h2-station-page .hr-daily-stat-card__label { color: #64748b; font-weight: 600; font-size: 11px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }', + '.h2-station-page .hr-daily-stat-card__value { font-weight: 800; 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; font-size: 13px; line-height: 1.2; }', '.h2-station-page .hr-daily-stat-card__value--count { color: #2563eb; }', + '.h2-station-page .hr-daily-stat-card__value--reconciled { color: #0f766e; }', '.h2-station-page .hr-daily-stat-card__value--kg { color: #059669; }', + '.h2-station-page .hr-daily-stat-card__value--amount { color: #d97706; }', '.h2-station-page--create .hr-batch-create-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }', - '.h2-station-page--create .hr-batch-create-toolbar__count { font-size: 13px; color: #64748b; font-variant-numeric: tabular-nums; }', + '.h2-station-page--create .hr-batch-create-toolbar__count { font-size: 13px; color: #64748b; 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; }', '.h2-station-page--create .hr-batch-create-table-wrap { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); }', '.h2-station-page--create .hr-batch-create-table .ant-table-thead > tr > th { background: #f8fafc !important; font-size: 12px !important; font-weight: 700 !important; color: #475569 !important; padding: 10px 12px !important; }', '.h2-station-page--create .hr-batch-create-table .ant-table-tbody > tr > td { padding: 8px 10px !important; vertical-align: middle !important; }', @@ -187,7 +205,7 @@ var HR_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-station-page--create .hr-batch-create-seq { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; border-radius: 6px; background: #f1f5f9; color: #64748b; font-size: 12px; font-weight: 700; }' ]).join('\n'); -var HR_DAILY_STATS_WINDOW = 28; +var HR_DAILY_STATS_WINDOW = 30; var HR_DAILY_STATS_PAGE_SIZE = 7; var HR_DAILY_STATS_MAX_PAGE = Math.ceil(HR_DAILY_STATS_WINDOW / HR_DAILY_STATS_PAGE_SIZE) - 1; @@ -224,11 +242,11 @@ var HR_APPOINTMENT_STATUS_MAP = { }; var HR_INITIAL_APPOINTMENTS = [ - { id: 'ap-1', plateNo: '浙A12345F', appointmentTime: '2026-06-10 09:00:00', appointmentKg: 15.0, driverName: '刘建国', contact: '13800138011', status: 'pending' }, - { id: 'ap-2', plateNo: '浙B23456F', appointmentTime: '2026-06-10 11:30:00', appointmentKg: 12.5, driverName: '陈师傅', contact: '13900139022', status: 'pending' }, - { id: 'ap-3', plateNo: '浙A67890F', appointmentTime: '2026-06-09 14:00:00', appointmentKg: 10.0, driverName: '王磊', contact: '13700137033', status: 'pending' }, - { id: 'ap-4', plateNo: '沪A88888F', appointmentTime: '2026-06-08 16:20:00', appointmentKg: 8.5, driverName: '张强', contact: '13600136044', status: 'accepted', handledTime: '2026-06-08 10:15:00' }, - { id: 'ap-5', plateNo: '浙B99999F', appointmentTime: '2026-06-07 08:45:00', appointmentKg: 18.0, driverName: '赵明', contact: '13500135055', status: 'rejected', rejectReason: '预约时段站点设备检修,请改约其他时间', handledTime: '2026-06-06 17:30:00' } + { id: 'ap-1', stationName: '嘉兴加氢站(一期)', stationCode: 'JX-H2-001', plateNo: '浙A12345F', appointmentTime: '2026-06-10 09:00:00', appointmentKg: 15.0, driverName: '刘建国', contact: '13800138011', status: 'pending' }, + { id: 'ap-2', stationName: '杭州临平加氢站', stationCode: 'HZ-H2-002', plateNo: '浙B23456F', appointmentTime: '2026-06-10 11:30:00', appointmentKg: 12.5, driverName: '陈师傅', contact: '13900139022', status: 'pending' }, + { id: 'ap-3', stationName: '嘉兴加氢站(一期)', stationCode: 'JX-H2-001', plateNo: '浙A67890F', appointmentTime: '2026-06-09 14:00:00', appointmentKg: 10.0, driverName: '王磊', contact: '13700137033', status: 'pending' }, + { id: 'ap-4', stationName: '上海宝山加氢站', stationCode: 'SH-H2-003', plateNo: '沪A88888F', appointmentTime: '2026-06-08 16:20:00', appointmentKg: 8.5, driverName: '张强', contact: '13600136044', status: 'accepted', handledTime: '2026-06-08 10:15:00' }, + { id: 'ap-5', stationName: '杭州临平加氢站', stationCode: 'HZ-H2-002', plateNo: '浙B99999F', appointmentTime: '2026-06-07 08:45:00', appointmentKg: 18.0, driverName: '赵明', contact: '13500135055', status: 'rejected', rejectReason: '预约时段站点设备检修,请改约其他时间', handledTime: '2026-06-06 17:30:00' } ]; function hrFormatKg(v) { @@ -286,10 +304,17 @@ function hrBuildRecentDailyWindow(list, dayjs, windowDays) { (list || []).forEach(function (r) { var day = hrExtractDateKey(r.hydrogenTime); if (!day) return; - if (!statsMap[day]) statsMap[day] = { count: 0, totalKg: 0 }; + if (!statsMap[day]) { + statsMap[day] = { count: 0, reconciledCount: 0, totalKg: 0, totalAmount: 0 }; + } statsMap[day].count += 1; + if (r.reconcileStatus === 'reconciled' || Boolean(r.reconcileTime)) { + statsMap[day].reconciledCount += 1; + } var kg = parseFloat(r.hydrogenKg); if (!isNaN(kg)) statsMap[day].totalKg += kg; + var amt = parseFloat(r.totalAmount != null ? r.totalAmount : r.customerAmount); + if (!isNaN(amt)) statsMap[day].totalAmount += amt; }); var days = []; @@ -314,27 +339,22 @@ function hrBuildRecentDailyWindow(list, dayjs, windowDays) { dateLabel: shortLabel, isToday: i === 0, count: stat ? stat.count : 0, - totalKg: stat ? Math.round(stat.totalKg * 100) / 100 : 0 + reconciledCount: stat ? stat.reconciledCount : 0, + totalKg: stat ? Math.round(stat.totalKg * 100) / 100 : 0, + totalAmount: stat ? Math.round(stat.totalAmount * 100) / 100 : 0 }); } return days; } -function hrInTimeRange(timeStr, range, dayjs) { - if (!range || range.length < 2 || !range[0] || !range[1]) return true; +function hrInDateRange(timeStr, startDate, endDate) { + if (!startDate && !endDate) return true; if (!timeStr) return false; - if (dayjs) { - var t = dayjs(timeStr); - var start = dayjs(range[0]); - var end = dayjs(range[1]); - if (!t.isValid() || !start.isValid() || !end.isValid()) return true; - return (t.isAfter(start) || t.isSame(start)) && (t.isBefore(end) || t.isSame(end)); - } - var tKey = String(timeStr).replace(/[-:\s]/g, ''); - var sKey = range[0].format ? range[0].format('YYYYMMDDHHmmss') : ''; - var eKey = range[1].format ? range[1].format('YYYYMMDDHHmmss') : ''; - if (!sKey || !eKey) return true; - return tKey >= sKey && tKey <= eKey; + var dateKey = String(timeStr).slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) return true; + if (startDate && dateKey < startDate) return false; + if (endDate && dateKey > endDate) return false; + return true; } function hrEmptyForm() { @@ -343,6 +363,7 @@ function hrEmptyForm() { stationName: '', stationCode: '', plateNo: '', + mileageKm: '', unitPrice: '', hydrogenKg: '', totalAmount: '' @@ -365,6 +386,7 @@ function hrEmptyBatchRow(defaults) { stationName: defaults.stationName || '', stationCode: defaults.stationCode || '', plateNo: '', + mileageKm: '', unitPrice: '', hydrogenKg: '', totalAmount: '' @@ -376,6 +398,7 @@ function hrIsBatchRowEmpty(row) { if ((row.hydrogenTime || '').trim()) return false; if ((row.stationName || '').trim()) return false; if ((row.plateNo || '').trim()) return false; + if ((row.mileageKm || '').trim()) return false; if ((row.unitPrice || '').trim()) return false; if ((row.hydrogenKg || '').trim()) return false; if ((row.totalAmount || '').trim()) return false; @@ -395,6 +418,11 @@ function hrValidateBatchRow(row, rowIndex) { if (!(row.hydrogenTime || '').trim()) return label + ':请填写加氢时间'; if (!(row.stationName || '').trim()) return label + ':请选择加氢站'; if (!(row.plateNo || '').trim()) return label + ':请填写车牌号'; + var mileageRaw = (row.mileageKm || '').trim(); + if (mileageRaw) { + var mileage = parseFloat(mileageRaw); + if (isNaN(mileage) || mileage < 0) return label + ':里程须为不小于 0 的数字'; + } var price = parseFloat(row.unitPrice); if (isNaN(price) || price < 0) return label + ':请填写有效的氢气单价'; var kg = parseFloat(row.hydrogenKg); @@ -404,6 +432,20 @@ function hrValidateBatchRow(row, rowIndex) { return null; } +function hrParseOptionalMileage(v) { + if (v == null || v === '') return null; + var n = typeof v === 'number' ? v : parseFloat(String(v).trim()); + if (isNaN(n) || n < 0) return null; + return Math.round(n); +} + +function hrFormatMileage(v) { + if (v == null || v === '') return '—'; + var n = typeof v === 'number' ? v : parseFloat(v); + if (isNaN(n)) return '—'; + return Math.round(n).toLocaleString('zh-CN'); +} + function hrBatchRowToPayload(row) { var plate = (row.plateNo || '').trim().toUpperCase(); if (plate && !/F$/u.test(plate)) plate += 'F'; @@ -415,6 +457,7 @@ function hrBatchRowToPayload(row) { stationName: (row.stationName || '').trim(), stationCode: (row.stationCode || '').trim(), plateNo: plate, + mileageKm: hrParseOptionalMileage(row.mileageKm), unitPrice: unitPrice, hydrogenKg: hydrogenKg, totalAmount: totalAmount @@ -445,9 +488,10 @@ function hrUpsertBridgeRow(payload) { customerUnitPrice: unitPrice, customerAmount: totalAmount, settlementStatus: 'customer', - mileageKm: payload.mileageKm != null ? payload.mileageKm : 0, + mileageKm: payload.mileageKm != null ? payload.mileageKm : null, creatorName: payload.fillerName || '站端账号', - reconcileStatus: payload.reconcileStatus || 'pending' + reconcileStatus: payload.reconcileStatus || 'pending', + verifyStatus: payload.verifyStatus || 'unverified' }); } @@ -458,7 +502,16 @@ function hrRemoveBridgeRow(id) { } function hrIsReconciled(record) { - return record && (record.reconcileStatus === 'reconciled' || Boolean(record.reconcileTime)); + return record && (record.reconcileStatus === 'reconciled' || Boolean(record.reconcileDate || record.reconcileTime)); +} + +function hrIsVerified(record) { + return record && (record.verifyStatus === 'verified' || Boolean(record.verifiedAt)); +} + +/** 已核对或已对账:站端不可改删 */ +function hrIsLocked(record) { + return hrIsVerified(record) || hrIsReconciled(record); } function hrFormatReconcileTime(v) { @@ -478,6 +531,158 @@ function hrRecalcTotalAmount(unitPrice, hydrogenKg) { return String(Math.round(p * k * 100) / 100); } +function hrFindDuplicateRow(candidate, excludeId) { + var bridge = hrGetVehicleLedgerBridge(); + if (bridge && typeof bridge.findDuplicateRow === 'function') { + return bridge.findDuplicateRow(candidate, excludeId); + } + return null; +} + +function hrTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount) { + var bridge = hrGetVehicleLedgerBridge(); + if (bridge && typeof bridge.totalAmountMismatch === 'function') { + return bridge.totalAmountMismatch(unitPrice, hydrogenKg, totalAmount, 0.05); + } + var p = Number(unitPrice); + var k = Number(hydrogenKg); + var a = Number(totalAmount); + if (!isFinite(p) || !isFinite(k) || !isFinite(a)) return null; + var expected = Math.round(p * k * 100) / 100; + var actual = Math.round(a * 100) / 100; + var diff = Math.round(Math.abs(actual - expected) * 100) / 100; + if (diff <= 0.05) return null; + return { expected: expected, actual: actual, diff: diff }; +} + +function hrLookupStationUnitPrice(stationName) { + var bridge = hrGetVehicleLedgerBridge(); + if (bridge && typeof bridge.lookupStationUnitPrice === 'function') { + return bridge.lookupStationUnitPrice(stationName); + } + return 42.5; +} + +function hrTodayDateKey(dayjsLib) { + if (dayjsLib) return dayjsLib().format('YYYY-MM-DD'); + var d = new Date(); + var pad = function (n) { return n < 10 ? '0' + n : String(n); }; + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); +} + +function hrHasManualLedgerForDate(stationIdOrName, ledgerDate) { + var bridge = hrGetVehicleLedgerBridge(); + if (!bridge || typeof bridge.hasManualLedgerForDate !== 'function') return false; + return Boolean(bridge.hasManualLedgerForDate(stationIdOrName, ledgerDate)); +} + +function hrGetManualLedger(stationIdOrName, ledgerDate) { + var bridge = hrGetVehicleLedgerBridge(); + if (!bridge || typeof bridge.getManualLedger !== 'function') return null; + return bridge.getManualLedger(stationIdOrName, ledgerDate) || null; +} + +function hrListManualLedgers(stationIdOrName) { + var bridge = hrGetVehicleLedgerBridge(); + if (!bridge || typeof bridge.listManualLedgersByStation !== 'function') return []; + return bridge.listManualLedgersByStation(stationIdOrName) || []; +} + +function hrUpsertManualLedger(input) { + var bridge = hrGetVehicleLedgerBridge(); + if (!bridge || typeof bridge.upsertManualLedger !== 'function') return null; + return bridge.upsertManualLedger(input); +} + +function hrReadFileAsDataUrl(file) { + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function () { resolve(String(reader.result || '')); }; + reader.onerror = function () { reject(reader.error); }; + reader.readAsDataURL(file); + }); +} + +function hrPadDatePart(n) { + return n < 10 ? '0' + n : String(n); +} + +function hrCellNeedsLedger(dateKey, todayKey, firstHydrogenDateKey) { + if (dateKey > todayKey) return false; + if (!firstHydrogenDateKey) return false; + return dateKey >= firstHydrogenDateKey; +} + +function hrGetFirstHydrogenDateKey(stationIdOrName) { + var key = String(stationIdOrName || '').trim(); + if (!key) return null; + var bridge = hrGetVehicleLedgerBridge(); + var rows = bridge && typeof bridge.getRows === 'function' ? (bridge.getRows() || []) : []; + var earliest = null; + rows.forEach(function (row) { + var sid = String(row.stationId || '').trim(); + var sn = String(row.stationName || '').trim(); + if (sid !== key && sn !== key) return; + var m = String(row.hydrogenTime || '').match(/^(\d{4}-\d{2}-\d{2})/); + if (!m) return; + if (!earliest || m[1] < earliest) earliest = m[1]; + }); + return earliest; +} + +function hrBuildManualMonthCells(year, month1to12, uploadedMap, todayKey, firstHydrogenDateKey) { + var first = new Date(year, month1to12 - 1, 1); + var startWeekday = first.getDay(); + var daysInMonth = new Date(year, month1to12, 0).getDate(); + var prevDays = new Date(year, month1to12 - 1, 0).getDate(); + var cells = []; + var i; + for (i = 0; i < 42; i++) { + var y = year; + var m = month1to12; + var d; + var inMonth = true; + if (i < startWeekday) { + d = prevDays - startWeekday + i + 1; + m = month1to12 - 1; + if (m < 1) { m = 12; y -= 1; } + inMonth = false; + } else if (i >= startWeekday + daysInMonth) { + d = i - startWeekday - daysInMonth + 1; + m = month1to12 + 1; + if (m > 12) { m = 1; y += 1; } + inMonth = false; + } else { + d = i - startWeekday + 1; + } + var dateKey = y + '-' + hrPadDatePart(m) + '-' + hrPadDatePart(d); + var needsLedger = hrCellNeedsLedger(dateKey, todayKey, firstHydrogenDateKey || null); + cells.push({ + dateKey: dateKey, + day: d, + inMonth: inMonth, + isToday: dateKey === todayKey, + isFuture: dateKey > todayKey, + needsLedger: needsLedger, + uploaded: Boolean(uploadedMap[dateKey]) + }); + } + return cells; +} + +function hrCountMissingInMonth(year, month1to12, uploadedMap, todayKey, firstHydrogenDateKey) { + var daysInMonth = new Date(year, month1to12, 0).getDate(); + var n = 0; + var d; + for (d = 1; d <= daysInMonth; d++) { + var key = year + '-' + hrPadDatePart(month1to12) + '-' + hrPadDatePart(d); + if (key > todayKey) break; + if (!hrCellNeedsLedger(key, todayKey, firstHydrogenDateKey || null)) continue; + if (!uploadedMap[key]) n += 1; + } + return n; +} + function hrCardTitleWithStep(step, text) { return React.createElement('span', { className: 'h2-card-title-bar h2-card-title-bar--step' }, React.createElement('span', { className: 'h2-card-step-badge', 'aria-hidden': true }, step), @@ -489,6 +694,7 @@ function hrFormDirty(form) { if ((form.hydrogenTime || '').trim()) return true; if ((form.stationName || '').trim()) return true; if ((form.plateNo || '').trim()) return true; + if ((form.mileageKm || '').trim()) return true; if ((form.unitPrice || '').trim()) return true; if ((form.hydrogenKg || '').trim()) return true; if ((form.totalAmount || '').trim()) return true; @@ -514,15 +720,45 @@ const Component = function () { var Col = antd.Col; var Tag = antd.Tag; var Badge = antd.Badge; + var Upload = antd.Upload; + var Image = antd.Image; var message = antd.message; var dayjs = window.dayjs; - var RangePicker = DatePicker.RangePicker; + var emptyFilters = { startDate: '', endDate: '', plateNo: undefined, stationName: undefined, reconcileStatus: undefined }; var subViewState = useState('list'); var subView = subViewState[0]; var setSubView = subViewState[1]; + var listMainTabState = useState('records'); + var listMainTab = listMainTabState[0]; + var setListMainTab = listMainTabState[1]; + + var manualTickState = useState(0); + var manualTick = manualTickState[0]; + var setManualTick = manualTickState[1]; + + var manualStationState = useState(''); + var manualStation = manualStationState[0]; + var setManualStation = manualStationState[1]; + + var manualDraftImagesState = useState([]); + var manualDraftImages = manualDraftImagesState[0]; + var setManualDraftImages = manualDraftImagesState[1]; + + var manualSelectedDateState = useState(function () { return hrTodayDateKey(window.dayjs); }); + var manualSelectedDate = manualSelectedDateState[0]; + var setManualSelectedDate = manualSelectedDateState[1]; + + var manualCursorYmState = useState(function () { + var key = hrTodayDateKey(window.dayjs); + var parts = String(key).split('-'); + return { y: Number(parts[0]), m: Number(parts[1]) }; + }); + var manualCursorYm = manualCursorYmState[0]; + var setManualCursorYm = manualCursorYmState[1]; + var formModeState = useState('create'); var formMode = formModeState[0]; var setFormMode = formModeState[1]; @@ -545,18 +781,18 @@ const Component = function () { }); }, []); - var draftFiltersState = useState({ timeRange: [], plateNo: undefined, stationName: undefined, reconcileStatus: undefined }); + var draftFiltersState = useState(function () { return Object.assign({}, emptyFilters); }); var draftFilters = draftFiltersState[0]; var setDraftFilters = draftFiltersState[1]; - var appliedFiltersState = useState({ timeRange: [], plateNo: undefined, stationName: undefined, reconcileStatus: undefined }); + var appliedFiltersState = useState(function () { return Object.assign({}, emptyFilters); }); var appliedFilters = appliedFiltersState[0]; var setAppliedFilters = appliedFiltersState[1]; var pageState = useState(1); var page = pageState[0]; var setPage = pageState[1]; - var pageSizeState = useState(10); + var pageSizeState = useState(20); var pageSize = pageSizeState[0]; var setPageSize = pageSizeState[1]; @@ -576,10 +812,6 @@ const Component = function () { var deleteModal = deleteModalState[0]; var setDeleteModal = deleteModalState[1]; - var appointmentModalOpenState = useState(false); - var appointmentModalOpen = appointmentModalOpenState[0]; - var setAppointmentModalOpen = appointmentModalOpenState[1]; - var appointmentDataState = useState(function () { return HR_INITIAL_APPOINTMENTS.map(function (r) { return Object.assign({}, r); }); }); @@ -589,7 +821,7 @@ const Component = function () { var appointmentPageState = useState(1); var appointmentPage = appointmentPageState[0]; var setAppointmentPage = appointmentPageState[1]; - var appointmentPageSizeState = useState(10); + var appointmentPageSizeState = useState(20); var appointmentPageSize = appointmentPageSizeState[0]; var setAppointmentPageSize = appointmentPageSizeState[1]; @@ -627,6 +859,149 @@ const Component = function () { }); }, [listData]); + React.useEffect(function () { + if (manualStation) return; + if (stationOptions[0] && stationOptions[0].value) { + setManualStation(stationOptions[0].value); + } + }, [stationOptions, manualStation]); + + var manualTodayKey = hrTodayDateKey(dayjs); + var activeManualStation = manualStation || (stationOptions[0] && stationOptions[0].value) || ''; + var manualTodayUploaded = useMemo(function () { + return activeManualStation + ? hrHasManualLedgerForDate(activeManualStation, manualTodayKey) + : false; + }, [activeManualStation, manualTodayKey, manualTick]); + + var manualAllEntries = useMemo(function () { + return hrListManualLedgers(activeManualStation); + }, [activeManualStation, manualTick]); + + var manualUploadedMap = useMemo(function () { + var map = {}; + manualAllEntries.forEach(function (item) { + if (item.images && item.images.length) map[item.ledgerDate] = true; + }); + return map; + }, [manualAllEntries]); + + var manualFirstHydrogenDate = useMemo(function () { + if (!activeManualStation) return null; + var found = stationOptions.find(function (o) { return o.value === activeManualStation; }); + return hrGetFirstHydrogenDateKey(activeManualStation) + || (found && found.stationCode ? hrGetFirstHydrogenDateKey(found.stationCode) : null); + }, [activeManualStation, manualTick, stationOptions]); + + var manualMonthCells = useMemo(function () { + return hrBuildManualMonthCells( + manualCursorYm.y, + manualCursorYm.m, + manualUploadedMap, + manualTodayKey, + manualFirstHydrogenDate + ); + }, [manualCursorYm.y, manualCursorYm.m, manualUploadedMap, manualTodayKey, manualFirstHydrogenDate]); + + var manualMissingCount = useMemo(function () { + return hrCountMissingInMonth( + manualCursorYm.y, + manualCursorYm.m, + manualUploadedMap, + manualTodayKey, + manualFirstHydrogenDate + ); + }, [manualCursorYm.y, manualCursorYm.m, manualUploadedMap, manualTodayKey, manualFirstHydrogenDate]); + + var manualSelectedEntry = useMemo(function () { + return activeManualStation + ? hrGetManualLedger(activeManualStation, manualSelectedDate) + : null; + }, [activeManualStation, manualSelectedDate, manualTick]); + + var manualSelectedUploaded = Boolean(manualSelectedEntry && manualSelectedEntry.images && manualSelectedEntry.images.length); + + React.useEffect(function () { + if (manualSelectedEntry && manualSelectedEntry.images && manualSelectedEntry.images.length) { + setManualDraftImages(manualSelectedEntry.images.map(function (img) { + return Object.assign({}, img, { archived: true }); + })); + } else { + setManualDraftImages([]); + } + }, [manualSelectedEntry, manualSelectedDate, manualTick]); + + var handleManualUploadFiles = useCallback(function (fileList) { + if (manualSelectedDate > hrTodayDateKey(dayjs)) { + message.warning('不能上传未来日期'); + return false; + } + var jobs = (fileList || []).map(function (file) { + return hrReadFileAsDataUrl(file).then(function (dataUrl) { + return { + id: 'ml-img-' + Date.now() + '-' + Math.random().toString(16).slice(2), + name: file.name || '台账照片.jpg', + dataUrl: dataUrl, + archived: false, + uploadedAt: dayjs ? dayjs().format('YYYY-MM-DD HH:mm:ss') : manualTodayKey + ' 12:00:00' + }; + }); + }); + Promise.all(jobs).then(function (images) { + setManualDraftImages(function (prev) { return prev.concat(images); }); + }).catch(function () { + message.error('读取图片失败'); + }); + return false; + }, [dayjs, manualTodayKey, manualSelectedDate, message]); + + var handleManualSave = useCallback(function () { + if (!activeManualStation) { + message.warning('请选择加氢站'); + return; + } + if (manualSelectedDate > hrTodayDateKey(dayjs)) { + message.warning('不能上传未来日期'); + return; + } + var pendingNew = manualDraftImages.filter(function (img) { + return !(img.archived || img.placeholder); + }); + if (manualSelectedUploaded && !pendingNew.length) { + message.warning('请先添加补传照片'); + return; + } + if (!manualDraftImages.length) { + message.warning('请至少上传一张手工台账照片'); + return; + } + var found = stationOptions.find(function (o) { return o.value === activeManualStation; }); + var saved = hrUpsertManualLedger({ + stationId: found && found.stationCode ? found.stationCode : '', + stationName: activeManualStation, + ledgerDate: manualSelectedDate, + images: manualDraftImages + }); + if (!saved) { + message.error('上传失败,请重试'); + return; + } + message.success(manualSelectedUploaded + ? manualSelectedDate + ' 已补传' + : manualSelectedDate + ' 手工台账已上传'); + setManualTick(function (n) { return n + 1; }); + }, [activeManualStation, manualDraftImages, manualSelectedDate, manualSelectedUploaded, dayjs, message, stationOptions]); + + var shiftManualMonth = useCallback(function (delta) { + setManualCursorYm(function (prev) { + var m = prev.m + delta; + var y = prev.y; + if (m < 1) { m = 12; y -= 1; } + else if (m > 12) { m = 1; y += 1; } + return { y: y, m: m }; + }); + }, []); + var pendingAppointmentCount = useMemo(function () { var count = 0; var i; @@ -673,20 +1048,49 @@ const Component = function () { return appliedFilters.reconcileStatus === 'reconciled' ? done : !done; }); } - if (appliedFilters.timeRange && appliedFilters.timeRange.length === 2) { + if (appliedFilters.startDate || appliedFilters.endDate) { list = list.filter(function (r) { - return hrInTimeRange(r.hydrogenTime, appliedFilters.timeRange, dayjs); + return hrInDateRange(r.hydrogenTime, appliedFilters.startDate, appliedFilters.endDate); }); } return list.sort(function (a, b) { return String(b.hydrogenTime || '').localeCompare(String(a.hydrogenTime || '')); }); - }, [listData, appliedFilters, dayjs]); + }, [listData, appliedFilters]); + + var pagedList = useMemo(function () { + var totalPages = Math.max(1, Math.ceil(filteredList.length / pageSize) || 1); + var safePage = Math.min(page, totalPages); + var start = (safePage - 1) * pageSize; + return filteredList.slice(start, start + pageSize); + }, [filteredList, page, pageSize]); + + var listSafePage = useMemo(function () { + var totalPages = Math.max(1, Math.ceil(filteredList.length / pageSize) || 1); + return Math.min(page, totalPages); + }, [filteredList.length, page, pageSize]); var dailyStatsWindow = useMemo(function () { return hrBuildRecentDailyWindow(filteredList, dayjs, HR_DAILY_STATS_WINDOW); }, [filteredList, dayjs]); + var dailyStatsSummary = useMemo(function () { + var totalKg = 0; + var i; + for (i = 0; i < dailyStatsWindow.length; i++) { + totalKg += dailyStatsWindow[i].totalKg || 0; + } + var rangeEnd = dailyStatsWindow[0] ? dailyStatsWindow[0].date : '—'; + var rangeStart = dailyStatsWindow.length + ? dailyStatsWindow[dailyStatsWindow.length - 1].date + : '—'; + return { + rangeStart: rangeStart, + rangeEnd: rangeEnd, + totalKg: Math.round(totalKg * 100) / 100 + }; + }, [dailyStatsWindow]); + var dailyStatsPageItems = useMemo(function () { var start = dailyStatsPage * HR_DAILY_STATS_PAGE_SIZE; var chunk = dailyStatsWindow.slice(start, start + HR_DAILY_STATS_PAGE_SIZE); @@ -702,16 +1106,18 @@ const Component = function () { var handleFilterQuery = useCallback(function () { setAppliedFilters({ - timeRange: (draftFilters.timeRange || []).slice(), + startDate: draftFilters.startDate || '', + endDate: draftFilters.endDate || '', plateNo: draftFilters.plateNo, - stationName: draftFilters.stationName, reconcileStatus: draftFilters.reconcileStatus + stationName: draftFilters.stationName, + reconcileStatus: draftFilters.reconcileStatus }); setPage(1); setDailyStatsPage(0); }, [draftFilters]); var handleFilterReset = useCallback(function () { - var empty = { timeRange: [], plateNo: undefined, stationName: undefined, reconcileStatus: undefined }; + var empty = { startDate: '', endDate: '', plateNo: undefined, stationName: undefined, reconcileStatus: undefined }; setDraftFilters(empty); setAppliedFilters(empty); setPage(1); @@ -744,6 +1150,13 @@ const Component = function () { var openCreate = useCallback(function () { var firstStation = stationOptions[0] || {}; + var gateStation = draftFilters.stationName || firstStation.value || ''; + if (gateStation && !hrHasManualLedgerForDate(gateStation, hrTodayDateKey(dayjs))) { + message.warning('请先在「手工台账」上传今日台账后再新增(站点:' + gateStation + ')'); + setListMainTab('manual'); + setManualStation(gateStation); + return; + } var defaults = { stationName: firstStation.value || '', stationCode: firstStation.stationCode || '' @@ -757,7 +1170,7 @@ const Component = function () { setFormMode('create'); setEditingId(null); setSubView('form'); - }, [stationOptions, dayjs]); + }, [stationOptions, dayjs, draftFilters.stationName, message]); var updateBatchRow = useCallback(function (rowId, patch) { setBatchRows(function (prev) { @@ -780,8 +1193,8 @@ const Component = function () { }, []); var openEdit = useCallback(function (record) { - if (hrIsReconciled(record)) { - message.warning('已对账记录不可编辑'); + if (hrIsLocked(record)) { + message.warning(hrIsReconciled(record) ? '已对账记录不可编辑' : '已核对记录不可编辑'); return; } setForm({ @@ -789,6 +1202,7 @@ const Component = function () { stationName: record.stationName || '', stationCode: record.stationCode || '', plateNo: (record.plateNo || '').replace(/F$/u, ''), + mileageKm: record.mileageKm != null && record.mileageKm !== '' ? String(record.mileageKm) : '', unitPrice: record.unitPrice != null ? String(record.unitPrice) : (record.costUnitPrice != null ? String(record.costUnitPrice) : ''), hydrogenKg: record.hydrogenKg != null ? String(record.hydrogenKg) : '', totalAmount: record.totalAmount != null ? String(record.totalAmount) : (record.customerAmount != null ? String(record.customerAmount) : '') @@ -811,6 +1225,14 @@ const Component = function () { message.warning('请填写车牌号'); return false; } + var mileageRaw = (form.mileageKm || '').trim(); + if (mileageRaw) { + var mileage = parseFloat(mileageRaw); + if (isNaN(mileage) || mileage < 0) { + message.warning('里程须为不小于 0 的数字'); + return false; + } + } var price = parseFloat(form.unitPrice); if (isNaN(price) || price < 0) { message.warning('请填写有效的氢气单价'); @@ -832,20 +1254,41 @@ const Component = function () { var handleFormSubmit = useCallback(function () { if (formSubmitting) return; if (!validateForm()) return; - setFormSubmitting(true); var payload = hrBatchRowToPayload(form); payload.id = editingId || undefined; - var saved = hrUpsertBridgeRow(payload); - if (!saved) { - setFormSubmitting(false); - message.error('保存失败:共享账本不可用'); + var dup = hrFindDuplicateRow(payload, editingId || undefined); + if (dup) { + message.error('已存在相同加氢站、车牌号与加氢时间的记录,请勿重复录入'); return; } - message.success(formMode === 'edit' ? '加氢记录已更新' : '加氢记录已新增'); - window.setTimeout(function () { - setFormSubmitting(false); - resetFormView(); - }, 280); + var doSave = function () { + setFormSubmitting(true); + var saved = hrUpsertBridgeRow(payload); + if (!saved) { + setFormSubmitting(false); + message.error('保存失败:共享账本不可用'); + return; + } + message.success(formMode === 'edit' ? '加氢记录已更新' : '加氢记录已新增'); + window.setTimeout(function () { + setFormSubmitting(false); + resetFormView(); + }, 280); + }; + var mismatch = hrTotalAmountMismatch(payload.unitPrice, payload.hydrogenKg, payload.totalAmount); + if (mismatch) { + Modal.confirm({ + title: '加氢总额与单价×量不一致', + content: '单价×量应为 ' + mismatch.expected.toFixed(2) + ' 元,当前总额为 ' + mismatch.actual.toFixed(2) + ' 元(偏差 ' + mismatch.diff.toFixed(2) + ' 元)。仍要按当前总额保存吗?', + okText: '仍要保存', + cancelText: '返回修改', + centered: true, + okButtonProps: { style: HR_PRIMARY_BTN_STYLE }, + onOk: doSave + }); + return; + } + doSave(); }, [form, formMode, editingId, formSubmitting, validateForm, resetFormView]); var handleBatchSubmit = useCallback(function () { @@ -855,7 +1298,22 @@ const Component = function () { message.warning('请至少填写一行加氢记录'); return; } + var todayKey = hrTodayDateKey(dayjs); + var missingStations = {}; + filledRows.forEach(function (r) { + var sn = String(r.stationName || '').trim(); + if (sn && !hrHasManualLedgerForDate(sn, todayKey)) missingStations[sn] = true; + }); + var missingList = Object.keys(missingStations); + if (missingList.length) { + message.warning('以下站点今日尚未上传手工台账,请先上传后再新增:' + missingList.join('、')); + setListMainTab('manual'); + setManualStation(missingList[0]); + setSubView('list'); + return; + } var i; + var payloads = []; for (i = 0; i < filledRows.length; i++) { var rowIndex = 0; var j; @@ -870,43 +1328,120 @@ const Component = function () { message.warning(err); return; } + var payload = hrBatchRowToPayload(filledRows[i]); + var dup = hrFindDuplicateRow(payload); + if (dup) { + message.error('第 ' + rowIndex + ' 行:已存在相同加氢站、车牌号与加氢时间的记录'); + return; + } + for (j = 0; j < payloads.length; j++) { + if ( + payloads[j].stationName === payload.stationName && + payloads[j].plateNo === payload.plateNo && + payloads[j].hydrogenTime === payload.hydrogenTime + ) { + message.error('第 ' + rowIndex + ' 行与批量内其他行重复(同站同车牌同时间)'); + return; + } + } + payloads.push(payload); } - setFormSubmitting(true); - var okCount = 0; - filledRows.forEach(function (row) { - var payload = hrBatchRowToPayload(row); - if (hrUpsertBridgeRow(payload)) okCount += 1; - }); - if (!okCount) { - setFormSubmitting(false); - message.error('保存失败:共享账本不可用'); + var mismatchRows = []; + for (i = 0; i < payloads.length; i++) { + var m = hrTotalAmountMismatch(payloads[i].unitPrice, payloads[i].hydrogenKg, payloads[i].totalAmount); + if (m) mismatchRows.push({ index: i + 1, mismatch: m }); + } + var doBatchSave = function () { + setFormSubmitting(true); + var okCount = 0; + payloads.forEach(function (row) { + if (hrUpsertBridgeRow(row)) okCount += 1; + }); + if (!okCount) { + setFormSubmitting(false); + message.error('保存失败:共享账本不可用'); + return; + } + message.success('已成功新增 ' + okCount + ' 条加氢记录'); + window.setTimeout(function () { + setFormSubmitting(false); + resetFormView(); + }, 280); + }; + if (mismatchRows.length) { + Modal.confirm({ + title: '部分行加氢总额与单价×量不一致', + content: '共 ' + mismatchRows.length + ' 行总额偏离单价×量超过 0.05 元。仍要按当前总额保存吗?', + okText: '仍要保存', + cancelText: '返回修改', + centered: true, + okButtonProps: { style: HR_PRIMARY_BTN_STYLE }, + onOk: doBatchSave + }); return; } - message.success('已成功新增 ' + okCount + ' 条加氢记录'); - window.setTimeout(function () { - setFormSubmitting(false); - resetFormView(); - }, 280); - }, [batchRows, formSubmitting, resetFormView]); + doBatchSave(); + }, [batchRows, formSubmitting, resetFormView, dayjs]); var handleAcceptAppointment = useCallback(function (record) { if (!record || record.status !== 'pending') return; Modal.confirm({ title: '接受预约', - content: '确认接受车牌「' + (record.plateNo || '') + '」的加氢预约?预约时间:' + (record.appointmentTime || '—'), - okText: '接受预约', + content: '确认接受「' + (record.stationName || '加氢站') + '」车牌「' + (record.plateNo || '') + '」的加氢预约?接受后将自动生成一条未对账加氢记录(时间=预约时间,量=预约量,单价取该站近期单价)。', + okText: '接受并生成记录', cancelText: '取消', centered: true, okButtonProps: { style: HR_PRIMARY_BTN_STYLE }, onOk: function () { + var stationName = (record.stationName || '').trim(); + var stationCode = (record.stationCode || '').trim(); + var hydrogenTime = record.appointmentTime || ''; + var plateNo = record.plateNo || ''; + var hydrogenKg = Math.round(parseFloat(record.appointmentKg) * 100) / 100; + if (!stationName || !hydrogenTime || !plateNo || isNaN(hydrogenKg) || hydrogenKg <= 0) { + message.error('预约信息不完整,无法生成加氢记录'); + return Promise.reject(); + } + var dup = hrFindDuplicateRow({ + stationName: stationName, + plateNo: plateNo, + hydrogenTime: hydrogenTime + }); + if (dup) { + message.error('已存在相同加氢站、车牌号与加氢时间的记录,无法重复落账'); + return Promise.reject(); + } + var unitPrice = hrLookupStationUnitPrice(stationName); + var totalAmount = Math.round(unitPrice * hydrogenKg * 100) / 100; + var saved = hrUpsertBridgeRow({ + hydrogenTime: hydrogenTime, + stationName: stationName, + stationCode: stationCode, + plateNo: plateNo, + unitPrice: unitPrice, + hydrogenKg: hydrogenKg, + totalAmount: totalAmount, + fillerName: '预约受理', + reconcileStatus: 'pending' + }); + if (!saved) { + message.error('接受失败:共享账本不可用'); + return Promise.reject(); + } var now = dayjs ? dayjs().format('YYYY-MM-DD HH:mm:ss') : ''; + var linkedId = saved.id || saved.key || ''; setAppointmentData(function (prev) { return prev.map(function (r) { if (r.id !== record.id) return r; - return Object.assign({}, r, { status: 'accepted', handledTime: now, rejectReason: '' }); + return Object.assign({}, r, { + status: 'accepted', + handledTime: now, + rejectReason: '', + linkedRecordId: linkedId + }); }); }); - message.success('已接受预约'); + message.success('已接受预约,并生成未对账加氢记录'); } }); }, [dayjs]); @@ -944,16 +1479,19 @@ const Component = function () { message.warning('当前筛选条件下暂无数据可导出'); return; } - var headers = ['序号', '加氢时间', '加氢站名称', '车牌号', '氢气单价(元/kg)', '加氢量(kg)', '加氢总额(元)', '对账状态', '对账时间']; + var headers = ['序号', '加氢时间', '加氢站名称', '车牌号', '里程(km)', '氢气单价(元/kg)', '加氢量(kg)', '加氢总额(元)', '核对状态', '核对时间', '对账状态', '对账时间']; var rows = filteredList.map(function (r, index) { return [ index + 1, r.hydrogenTime || '', r.stationName || '', r.plateNo || '', + r.mileageKm != null && r.mileageKm !== '' ? String(Math.round(Number(r.mileageKm))) : '', hrFormatYuan(r.unitPrice != null ? r.unitPrice : r.costUnitPrice), hrFormatKg(r.hydrogenKg), hrFormatYuan(r.totalAmount != null ? r.totalAmount : r.customerAmount), + hrIsVerified(r) ? '已核对' : '未核对', + hrFormatReconcileTime(r.verifiedAt), hrIsReconciled(r) ? '已对账' : '未对账', hrFormatReconcileTime(r.reconcileTime) ]; @@ -970,7 +1508,7 @@ const Component = function () { key: 'hydrogenTime', width: 168, render: function (v) { - return React.createElement('span', { className: 'hr-refuel-time' }, v || '—'); + return React.createElement('span', { className: 'hr-refuel-time tabular-nums' }, v || '—'); } }, { @@ -987,7 +1525,16 @@ const Component = function () { key: 'plateNo', width: 120, render: function (v) { - return React.createElement('span', { className: 'hr-refuel-plate' }, hrFormatPlateDisplay(v)); + return React.createElement('span', { className: 'hr-refuel-plate tabular-nums' }, hrFormatPlateDisplay(v)); + } + }, + { + title: '里程(km)', + dataIndex: 'mileageKm', + key: 'mileageKm', + width: 110, + render: function (v) { + return React.createElement('span', { className: 'hr-num tabular-nums' }, hrFormatMileage(v)); } }, { @@ -995,10 +1542,9 @@ const Component = function () { dataIndex: 'unitPrice', key: 'unitPrice', width: 128, - align: 'right', render: function (v, record) { var price = v != null ? v : record.costUnitPrice; - return React.createElement('span', { className: 'hr-refuel-money' }, hrFormatYuan(price)); + return React.createElement('span', { className: 'hr-refuel-money tabular-nums' }, hrFormatYuan(price)); } }, { @@ -1006,9 +1552,8 @@ const Component = function () { dataIndex: 'hydrogenKg', key: 'hydrogenKg', width: 112, - align: 'right', render: function (v) { - return React.createElement('span', { className: 'hr-refuel-kg' }, hrFormatKg(v)); + return React.createElement('span', { className: 'hr-refuel-kg tabular-nums' }, hrFormatKg(v)); } }, { @@ -1016,10 +1561,31 @@ const Component = function () { dataIndex: 'totalAmount', key: 'totalAmount', width: 120, - align: 'right', render: function (v, record) { var amt = v != null ? v : record.customerAmount; - return React.createElement('span', { className: 'hr-refuel-money' }, hrFormatYuanSymbol(amt)); + return React.createElement('span', { className: 'hr-refuel-money tabular-nums' }, hrFormatYuanSymbol(amt)); + } + }, + { + title: '核对状态', + dataIndex: 'verifyStatus', + key: 'verifyStatus', + width: 100, + render: function (_, record) { + var ok = hrIsVerified(record); + return React.createElement(Tag, { + color: ok ? 'processing' : 'default', + style: { margin: 0, borderRadius: 6, fontWeight: 600 } + }, ok ? '已核对' : '未核对'); + } + }, + { + title: '核对时间', + dataIndex: 'verifiedAt', + key: 'verifiedAt', + width: 148, + render: function (v) { + return React.createElement('span', { className: 'hr-refuel-time tabular-nums' }, hrFormatReconcileTime(v)); } }, { @@ -1041,39 +1607,48 @@ const Component = function () { key: 'reconcileTime', width: 148, render: function (v) { - return React.createElement('span', { className: 'hr-refuel-time' }, hrFormatReconcileTime(v)); + return React.createElement('span', { className: 'hr-refuel-time tabular-nums' }, hrFormatReconcileTime(v)); } }, { title: '操作', key: 'actions', - width: 168, + width: 148, fixed: 'right', render: function (_, record) { - var done = hrIsReconciled(record); + var locked = hrIsLocked(record); return React.createElement(OperationActions, { edit: { - onClick: function () { openEdit(record); }, - disabled: done - }, - more: [{ - key: 'del', - label: '删除', - danger: true, - disabled: done, + label: '编辑', + disabled: locked, onClick: function () { - if (done) { - message.warning('已对账记录不可删除'); + if (locked) { + message.warning(hrIsReconciled(record) ? '已对账记录不可编辑' : '已核对记录不可编辑'); return; } - setDeleteModal({ open: true, record: record }); + openEdit(record); } - }] + }, + more: [ + { + key: 'delete', + label: '删除', + danger: true, + disabled: locked, + onClick: function () { + if (locked) { + message.warning(hrIsReconciled(record) ? '已对账记录不可删除' : '已核对记录不可删除'); + return; + } + setDeleteModal({ open: true, record: record }); + } + } + ] }); } } ]; - }, [openEdit]); + }, [openEdit, message]); var appointmentColumns = useMemo(function () { return [ @@ -1083,7 +1658,17 @@ const Component = function () { width: 52, align: 'center', render: function (_, __, index) { - return React.createElement('span', { className: 'h2-refuel-drill-seq' }, index + 1); + return React.createElement('span', { className: 'h2-refuel-drill-seq tabular-nums' }, index + 1); + } + }, + { + title: '加氢站名称', + dataIndex: 'stationName', + key: 'stationName', + width: 160, + ellipsis: true, + render: function (v) { + return React.createElement('span', { className: 'h2-refuel-drill-station', title: v || '' }, v || '—'); } }, { @@ -1092,7 +1677,7 @@ const Component = function () { key: 'plateNo', width: 120, render: function (v) { - return React.createElement('span', { className: 'h2-refuel-drill-plate' }, v || '—'); + return React.createElement('span', { className: 'h2-refuel-drill-plate tabular-nums' }, v || '—'); } }, { @@ -1101,7 +1686,7 @@ const Component = function () { key: 'appointmentTime', width: 168, render: function (v) { - return React.createElement('span', { className: 'h2-refuel-drill-time' }, v || '—'); + return React.createElement('span', { className: 'h2-refuel-drill-time tabular-nums' }, v || '—'); } }, { @@ -1109,9 +1694,8 @@ const Component = function () { dataIndex: 'appointmentKg', key: 'appointmentKg', width: 140, - align: 'right', render: function (v) { - return React.createElement('span', { className: 'h2-refuel-drill-kg' }, hrFormatKg(v)); + return React.createElement('span', { className: 'h2-refuel-drill-kg tabular-nums' }, hrFormatKg(v)); } }, { @@ -1129,7 +1713,7 @@ const Component = function () { key: 'contact', width: 130, render: function (v) { - return React.createElement('span', { className: 'h2-refuel-drill-contact' }, v || '—'); + return React.createElement('span', { className: 'h2-refuel-drill-contact tabular-nums' }, v || '—'); } }, { @@ -1142,24 +1726,57 @@ const Component = function () { return React.createElement(Tag, { color: info.color, style: { margin: 0, borderRadius: 6, fontWeight: 600 } }, info.label); } }, + { + title: '处理情况', + key: 'handleInfo', + width: 240, + render: function (_, record) { + if (record.status === 'pending') { + return React.createElement('span', { style: { color: '#2563eb', fontWeight: 600 } }, '待站端处理'); + } + var timeText = record.handledTime + ? React.createElement('div', { className: 'tabular-nums', style: { fontSize: 12, color: '#64748b' } }, '处理时间:' + record.handledTime) + : null; + if (record.status === 'accepted') { + return React.createElement('div', null, + React.createElement('span', { style: { color: '#059669', fontWeight: 600 } }, '已接受,已生成加氢记录'), + timeText + ); + } + if (record.status === 'rejected') { + return React.createElement('div', null, + React.createElement('span', { style: { color: '#dc2626', fontWeight: 600 } }, '已拒绝'), + timeText, + record.rejectReason + ? React.createElement('div', { className: 'hr-appointment-reject-reason', title: record.rejectReason }, '原因:' + record.rejectReason) + : null + ); + } + return React.createElement('span', { style: { color: '#94a3b8' } }, '—'); + } + }, { title: '操作', key: 'actions', - width: 168, + width: 148, fixed: 'right', render: function (_, record) { if (record.status !== 'pending') { - return record.status === 'rejected' && record.rejectReason - ? React.createElement('div', null, - React.createElement('span', { style: { color: '#94a3b8', fontSize: 12 } }, '已处理'), - React.createElement('div', { className: 'hr-appointment-reject-reason', title: record.rejectReason }, '拒绝原因:' + record.rejectReason) - ) - : React.createElement('span', { style: { color: '#94a3b8', fontSize: 12 } }, '已处理'); + return React.createElement(OperationActions, { more: [] }); } return React.createElement(OperationActions, { more: [ - { key: 'accept', label: '接受预约', onClick: function () { handleAcceptAppointment(record); } }, - { key: 'reject', label: '拒绝预约', danger: true, onClick: function () { openRejectAppointment(record); } } + { + key: 'accept', + label: '接受', + onClick: function () { handleAcceptAppointment(record); } + }, + { + key: 'reject', + label: '拒绝', + danger: true, + onClick: function () { openRejectAppointment(record); } + } ] }); } @@ -1293,6 +1910,21 @@ const Component = function () { }); } }, + { + title: '里程(km)', + width: 110, + render: function (_, record) { + return React.createElement(InputNumber, { + style: { width: '100%' }, + min: 0, + step: 1, + precision: 0, + placeholder: '选填', + value: record.mileageKm === '' || record.mileageKm == null ? null : parseFloat(record.mileageKm), + onChange: function (v) { updateBatchRow(record._rowId, { mileageKm: v == null ? '' : String(v) }); } + }); + } + }, { title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '单价(元/kg)'), width: 120, @@ -1372,70 +2004,118 @@ const Component = function () { React.createElement('div', { style: { color: '#94a3b8', marginTop: 12 } }, '暂无符合检索条件的加氢记录') ); + var renderListMainTabs = function () { + return React.createElement('div', { + style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }, + role: 'tablist', + 'aria-label': '加氢记录、手工台账与预约加氢' + }, + React.createElement(Button, { + type: listMainTab === 'records' ? 'primary' : 'default', + style: listMainTab === 'records' ? HR_PRIMARY_BTN_STYLE : { borderRadius: 8, fontWeight: 600 }, + onClick: function () { setListMainTab('records'); } + }, '加氢记录'), + React.createElement(Badge, { + dot: !manualTodayUploaded, + offset: [-2, 2] + }, + React.createElement(Button, { + type: listMainTab === 'manual' ? 'primary' : 'default', + style: listMainTab === 'manual' ? HR_PRIMARY_BTN_STYLE : { borderRadius: 8, fontWeight: 600 }, + onClick: function () { setListMainTab('manual'); } + }, '手工台账') + ), + React.createElement(Badge, { + count: pendingAppointmentCount, + overflowCount: 99, + offset: [6, -2], + size: 'small', + showZero: false, + title: pendingAppointmentCount ? ('待处理预约 ' + pendingAppointmentCount + ' 条') : undefined + }, + React.createElement(Button, { + type: listMainTab === 'appointments' ? 'primary' : 'default', + style: listMainTab === 'appointments' ? HR_PRIMARY_BTN_STYLE : { borderRadius: 8, fontWeight: 600 }, + onClick: function () { setListMainTab('appointments'); }, + 'aria-label': pendingAppointmentCount + ? ('预约加氢,待处理 ' + pendingAppointmentCount + ' 条') + : '预约加氢' + }, '预约加氢') + ) + ); + }; + var renderAppointmentPanel = function () { var records = sortedAppointments; var summary = appointmentSummary; + var apptTotalPages = Math.max(1, Math.ceil(records.length / appointmentPageSize) || 1); + var safeAppointmentPage = Math.min(appointmentPage, apptTotalPages); + var apptStart = (safeAppointmentPage - 1) * appointmentPageSize; + var pagedAppointments = records.slice(apptStart, apptStart + appointmentPageSize); var stats = [ { key: 'pending', label: '待处理预约', value: String(summary.pending || 0), mod: 'count', valueMod: 'count' }, { key: 'accepted', label: '已接受', value: String(summary.accepted || 0), mod: 'kg', valueMod: 'kg' }, { key: 'rejected', label: '已拒绝', value: String(summary.rejected || 0), mod: 'cost', valueMod: 'cost' }, { key: 'totalKg', label: '预约加氢量合计(kg)', value: hrFormatKg(summary.totalKg), mod: 'customer', valueMod: 'customer' } ]; - var tablePagination = records.length > 8 - ? { - current: appointmentPage, - pageSize: appointmentPageSize, - total: records.length, - showSizeChanger: true, - pageSizeOptions: ['5', '10', '20', '50'], - size: 'small', - showTotal: function (t) { return '共 ' + t + ' 条'; }, - onChange: function (p, ps) { setAppointmentPage(p); setAppointmentPageSize(ps); } - } - : false; - return React.createElement('div', { className: 'h2-refuel-drill-panel' }, - React.createElement('div', { className: 'h2-refuel-drill-station-card' }, - React.createElement('div', { className: 'h2-refuel-drill-station-card__name' }, '预约加氢管理'), - React.createElement('div', { className: 'h2-refuel-drill-station-card__meta' }, - '司机预约记录 · 待处理 ' + (summary.pending || 0) + ' 条 · 共 ' + records.length + ' 条' - ) - ), - React.createElement('div', { className: 'h2-refuel-drill-stats', role: 'group', 'aria-label': '预约统计' }, - stats.map(function (item) { - return React.createElement('div', { - key: item.key, - className: 'h2-refuel-drill-stat h2-refuel-drill-stat--' + item.mod, - role: 'article', - 'aria-label': item.label + ' ' + item.value - }, - React.createElement('div', { className: 'h2-refuel-drill-stat__label' }, item.label), - React.createElement('div', { - className: 'h2-refuel-drill-stat__value h2-refuel-drill-stat__value--' + item.valueMod - }, item.value) - ); - }) - ), - React.createElement('div', { className: 'h2-refuel-drill-table-wrap' }, - React.createElement('div', { className: 'h2-refuel-drill-table-head' }, - React.createElement('div', { className: 'h2-refuel-drill-table-head__left' }, - React.createElement('span', { className: 'h2-refuel-drill-table-head__title' }, '预约明细'), - React.createElement('span', { className: 'h2-refuel-drill-table-head__count' }, '共 ' + records.length + ' 条') - ), + return React.createElement('div', { className: 'lc-table-section', style: { flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 12 } }, + React.createElement('div', { className: 'lc-table-toolbar' }, + React.createElement('div', { className: 'lc-table-toolbar-left' }, renderListMainTabs()), + React.createElement('div', { className: 'lc-table-toolbar-actions' }, summary.pending ? React.createElement(Tag, { color: 'processing', style: { margin: 0, borderRadius: 6, fontWeight: 600 } }, summary.pending + ' 条待处理') : React.createElement(Tag, { style: { margin: 0, borderRadius: 6, color: '#64748b' } }, '暂无待处理') + ) + ), + React.createElement(Card, { + className: 'lc-filter-card', + bordered: false, + title: '预约信息与处理情况' + }, + React.createElement('div', { style: { color: '#64748b', fontSize: 13, marginBottom: 12, lineHeight: 1.6 } }, + '全站运营视角 · 列表展示预约信息与处理情况;待处理可接受或拒绝,接受后自动生成未对账加氢记录。' ), + React.createElement('div', { className: 'h2-refuel-drill-stats', role: 'group', 'aria-label': '预约统计', style: { marginBottom: 0 } }, + stats.map(function (item) { + return React.createElement('div', { + key: item.key, + className: 'h2-refuel-drill-stat h2-refuel-drill-stat--' + item.mod, + role: 'article', + 'aria-label': item.label + ' ' + item.value + }, + React.createElement('div', { className: 'h2-refuel-drill-stat__label' }, item.label), + React.createElement('div', { + className: 'h2-refuel-drill-stat__value h2-refuel-drill-stat__value--' + item.valueMod + ' tabular-nums' + }, item.value) + ); + }) + ) + ), + React.createElement('div', { className: 'lc-table-card', style: { flex: 1 } }, React.createElement(Table, { - className: 'h2-refuel-record-table', - size: 'small', + className: 'lc-list-table', + size: 'middle', bordered: false, rowKey: 'id', columns: appointmentColumns, - dataSource: records, - pagination: tablePagination, + dataSource: pagedAppointments, + pagination: false, locale: { emptyText: '暂无预约记录' }, - scroll: records.length > 8 ? { x: 'max-content', y: 320 } : { x: 'max-content' } - }) + scroll: { x: 1280, y: 'calc(100vh - 420px)' } + }), + React.createElement('div', { className: 'vm-table-footer' }, + React.createElement(TablePagination, { + page: safeAppointmentPage, + pageSize: appointmentPageSize, + total: records.length, + pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS, + onPageChange: setAppointmentPage, + onPageSizeChange: function (size) { + setAppointmentPageSize(size); + setAppointmentPage(1); + } + }) + ) ) ); }; @@ -1585,6 +2265,16 @@ const Component = function () { value: form.plateNo || '', maxLength: 16, onChange: function (e) { setForm(function (f) { return Object.assign({}, f, { plateNo: e.target.value }); }); } + }))), + col12(formItem('里程(km)', false, React.createElement(InputNumber, { + className: inputCls, + style: { width: '100%' }, + min: 0, + step: 1, + precision: 0, + placeholder: '选填', + value: form.mileageKm === '' || form.mileageKm == null ? null : parseFloat(form.mileageKm), + onChange: function (v) { setForm(function (f) { return Object.assign({}, f, { mileageKm: v == null ? '' : String(v) }); }); } }))) ) ) @@ -1675,16 +2365,355 @@ const Component = function () { batchCreateViewNode, editFormViewNode, subView === 'list' ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' } }, - + listMainTab === 'manual' ? React.createElement(React.Fragment, null, + React.createElement('div', { className: 'lc-table-toolbar', style: { marginBottom: 12, flexShrink: 0 } }, + React.createElement('div', { className: 'lc-table-toolbar-left' }, renderListMainTabs()) + ), + React.createElement(Card, { + className: 'lc-filter-card', + title: '手工台账上传', + bordered: false, + style: { flex: 1, overflow: 'auto' } + }, + React.createElement('div', { style: { marginBottom: 12, color: '#64748b', fontSize: 13, lineHeight: 1.6 } }, + '每日需上传本站纸质手工台账照片。日历可查看已传/未传日期,点选过往日期可补传;未上传今日台账时,不可新增加氢记录。' + ), + React.createElement(Row, { gutter: [16, 16], style: { marginBottom: 16 } }, + React.createElement(Col, { xs: 24, md: 10 }, + React.createElement('div', { style: { marginBottom: 6, fontWeight: 600, color: '#475569', fontSize: 13 } }, '加氢站'), + React.createElement(Select, { + style: { width: '100%' }, + value: activeManualStation || undefined, + options: stationOptions, + placeholder: '请选择加氢站', + onChange: function (v) { setManualStation(v); } + }) + ), + React.createElement(Col, { xs: 24, md: 14 }, + React.createElement('div', { + style: { + padding: '12px 14px', + borderRadius: 10, + background: manualTodayUploaded ? '#ecfdf5' : '#fff7ed', + border: '1px solid ' + (manualTodayUploaded ? '#a7f3d0' : '#fed7aa'), + color: manualTodayUploaded ? '#047857' : '#c2410c', + fontWeight: 600, + marginTop: 22 + } + }, manualTodayUploaded + ? '今日已上传手工台账(' + manualTodayKey + ')' + : '今日尚未上传手工台账(' + manualTodayKey + ')') + ) + ), + React.createElement(Row, { gutter: [24, 24] }, + React.createElement(Col, { xs: 24, lg: 12 }, + React.createElement('div', { + style: { + border: '1px solid #e2e8f0', + borderRadius: 12, + padding: 16, + background: '#fff' + } + }, + React.createElement('div', { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8 + } + }, + React.createElement(Button, { + type: 'text', + onClick: function () { shiftManualMonth(-1); }, + 'aria-label': '上一月' + }, '‹'), + React.createElement('strong', { + className: 'tabular-nums', + style: { fontSize: 15 } + }, manualCursorYm.y + '年' + manualCursorYm.m + '月'), + React.createElement(Button, { + type: 'text', + onClick: function () { shiftManualMonth(1); }, + 'aria-label': '下一月' + }, '›') + ), + React.createElement('div', { + style: { fontSize: 13, color: '#64748b', marginBottom: 12 } + }, manualFirstHydrogenDate + ? (manualMissingCount > 0 + ? '本月尚有 ' + manualMissingCount + ' 天未上传(自 ' + manualFirstHydrogenDate + ' 起),点选日期可补传' + : '本月自 ' + manualFirstHydrogenDate + ' 起截至今日均已上传') + : '本站尚无加氢记录,历史日期无需补传'), + React.createElement('div', { + style: { + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gap: 4, + marginBottom: 6, + textAlign: 'center', + fontSize: 12, + color: '#94a3b8', + fontWeight: 600 + } + }, ['日', '一', '二', '三', '四', '五', '六'].map(function (w) { + return React.createElement('span', { key: w }, w); + })), + React.createElement('div', { + role: 'grid', + 'aria-label': '手工台账日历', + style: { + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gap: 4 + } + }, manualMonthCells.map(function (cell) { + var selected = manualSelectedDate === cell.dateKey; + var bg = '#fff'; + var color = cell.inMonth ? '#0f172a' : '#cbd5e1'; + var border = '1px solid transparent'; + if (cell.isFuture) { + color = '#cbd5e1'; + bg = '#f8fafc'; + } else if (cell.needsLedger && cell.uploaded) { + bg = '#ecfdf5'; + color = '#047857'; + } else if (cell.needsLedger && !cell.uploaded) { + bg = '#fff7ed'; + color = '#c2410c'; + } else if (cell.inMonth) { + bg = '#f8fafc'; + color = '#94a3b8'; + } + if (selected) { + border = '2px solid #059669'; + } else if (cell.isToday) { + border = '1px solid #34d399'; + } + var statusLabel = cell.isFuture + ? '未来' + : (!cell.needsLedger ? '无需补传' : (cell.uploaded ? '已上传' : '未上传')); + return React.createElement('button', { + key: cell.dateKey + (cell.inMonth ? '' : '-o'), + type: 'button', + disabled: cell.isFuture, + onClick: function () { + if (cell.isFuture) return; + setManualSelectedDate(cell.dateKey); + }, + 'aria-label': cell.dateKey + statusLabel, + style: { + appearance: 'none', + WebkitAppearance: 'none', + border: border, + borderRadius: 8, + background: bg, + color: color, + cursor: cell.isFuture ? 'not-allowed' : 'pointer', + padding: '8px 0 6px', + minHeight: 44, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + fontWeight: cell.isToday || selected ? 700 : 500, + fontVariantNumeric: 'tabular-nums' + } + }, + React.createElement('span', null, cell.day), + cell.inMonth && cell.needsLedger + ? React.createElement('i', { + 'aria-hidden': true, + style: { + display: 'block', + width: 6, + height: 6, + borderRadius: '50%', + background: cell.uploaded ? '#10b981' : '#fb923c' + } + }) + : React.createElement('span', { style: { height: 6 } }) + ); + })), + React.createElement('div', { + style: { + display: 'flex', + gap: 16, + marginTop: 12, + fontSize: 12, + color: '#64748b' + } + }, + React.createElement('span', null, + React.createElement('i', { + style: { + display: 'inline-block', + width: 8, + height: 8, + borderRadius: '50%', + background: '#10b981', + marginRight: 6, + verticalAlign: 'middle' + } + }), + '已上传' + ), + React.createElement('span', null, + React.createElement('i', { + style: { + display: 'inline-block', + width: 8, + height: 8, + borderRadius: '50%', + background: '#fb923c', + marginRight: 6, + verticalAlign: 'middle' + } + }), + '未上传' + ) + ) + ) + ), + React.createElement(Col, { xs: 24, lg: 12 }, + React.createElement('div', { + style: { + border: '1px solid #e2e8f0', + borderRadius: 12, + padding: 16, + background: '#fff', + minHeight: 320 + } + }, + React.createElement('div', { style: { fontWeight: 700, marginBottom: 4, fontSize: 15 } }, + '上传 ' + manualSelectedDate + (manualSelectedDate === manualTodayKey ? '(今日)' : '') + ), + React.createElement('div', { + style: { fontSize: 13, color: '#64748b', marginBottom: 14 } + }, manualSelectedDate > manualTodayKey + ? '不能上传未来日期' + : (manualSelectedUploaded + ? '该日台账已归档,仅可补传新图,不可删除' + : '请拍摄清晰的纸质台账照片,可多张;确认后归档不可删')), + React.createElement(Upload, { + accept: 'image/*', + multiple: true, + showUploadList: false, + disabled: manualSelectedDate > manualTodayKey, + beforeUpload: function (file, fileList) { + if (file === fileList[0]) handleManualUploadFiles(fileList); + return false; + } + }, + React.createElement(Button, { + style: { borderRadius: 8, marginBottom: 12 }, + disabled: manualSelectedDate > manualTodayKey + }, '拍照 / 选择图片') + ), + React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 16 } }, + manualDraftImages.map(function (img) { + var archived = Boolean(img.archived || img.placeholder); + return React.createElement('div', { + key: img.id, + style: { + width: 120, + border: '1px solid #e2e8f0', + borderRadius: 10, + overflow: 'hidden', + background: '#f8fafc', + position: 'relative' + } + }, + img.dataUrl + ? React.createElement(Image, { src: img.dataUrl, width: 120, height: 90, style: { objectFit: 'cover' } }) + : React.createElement('div', { + style: { + width: 120, + height: 90, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#94a3b8', + fontSize: 12, + fontWeight: 600 + } + }, '已归档'), + archived + ? React.createElement('div', { + style: { + textAlign: 'center', + padding: '4px 0', + fontSize: 12, + fontWeight: 600, + color: '#64748b', + background: '#f1f5f9' + } + }, '已归档') + : React.createElement(Button, { + type: 'link', + danger: true, + size: 'small', + onClick: function () { + setManualDraftImages(function (prev) { + return prev.filter(function (x) { + return x.id !== img.id; + }); + }); + } + }, '删除') + ); + }) + ), + React.createElement(Button, { + type: 'primary', + style: HR_PRIMARY_BTN_STYLE, + onClick: handleManualSave, + disabled: manualSelectedDate > manualTodayKey || ( + manualSelectedUploaded + ? !manualDraftImages.some(function (img) { return !(img.archived || img.placeholder); }) + : !manualDraftImages.length + ) + }, manualSelectedUploaded + ? '确认补传 ' + manualSelectedDate + : '确认上传 ' + manualSelectedDate) + ) + ) + ) + ) + ) : listMainTab === 'appointments' ? renderAppointmentPanel() + : React.createElement(React.Fragment, null, + !manualTodayUploaded && activeManualStation ? React.createElement('div', { + style: { + marginBottom: 12, + padding: '10px 14px', + borderRadius: 10, + background: '#fff7ed', + border: '1px solid #fed7aa', + color: '#c2410c', + fontSize: 13, + fontWeight: 600, + cursor: 'pointer' + }, + onClick: function () { setListMainTab('manual'); }, + role: 'button', + tabIndex: 0 + }, '今日尚未上传手工台账(' + activeManualStation + '),点击前往上传;未上传不可新增电子记录') : null, React.createElement(Card, { className: 'lc-filter-card', title: '筛选条件', bordered: false }, React.createElement('div', { className: 'lc-filter-grid' }, - renderFilterField('加氢时间', React.createElement(RangePicker, { - showTime: true, - style: { width: '100%' }, - placeholder: ['开始时间', '结束时间'], - value: draftFilters.timeRange && draftFilters.timeRange.length ? draftFilters.timeRange : null, - onChange: function (v) { - setDraftFilters(function (p) { return Object.assign({}, p, { timeRange: v || [] }); }); + renderFilterField('加氢时间', React.createElement(DateRangeFilterField, { + startDate: draftFilters.startDate || '', + endDate: draftFilters.endDate || '', + startPlaceholder: '开始日期', + endPlaceholder: '结束日期', + ariaLabel: '加氢时间', + onChange: function (range) { + setDraftFilters(function (p) { + return Object.assign({}, p, { + startDate: range.startDate || '', + endDate: range.endDate || '' + }); + }); } })), renderFilterField('车牌号', React.createElement(Select, { @@ -1731,13 +2760,10 @@ const Component = function () { React.createElement('div', { className: 'hr-daily-stats-section' }, React.createElement('div', { className: 'hr-daily-stats-head' }, React.createElement('span', { className: 'hr-daily-stats-head__title' }, '每日统计'), - React.createElement('span', { className: 'hr-daily-stats-head__meta' }, - (function () { - var rangeStart = dailyStatsPageItems[0] ? dailyStatsPageItems[0].date : '—'; - var rangeEnd = dailyStatsPageItems.length ? dailyStatsPageItems[dailyStatsPageItems.length - 1].date : '—'; - return '近28天(含今天)· 第 ' + (dailyStatsPage + 1) + '/' + (HR_DAILY_STATS_MAX_PAGE + 1) + ' 组 · ' - + rangeStart + ' 至 ' + rangeEnd + ' · 筛选记录 ' + filteredList.length + ' 笔'; - })() + React.createElement('span', { className: 'hr-daily-stats-head__meta tabular-nums' }, + '近30天(含今天)· ' + + dailyStatsSummary.rangeStart + ' 至 ' + dailyStatsSummary.rangeEnd + + ' · 加氢总量 ' + hrFormatKg(dailyStatsSummary.totalKg) + ' kg' ) ), React.createElement('div', { className: 'hr-daily-stats-carousel' }, @@ -1754,19 +2780,32 @@ const Component = function () { key: item.date, className: 'hr-daily-stat-card' + (item.isToday ? ' hr-daily-stat-card--today' : ''), role: 'article', - 'aria-label': item.date + ' 加氢 ' + item.count + ' 次,加氢量 ' + hrFormatKg(item.totalKg) + ' kg' + 'aria-label': item.date + + ' 加氢数据 ' + item.count + ' 条,已对账 ' + item.reconciledCount + + ' 条,加氢量 ' + hrFormatKg(item.totalKg) + ' kg,加氢总额 ' + + hrFormatYuan(item.totalAmount) + ' 元' }, React.createElement('div', { className: 'hr-daily-stat-card__date' }, - React.createElement('span', null, item.dateLabel), + React.createElement('span', { className: 'tabular-nums' }, item.dateLabel), item.isToday ? React.createElement('span', { className: 'hr-daily-stat-card__today-tag' }, '今天') : null ), - React.createElement('div', { className: 'hr-daily-stat-card__row' }, - React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢次数'), - React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--count' }, String(item.count)) - ), - React.createElement('div', { className: 'hr-daily-stat-card__row' }, - React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢量(kg)'), - React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--kg' }, hrFormatKg(item.totalKg)) + React.createElement('div', { className: 'hr-daily-stat-card__metrics' }, + React.createElement('div', { className: 'hr-daily-stat-card__metric' }, + React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢数据条数'), + React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--count tabular-nums' }, String(item.count)) + ), + React.createElement('div', { className: 'hr-daily-stat-card__metric' }, + React.createElement('span', { className: 'hr-daily-stat-card__label' }, '已对账条数'), + React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--reconciled tabular-nums' }, String(item.reconciledCount)) + ), + React.createElement('div', { className: 'hr-daily-stat-card__metric' }, + React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢量'), + React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--kg tabular-nums' }, hrFormatKg(item.totalKg)) + ), + React.createElement('div', { className: 'hr-daily-stat-card__metric' }, + React.createElement('span', { className: 'hr-daily-stat-card__label' }, '加氢总额'), + React.createElement('span', { className: 'hr-daily-stat-card__value hr-daily-stat-card__value--amount tabular-nums' }, hrFormatYuan(item.totalAmount)) + ) ) ); }) @@ -1783,17 +2822,7 @@ const Component = function () { React.createElement('div', { className: 'lc-table-section' }, React.createElement('div', { className: 'lc-table-toolbar' }, - React.createElement('div', { className: 'lc-table-toolbar-left' }, - React.createElement(Badge, { count: pendingAppointmentCount, offset: [8, 0], size: 'small' }, - React.createElement(Button, { - type: 'default', - icon: HR_ICONS.calendar, - style: { borderRadius: 8, fontWeight: 600, borderColor: '#3b82f6', color: '#2563eb' }, - onClick: function () { setAppointmentModalOpen(true); }, - 'aria-label': '预约加氢' - }, '预约加氢') - ) - ), + React.createElement('div', { className: 'lc-table-toolbar-left' }, renderListMainTabs()), React.createElement('div', { className: 'lc-table-toolbar-actions' }, React.createElement(Button, { type: 'default', @@ -1814,42 +2843,30 @@ const Component = function () { className: 'lc-list-table', rowKey: 'id', columns: columns, - dataSource: filteredList, + dataSource: pagedList, size: 'middle', scroll: { x: 1280 }, - pagination: { - current: page, + pagination: false, + locale: { emptyText: emptyNode } + }), + React.createElement('div', { className: 'vm-table-footer' }, + React.createElement(TablePagination, { + page: listSafePage, pageSize: pageSize, total: filteredList.length, - showSizeChanger: true, - pageSizeOptions: ['5', '10', '20', '50'], - showTotal: function (t) { return '共 ' + t + ' 条'; }, - onChange: function (p, ps) { setPage(p); setPageSize(ps); } - }, - locale: { emptyText: emptyNode } - }) + pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS, + onPageChange: setPage, + onPageSizeChange: function (size) { + setPageSize(size); + setPage(1); + } + }) + ) ) ) + ) ) : null, - React.createElement(Modal, { - className: 'h2-refuel-drill-modal', - title: '预约加氢', - open: appointmentModalOpen, - onCancel: function () { setAppointmentModalOpen(false); }, - footer: React.createElement(Button, { - type: 'primary', - onClick: function () { setAppointmentModalOpen(false); }, - style: HR_PRIMARY_BTN_STYLE - }, '知道了'), - width: 980, - centered: true, - destroyOnClose: true, - styles: { body: { maxHeight: '78vh', overflow: 'auto' } } - }, - renderAppointmentPanel() - ), - React.createElement(Modal, { title: '确认删除', open: deleteModal.open, @@ -1857,8 +2874,8 @@ const Component = function () { onCancel: function () { setDeleteModal({ open: false, record: null }); }, onOk: function () { if (deleteModal.record) { - if (hrIsReconciled(deleteModal.record)) { - message.warning('已对账记录不可删除'); + if (hrIsLocked(deleteModal.record)) { + message.warning(hrIsReconciled(deleteModal.record) ? '已对账记录不可删除' : '已核对记录不可删除'); setDeleteModal({ open: false, record: null }); return; } @@ -1889,7 +2906,9 @@ const Component = function () { destroyOnClose: true }, React.createElement('div', { style: { marginBottom: 12, fontSize: 13, color: '#475569', lineHeight: 1.6 } }, - '车牌号:', + '加氢站:', + React.createElement('strong', { style: { color: '#0f172a' } }, (rejectModal.record && rejectModal.record.stationName) || '—'), + ' · 车牌号:', React.createElement('strong', { style: { color: '#0f172a' } }, (rejectModal.record && rejectModal.record.plateNo) || '—'), ' · 预约时间:', React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, (rejectModal.record && rejectModal.record.appointmentTime) || '—') diff --git a/src/prototypes/oneos-web-h2-station/pages/README.md b/src/prototypes/oneos-web-h2-station/pages/README.md new file mode 100644 index 0000000..3c44a28 --- /dev/null +++ b/src/prototypes/oneos-web-h2-station/pages/README.md @@ -0,0 +1,15 @@ +# 加氢记录(Web)页面说明 + +| 文件 | 说明 | +|---|---| +| `02-加氢记录.jsx` | 本原型唯一页面:PC 加氢流水台账(含导出、预约加氢) | + +## 菜单分工(加氢站管理) + +| 菜单 | 原型 ID | 页面实现 | +|---|---|---| +| 站点信息 | `oneos-web-h2-station-site` | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` | +| 加氢订单 | `oneos-h5-h2-order` | H5 包内 | +| 加氢记录 | `oneos-web-h2-station` | 本目录 `02-加氢记录.jsx` | + +历史合包中的 `01-加氢订单.jsx` 已删除;`03-站点信息.jsx` 已迁移至站点信息原型包。 diff --git a/src/prototypes/vehicle-h2-fee-ledger/.spec/PRD.md b/src/prototypes/vehicle-h2-fee-ledger/.spec/PRD.md index b504708..fa9b30b 100644 --- a/src/prototypes/vehicle-h2-fee-ledger/.spec/PRD.md +++ b/src/prototypes/vehicle-h2-fee-ledger/.spec/PRD.md @@ -2,11 +2,12 @@ | 项目 | 内容 | |------|------| -| 文档版本 | v2.3(业务版) | +| 文档版本 | v2.4(业务版) | | 产品模块 | 台账数据 → 车辆氢费明细 | | 文档类型 | 产品需求说明 | | 适用读者 | 产品、业务、运营、测试、项目 | -| 修订说明 | 同步成本单价校验图标与一键替换、固定/阶梯定价标签及与加氢站价格配置取价逻辑 | +| 修订说明 | v2.4:核对 vs 对账双状态——「完成核对」≠ 对账结案;对账仅由加氢站对账单提交回写 | +| 双状态规格 | [verify-reconcile.md](./verify-reconcile.md) | --- @@ -15,7 +16,7 @@ ### 1.1 业务痛点 - 车辆加氢费用分散在业务同事手工记录中,缺少统一台账,难以按客户、站点、时间核对。 -- 录入后需区分「是否已正式提交」「是否已完成对账」,避免未核实数据进入对账与结算环节。 +- 录入后需区分「是否已正式提交」「是否已完成核对」「是否已完成对账」,避免未核实数据进入对账与结算环节。 - 加氢量偏大、单价与公司在该站该时段的标准价不一致等情况,需要**提前暴露**,减少后续财务纠纷。 ### 1.2 产品价值 @@ -23,7 +24,7 @@ | 价值点 | 说明 | |--------|------| | 统一台账 | 一条记录对应一次加氢业务,关联车辆、客户、站点与金额 | -| 流程可控 | 待保存 → 未对账 → 已对账,责任边界清晰 | +| 流程可控 | 待保存 → 保存(未核对+未对账)→ **完成核对** → 对账单提交 → **已对账** | | 效率提升 | 支持批量导入、批量改价、复制同类记录 | | 风险可见 | 异常数据标黄,可一键只看有问题记录 | | 可追溯 | 关键修改留痕,便于主管复核与客诉查证 | diff --git a/src/prototypes/vehicle-h2-fee-ledger/.spec/verify-reconcile.md b/src/prototypes/vehicle-h2-fee-ledger/.spec/verify-reconcile.md new file mode 100644 index 0000000..9f332c8 --- /dev/null +++ b/src/prototypes/vehicle-h2-fee-ledger/.spec/verify-reconcile.md @@ -0,0 +1,73 @@ +# 核对 vs 对账(双状态) + +| 项 | 说明 | +|---|---| +| 适用范围 | 车辆氢费明细、加氢记录 Web、加氢订单 H5、站点信息·对账单 | +| 共享 Store | `src/common/h2VehicleLedgerBridge.js` | +| 数据源 | 原型本地种子 + 内存 Store(**未接真实 API**) | + +--- + +## 1. 两套状态(不得混用) + +| 状态 | 字段 | 取值 | 谁触发 | 用户可见 | +|---|---|---|---|---| +| **核对** | `verifyStatus` | `unverified` / `verified` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 | +| **核对时间** | `verifiedAt` | 日期时间 | 同上 | 列表可展示 | +| **对账** | `reconcileStatus` | `pending` / `reconciled`(台账另有 `draft` 待保存) | 站点信息 · **对账单提交** | 未对账 / 已对账 | +| **对账时间** | `reconcileDate` | 日期时间 | 对账单提交回写 | 对账日期列 | + +> 历史字段 `reconciledAt`:完成核对时与 `verifiedAt` 同写(兼容旧展示);**不得**再由「完成核对」把 `reconcileStatus` 写成 `reconciled`。 + +--- + +## 2. 端到端数据流(判定顺序) + +| 步骤 | 动作 | 结果 | +|---|---|---| +| 1 | 加氢站(H5/Web)新增加氢记录 | 写入共享 Store:`verifyStatus=unverified`,`reconcileStatus=pending`;按站点成本价补 `costUnitPrice`,`costTotal≈单价×量`;同步出现在车辆氢费明细 | +| 2 | 能源部在车辆氢费明细确认无误 → **完成核对** | `verifyStatus=verified`,写 `verifiedAt`;站端列表同步「已核对」;**业务员侧不可再改删**(原型规则) | +| 3 | 站点信息生成并对账单 **提交** | 仅纳入:**已核对** ∧ **未对账** ∧ 无 `statementRecordId` ∧ 本站 ∧ 账期;提交后 `reconcileStatus=reconciled`,写 `reconcileDate`、`statementRecordId` 等 | + +--- + +## 3. 前置条件 + +| 场景 | 行为 | +|---|---| +| 未保存草稿(台账 `draft`) | 不可完成核对;不可进对账单 | +| 未核对 | 可编辑(未对账前提下);不可进对账单 | +| 已核对未对账 | 不可业务员改删;可进对账单 | +| 已对账 | 展示「已对账」+ 对账时间;绑定对账单后不再进入下一张 | + +--- + +## 4. 车辆氢费明细 UI 口径 + +| 控件 | 规则 | +|---|---| +| 按钮文案 | **完成核对**(原「完成对账」) | +| 勾选 | 仅「已保存且未核对」行 | +| 核对状态列 | 未核对 / 已核对 | +| 对账状态列 | 未对账 / 已对账(由对账单决定,非完成核对) | +| 对账日期 | 对账单回写后才有值 | + +--- + +## 5. 站端(加氢记录 / 加氢订单)展示 + +| 列/标签 | 数据 | +|---|---| +| 核对状态 | `verifyStatus` | +| 核对时间 | `verifiedAt`(未核对应「—」) | +| 对账状态 | `reconcileStatus === reconciled` → 已对账,否则未对账 | +| 对账时间 | `reconcileDate`(未对账「—」) | +| 只读 | 已核对 **或** 已对账 → 禁编辑/删除 | + +--- + +## 6. 边界 + +- 站端上报默认未核对、未对账 +- 原型演示:嘉兴本站首页种子含 **未核对**(无核对时间,`rf-11`/`rf-12`)、**已核对未对账**(`rf-13`)、**已核对已对账**(`rf-1`~`rf-4`);他站另有样例 +- 跨页依赖同浏览器会话共享 Store diff --git a/src/prototypes/vehicle-h2-fee-ledger/H2LedgerPage.jsx b/src/prototypes/vehicle-h2-fee-ledger/H2LedgerPage.jsx index d3d848e..d1083b7 100644 --- a/src/prototypes/vehicle-h2-fee-ledger/H2LedgerPage.jsx +++ b/src/prototypes/vehicle-h2-fee-ledger/H2LedgerPage.jsx @@ -1,5 +1,5 @@ // 【重要】必须使用 const Component 作为组件变量名 -// 台账数据 - 车辆氢费明细(业务人员手动录入;保存后为未对账,勾选后「完成对账」) +// 台账数据 - 车辆氢费明细(业务人员手动录入;保存后未核对+未对账;勾选「完成核对」;对账由站点对账单提交) // 原型:联调后对接加氢站、车辆、客户、公司及登录账号接口 // 车辆氢费明细 PRD 正文(与 车辆氢费明细-需求文档.md 同步) @@ -871,6 +871,8 @@ const Component = function () { var RECONCILE_DRAFT = 'draft'; var RECONCILE_PENDING = 'pending'; var RECONCILE_RECONCILED = 'reconciled'; + var VERIFY_VERIFIED = 'verified'; + var VERIFY_UNVERIFIED = 'unverified'; var VEHICLE_INFO_TABLE = [ '浙AD12345F', @@ -2298,7 +2300,7 @@ const Component = function () { } function isCustomerNameWarn(row) { - if (isReconciledRow(row)) return false; + if (isDataLockedRow(row)) return false; return getCustomerNameWarnInfo(row).warn; } @@ -2319,7 +2321,7 @@ const Component = function () { } function canQuickReplaceCustomer(row, isAdminRole) { - if (!row || isReconciledRow(row)) return false; + if (!row || isDataLockedRow(row)) return false; if (isAdminRole) return true; return row.createdBy === CURRENT_USER.id; } @@ -2535,28 +2537,45 @@ const Component = function () { return row && row.reconcileStatus === RECONCILE_RECONCILED; } + function isVerifiedRow(row) { + return row && (row.verifyStatus === VERIFY_VERIFIED || row.verifyStatus === 'verified'); + } + function isStatementLockedRow(row) { return !!(row && row.statementPatched); } + /** 已核对或已对账后业务员侧锁定 */ + function isDataLockedRow(row) { + return isVerifiedRow(row) || isReconciledRow(row) || isStatementLockedRow(row); + } + function rowDisplaySortTier(row) { if (isReconciledRow(row)) return 0; - if (isPendingRow(row)) return 1; - return 2; + if (isVerifiedRow(row)) return 1; + if (isPendingRow(row)) return 2; + return 3; + } + + function rowVerifyLabel(row) { + if (isNewRow(row)) return ''; + if (isVerifiedRow(row)) return '已核对'; + if (isPendingRow(row) || isReconciledRow(row)) return '未核对'; + return ''; } function rowStatusLabel(row) { if (isReconciledRow(row)) return '已对账'; - if (isPendingRow(row)) return '未对账'; + if (isPendingRow(row) || isVerifiedRow(row)) return '未对账'; return ''; } function canShowPendingActions(row) { - return isPendingRow(row); + return isPendingRow(row) && !isVerifiedRow(row); } function canCopyRow(row) { - if (!row || isReconciledRow(row)) return false; + if (!row || isDataLockedRow(row)) return false; if (isPendingRow(row)) return true; return isNewRow(row) && row.createdBy === CURRENT_USER.id; } @@ -2602,15 +2621,16 @@ const Component = function () { companyName: sourceRow.companyName || '', createdAt: now, reconcileStatus: null, + verifyStatus: null, savedAt: null, + verifiedAt: null, reconciledAt: null }; return recalcAllRows([draft])[0]; } function isRowBatchEditable(row) { - if (isStatementLockedRow(row)) return false; - if (isReconciledRow(row)) return false; + if (isDataLockedRow(row)) return false; if (isPendingRow(row)) return true; return isNewRow(row) && row.createdBy === CURRENT_USER.id; } @@ -2941,8 +2961,7 @@ const Component = function () { receiptDate: patch.receiptDate, receiptAmount: patch.receiptAmount != null ? patch.receiptAmount : r.receiptAmount, stationPaymentStatus: patch.paymentStatus || 'paid', - paymentStatus: patch.paymentStatus || 'paid', - reconciledAt: patch.reconcileDate + paymentStatus: patch.paymentStatus || 'paid' }); }); } @@ -3081,7 +3100,9 @@ const Component = function () { remark: '', createdAt: day, reconcileStatus: reconcileStatus, + verifyStatus: reconcileStatus === RECONCILE_RECONCILED ? VERIFY_VERIFIED : (reconcileStatus === RECONCILE_PENDING ? VERIFY_UNVERIFIED : null), savedAt: reconcileStatus !== RECONCILE_DRAFT ? day : null, + verifiedAt: reconcileStatus === RECONCILE_RECONCILED ? day : null, reconciledAt: reconcileStatus === RECONCILE_RECONCILED ? day : null }); } @@ -3105,7 +3126,9 @@ const Component = function () { remark: '客户名称校验演示', createdAt: demoDay, reconcileStatus: RECONCILE_PENDING, - savedAt: demoDay + verifyStatus: VERIFY_UNVERIFIED, + savedAt: demoDay, + verifiedAt: null }, draft ); @@ -3794,34 +3817,38 @@ const Component = function () { setEditingKeys([]); }, [allRows, appendChangeLog]); - var handleCompleteReconcile = useCallback(function () { + var handleCompleteVerify = useCallback(function () { if (!selectedRowKeys.length) { - message.warning('请先勾选需要对账的记录'); + message.warning('请先勾选需要核对的记录'); return; } var keySet = {}; selectedRowKeys.forEach(function (k) { keySet[k] = true; }); var selected = allRows.filter(function (r) { return keySet[r.key]; }); - var notPending = selected.filter(function (r) { return !isPendingRow(r); }); - if (notPending.length) { - message.warning('仅可对「未对账」状态的记录完成对账'); + var invalid = selected.filter(function (r) { return !isPendingRow(r) || isVerifiedRow(r); }); + if (invalid.length) { + message.warning('仅可对「已保存且未核对」的记录完成核对'); return; } - var reconciledAt = nowDayjs(); + var verifiedAt = nowDayjs(); selected.forEach(function (r) { - appendChangeLog(r.key, 'reconcileStatus', RECONCILE_PENDING, RECONCILE_RECONCILED, r, { force: true }); + appendChangeLog(r.key, 'verifyStatus', VERIFY_UNVERIFIED, VERIFY_VERIFIED, r, { force: true }); }); setAllRows(function (prev) { return prev.map(function (r) { - if (keySet[r.key] && isPendingRow(r)) { - return Object.assign({}, r, { reconcileStatus: RECONCILE_RECONCILED, reconciledAt: reconciledAt }); + if (keySet[r.key] && isPendingRow(r) && !isVerifiedRow(r)) { + return Object.assign({}, r, { + verifyStatus: VERIFY_VERIFIED, + verifiedAt: verifiedAt, + reconciledAt: verifiedAt + }); } return r; }); }); setSelectedRowKeys([]); setEditingKeys([]); - message.success('已完成对账,共 ' + selected.length + ' 条'); + message.success('已完成核对,共 ' + selected.length + ' 条'); }, [selectedRowKeys, allRows, appendChangeLog]); var rowSelection = useMemo( @@ -3832,7 +3859,7 @@ const Component = function () { setSelectedRowKeys(keys); }, getCheckboxProps: function (record) { - return { disabled: !isPendingRow(record) }; + return { disabled: !(isPendingRow(record) && !isVerifiedRow(record)) }; } }; }, @@ -4810,14 +4837,26 @@ const Component = function () { } }, { - title: '状态', + title: '核对状态', + key: 'verifyStatus', + width: 88, + align: 'center', + render: function (_, record) { + var label = rowVerifyLabel(record); + if (!label) return React.createElement('span', { style: { color: '#cbd5e1' } }, '—'); + var color = label === '已核对' ? 'green' : 'orange'; + return React.createElement(Tag, { color: color, style: { margin: 0 } }, label); + } + }, + { + title: '对账状态', key: 'recordStatus', - width: 72, + width: 88, align: 'center', render: function (_, record) { var label = rowStatusLabel(record); if (!label) return React.createElement('span', { style: { color: '#cbd5e1' } }, '—'); - var color = label === '已对账' ? 'blue' : 'orange'; + var color = label === '已对账' ? 'blue' : 'default'; return React.createElement(Tag, { color: color, style: { margin: 0 } }, label); } }, @@ -5475,12 +5514,12 @@ const Component = function () { React.createElement( Popconfirm, { - title: '完成对账后数据将标记为已对账,只能联系主管进行修改,是否确认。', + title: '完成核对后数据将标记为已核对,只能联系主管进行修改;对账须待加氢站对账单提交后才会变为已对账。是否确认?', okText: '确认', cancelText: '取消', - onConfirm: handleCompleteReconcile + onConfirm: handleCompleteVerify }, - React.createElement(Button, { type: 'primary', 'data-annotation-id': 'vh2-btn-reconcile' }, '完成对账') + React.createElement(Button, { type: 'primary', 'data-annotation-id': 'vh2-btn-reconcile' }, '完成核对') ) ) ), diff --git a/src/prototypes/vehicle-h2-fee-ledger/annotation-source.json b/src/prototypes/vehicle-h2-fee-ledger/annotation-source.json index f5841f6..901c1ad 100644 --- a/src/prototypes/vehicle-h2-fee-ledger/annotation-source.json +++ b/src/prototypes/vehicle-h2-fee-ledger/annotation-source.json @@ -20,7 +20,7 @@ "path": [] }, "aiPrompt": "说明台账数据模块下车辆氢费明细的定位。", - "annotationText": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存 → 未对账 → 完成对账 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。", + "annotationText": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存(未核对+未对账)→ 完成核对 → 对账单提交 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。", "hasMarkdown": true, "color": "#0f766e", "images": [], @@ -83,7 +83,7 @@ "path": [] }, "aiPrompt": "保存、对账、导入导出与状态流转。", - "annotationText": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成对账 | 勾选未对账行结案为已对账 |\n\n**保存**与**完成对账**区别:保存是提交草稿;完成对账表示与客户/内部核对无误,业务员不可再改删。", + "annotationText": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成核对 | 勾选未核对行标记为已核对;对账须待站点对账单提交 |\n\n**保存**与**完成核对**区别:保存是提交草稿;完成核对表示能源部确认数据无误;对账结案由加氢站对账单提交触发。", "hasMarkdown": true, "color": "#334155", "images": [], @@ -146,7 +146,7 @@ "path": [] }, "aiPrompt": "待保存与未对账状态转换。", - "annotationText": "将待保存行提交为「未对账」正式数据,方可勾选完成对账。", + "annotationText": "将待保存行提交为「未核对+未对账」正式数据,方可勾选完成核对。", "hasMarkdown": false, "color": "#2563eb", "images": [], @@ -157,7 +157,7 @@ { "id": "vh2-btn-reconcile", "index": 8, - "title": "完成对账", + "title": "完成核对", "pageId": "list", "locator": { "selectors": [ @@ -283,10 +283,10 @@ ] }, "markdownMap": { - "vh2-header": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存 → 未对账 → 完成对账 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。", + "vh2-header": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存(未核对+未对账)→ 完成核对 → 对账单提交 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。", "vh2-filter": "# 筛选条件\n\n默认展示加氢时间、订单编号、车牌号码;点击「展开」显示客户、加氢站、业务员、承担方式、客户收款状态、开票公司等。\n\n| 操作 | 行为 |\n|---|---|\n| 查询 | 应用当前筛选,并重算顶部合计 |\n| 重置 | 清空筛选条件 |\n| 展开/收起 | 切换更多筛选项 |\n\n筛选后列表按状态分层排序:待保存 → 未对账 → 已对账。", "vh2-only-warn": "# 异常数据规则\n\n以下情况计入异常,可勾选「仅显示异常数据」筛出。\n\n## 单元格标黄\n\n1. **加氢量疑似有误**:加氢量 > 60kg\n2. **成本单价异常**:与站点该时段标准成本价不一致(固定价或阶梯价口径)\n3. **加氢单价异常**:与站点该时段标准客户价不一致\n\n## 成本单价列\n\n- 单价前 **固定 / 阶梯** 标签(阶梯价悬浮可见当前阶梯量与对应单价)\n- 与标准价一致:绿色 ✓;不一致:红色 ⚠ + 单元格标黄\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 整行标红(客户名称异常)\n\n按 **车牌 + 加氢日期** 比对车辆运营态:\n\n| 场景 | 判定 |\n|------|------|\n| 租赁/自营已交车未还车 | 录入客户与系统登记客户不一致 |\n| 库存车 | 不应填写客户名称 |\n| 异动期内 | 客户名称应为「车辆异动」 |\n\n整行淡红底,各单元格斜向水印「客户疑似有误」;客户名称列有警示图标,悬浮可看系统客户、交车时间等。\n\n**与系统登记客户不一致**时,提示框提供 **一键替换为系统客户**,替换后写入变更日志(客户名称(一键替换))。\n\n开启后只展示异常记录;**本人待保存的新增行仍显示**。", - "vh2-toolbar": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成对账 | 勾选未对账行结案为已对账 |\n\n**保存**与**完成对账**区别:保存是提交草稿;完成对账表示与客户/内部核对无误,业务员不可再改删。", + "vh2-toolbar": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成核对 | 勾选未核对行标记为已核对;对账须待站点对账单提交 |\n\n**保存**与**完成核对**区别:保存是提交草稿;完成核对表示能源部确认数据无误;对账结案由加氢站对账单提交触发。", "vh2-btn-reconcile": "# 完成对账\n\n- 仅 **未对账** 行可勾选\n- 点击前二次确认:「完成对账后只能联系主管修改」\n- 成功后状态变为 **已对账**,对账日期写入\n- 业务员侧编辑/删除不可用;主管可纠错并留变更日志", "vh2-totals": "# 顶部合计条\n\n展示当前列表可见行的三项合计:\n\n- 加氢量(kg)\n- 成本总价(元)\n- 加氢总价(元)\n\n**随查询重算**:点击「查询」后按新结果汇总。\n\n**随异常开关变化**:开启「仅显示异常数据」时,仅对可见异常行(及本人待保存行)合计。\n\n> 合计含列表中全部可见状态;导出范围仅为未对账+已对账,不含待保存草稿。", "vh2-table": "# 列表字段与操作\n\n**核心可编辑字段**(未对账/待保存):加氢时间、站点、车牌、加氢量、成本单价、加氢单价、承担方式等。\n\n**系统带出只读**:业务员、开票/付款相关字段等;客户名称依业务可选填,但会与车辆运营态校验。\n\n## 成本单价列\n\n- 单价前展示 **固定 / 阶梯** 标签(与加氢站价格配置一致)\n- 阶梯价悬浮:当前阶梯量、对应单价\n- 校验图标:正确绿色 ✓ / 异常红色 ⚠\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 客户名称列\n\n- 与系统登记不一致时整行标红,单元格水印「客户疑似有误」\n- 客户名称后 **警示图标**:悬浮显示异常说明(系统客户、交车时间等)\n- **一键替换为系统客户**(仅「与系统登记客户不一致」场景,未对账数据可操作)\n- 替换写入变更日志\n\n**行操作**(更多菜单):编辑、删除、复制多行、变更日志。\n\n**列表勾选**:仅未对账记录可勾选,用于批量完成对账。\n\n表头支持批量改价(成本单价、加氢单价、承担方式)。" @@ -304,7 +304,7 @@ "type": "markdown", "id": "vh2-doc-overview", "title": "模块总览", - "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 车辆氢费明细 |\n| 目标用户 | 业务员、主管 |\n| 核心任务 | 录入加氢费用、保存、对账、导入导出 |\n| 页面结构 | 筛选区 + 工具栏 + 合计条 + 宽表 + 底部新增 |\n\n## 2. 状态流转\n\n待保存 → **保存** → 未对账 → **完成对账** → 已对账\n\n## 3. 验收重点\n\n1. 筛选与合计联动\n2. 异常标黄与「仅显示异常」\n3. 成本单价固定/阶梯标签、校验图标与一键替换\n4. 保存/对账权限区分业务员与主管\n5. 变更日志可追溯\n6. 导出不含待保存草稿" + "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 车辆氢费明细 |\n| 目标用户 | 业务员、主管 |\n| 核心任务 | 录入加氢费用、保存、对账、导入导出 |\n| 页面结构 | 筛选区 + 工具栏 + 合计条 + 宽表 + 底部新增 |\n\n## 2. 状态流转\n\n待保存 → **保存** → 未核对 → **完成核对** → 对账单提交 → 已对账\n\n## 3. 验收重点\n\n1. 筛选与合计联动\n2. 异常标黄与「仅显示异常」\n3. 成本单价固定/阶梯标签、校验图标与一键替换\n4. 保存/对账权限区分业务员与主管\n5. 变更日志可追溯\n6. 导出不含待保存草稿" }, { "type": "link", @@ -348,7 +348,7 @@ "type": "markdown", "id": "vh2-doc-table", "title": "列表与合计", - "markdown": "# 列表与合计\n\n顶部合计条实时汇总可见行的加氢量、成本总价、加氢总价。\n\n列表支持行内编辑、复制、批量改价、变更日志;仅未对账可勾选完成对账。\n\n成本单价列含固定/阶梯标签、标准价校验图标与一键替换;客户名称列含运营态校验、警示图标与一键替换(见异常数据说明)。" + "markdown": "# 列表与合计\n\n顶部合计条实时汇总可见行的加氢量、成本总价、加氢总价。\n\n列表支持行内编辑、复制、批量改价、变更日志;仅未核对可勾选完成核对;已对账由对账单提交产生。\n\n成本单价列含固定/阶梯标签、标准价校验图标与一键替换;客户名称列含运营态校验、警示图标与一键替换(见异常数据说明)。" } ] } diff --git a/src/prototypes/vm-shared/DESIGN.md b/src/prototypes/vm-shared/DESIGN.md index 688a5fb..d3c5880 100644 --- a/src/prototypes/vm-shared/DESIGN.md +++ b/src/prototypes/vm-shared/DESIGN.md @@ -335,6 +335,6 @@ import { OperationActions } from '../../common/OperationActions'; | 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` | | 客户管理 | `src/prototypes/customer-management/index.tsx` | | 收款记录 | `src/prototypes/payment-records/index.tsx` | -| 加氢站站点信息 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | +| 加氢站站点信息 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` | | 车辆维修明细 | `src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx` | | 租赁合同管理 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` | diff --git a/src/resources/design-system/DESIGN.md b/src/resources/design-system/DESIGN.md index 1e6420c..faa515f 100644 --- a/src/resources/design-system/DESIGN.md +++ b/src/resources/design-system/DESIGN.md @@ -343,7 +343,7 @@ const vmTheme = { | 车辆资产 | `src/prototypes/vehicle-management/index.tsx` | | 租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` | | 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` | -| 加氢站站点 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | +| 加氢站站点 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` | ---