diff --git a/.axhub/make/sidebar-tree.json b/.axhub/make/sidebar-tree.json index 77dd792..04bedce 100644 --- a/.axhub/make/sidebar-tree.json +++ b/.axhub/make/sidebar-tree.json @@ -1,19 +1,7 @@ { "version": 1, - "updatedAt": "2026-07-17T03:32:12.024Z", + "updatedAt": "2026-07-20T05:34:45.927Z", "prototypes": [ - { - "id": "item-prototypes-oneos-web-approval", - "kind": "item", - "title": "oneos web approval", - "itemKey": "prototypes/oneos-web-approval" - }, - { - "id": "item-prototypes-oneos-web-h2-station-analysis", - "kind": "item", - "title": "oneos web h2 station analysis", - "itemKey": "prototypes/oneos-web-h2-station-analysis" - }, { "id": "folder-1783875936186-27raza", "kind": "folder", @@ -25,12 +13,6 @@ "title": "业务管理", "itemKey": "prototypes/oneos-web-business" }, - { - "id": "item:prototypes:oneos-web-contract-template", - "kind": "item", - "title": "合同模板管理", - "itemKey": "prototypes/oneos-web-contract-template" - }, { "id": "item:prototypes:oneos-web-data-analysis", "kind": "item", @@ -43,12 +25,6 @@ "title": "财务管理(合包)", "itemKey": "prototypes/oneos-web-finance" }, - { - "id": "item:prototypes:oneos-web-h2-station", - "kind": "item", - "title": "加氢站管理", - "itemKey": "prototypes/oneos-web-h2-station" - }, { "id": "item:prototypes:oneos-web-help-center", "kind": "item", @@ -132,7 +108,7 @@ { "id": "folder-1782874599304-roj8fd", "kind": "folder", - "title": "运维管理", + "title": "车辆资产", "children": [ { "id": "item:prototypes:vehicle-management", @@ -197,11 +173,29 @@ "title": "站点信息", "itemKey": "prototypes/oneos-web-h2-station-site" }, + { + "id": "item:prototypes:oneos-h5-h2-order", + "kind": "item", + "title": "加氢记录(H5)", + "itemKey": "prototypes/oneos-h5-h2-order" + }, + { + "id": "item:prototypes:oneos-web-h2-station", + "kind": "item", + "title": "加氢记录", + "itemKey": "prototypes/oneos-web-h2-station" + }, { "id": "item:prototypes:oneos-web-h2-station-weekly", "kind": "item", "title": "站点周报统计", "itemKey": "prototypes/oneos-web-h2-station-weekly" + }, + { + "id": "item-prototypes-oneos-web-h2-station-analysis", + "kind": "item", + "title": "oneos web h2 station analysis", + "itemKey": "prototypes/oneos-web-h2-station-analysis" } ] }, @@ -213,8 +207,20 @@ { "id": "item:prototypes:business-dept-ledger", "kind": "item", - "title": "业务部台账", + "title": "项目盈亏情况", "itemKey": "prototypes/business-dept-ledger" + }, + { + "id": "item:prototypes:oneos-web-h2-station-stats", + "kind": "item", + "title": "加氢站数量统计", + "itemKey": "prototypes/oneos-web-h2-station-stats" + }, + { + "id": "item:prototypes:customer-payment-collection", + "kind": "item", + "title": "客户回款情况", + "itemKey": "prototypes/customer-payment-collection" } ] }, @@ -277,12 +283,6 @@ "kind": "item", "title": "还车应结款", "itemKey": "prototypes/vehicle-return-settlement" - }, - { - "id": "item:prototypes:vehicle-return-settlement-v2", - "kind": "item", - "title": "还车应结款-新", - "itemKey": "prototypes/vehicle-return-settlement-v2" } ] }, @@ -299,6 +299,19 @@ } ] }, + { + "id": "folder-1784341576489-spa7ms", + "kind": "folder", + "title": "审批中心", + "children": [ + { + "id": "item:prototypes:oneos-web-approval", + "kind": "item", + "title": "审批中心", + "itemKey": "prototypes/oneos-web-approval" + } + ] + }, { "id": "item:prototypes:lease-business-line-overview", "kind": "item", @@ -1092,4 +1105,4 @@ "templates": [], "components": [], "canvas": [] -} \ No newline at end of file +} diff --git a/.cursor/rules/business-logic-documentation.mdc b/.cursor/rules/business-logic-documentation.mdc new file mode 100644 index 0000000..3eeacad --- /dev/null +++ b/.cursor/rules/business-logic-documentation.mdc @@ -0,0 +1,37 @@ +--- +description: 复杂业务/判断逻辑必须同步写入 PRD 与 .spec Markdown,并同步标注目录 +alwaysApply: true +--- + +# 业务逻辑文档化(全局强制) + +实现或修改**复杂判断逻辑**时,代码与文档**同一轮交付**。完整指南:`rules/business-logic-documentation-guide.md`。 + +## 触发条件(满足任一即必须文档化) + +多步判定链、跨模块校验、状态/公式推导、导入保存规则、权限分支、一键修复建议值。 + +## 必须更新的文件 + +1. **`src/prototypes//.spec/.md`** — 完整规则(判定顺序、数据源、边界、代码路径) +2. **`src/prototypes//.spec/requirements-prd.md`** — 摘要表 + §验收项 + 链接到规格全文 +3. **`annotation-source.json`** — 运行 `sync-annotation-directory.mjs` 同步目录/字段标注 +4. 字段级:`columnHeaderTips.ts` 或对应 `annotationText`(如有) + +## 规格文最低要求 + +- 判定**优先级顺序**(表格) +- **前置条件**(何时不校验) +- **数据源**(种子 JSON / 哪几个模块) +- 用户可见结果(通过/警告文案、是否可替换) +- 标明原型本地种子 vs 未接真实 API + +## 禁止 + +- 只改 `utils/*.ts` 不更新 PRD / `.spec` +- 口头解释替代 Markdown +- 验收通过时 PRD 与页面行为不一致 + +## 范例 + +`lease-business-detail`:`.spec/field-checks.md` + PRD §5 单元格系统校验 + 目录「单元格系统校验」 diff --git a/.cursor/rules/cloud-publish-url.mdc b/.cursor/rules/cloud-publish-url.mdc new file mode 100644 index 0000000..00e13ec --- /dev/null +++ b/.cursor/rules/cloud-publish-url.mdc @@ -0,0 +1,31 @@ +--- +description: 对象存储发布 URL 必须与 Make 工具一致,禁止改后缀或加 prototypes/ 前缀 +alwaysApply: true +--- + +# 对象存储发布 URL(全局强制) + +完整规范:`rules/cloud-publish-url-guide.md`。配置:`.axhub/make/axhub.config.json` → `cloudPublishing.s3`。 + +## 标准形态 + +```text +{baseUrl}/{prototype-id}/index.html +``` + +示例:`https://prototype.lnoneos.com/lease-business-detail/index.html` + +## 禁止(Agent 发布或写链接时) + +- 不得改为 `/prototypes/{id}/index.html` +- 不得去掉 `/index.html` 后缀 +- 不得自改 `baseUrl` 域名 +- 不得把本地 dev 路径 `/prototypes/...` 当作已发布链接写入 PRD/导航/registry + +## 脚本发布 + +使用 `scripts/publish-all-to-s3.mjs`;`resolveObjectPrefix` 已与 Make 对齐为扁平 `{prototype-id}`。 + +## 回报用户 + +发布成功后给出的 URL 须与 Make「已发布到对象存储」弹窗**完全一致**。 diff --git a/AGENTS.md b/AGENTS.md index 0c82f22..f9798eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ | 主题 | `src/themes//` | `rules/theme-guide.md` | | 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` | | 画布 | `src/prototypes//canvas.excalidraw`、`canvas-assets/` | 原型画布和画布素材 | +| 业务逻辑规格 | `src/prototypes//.spec/.md` | `rules/business-logic-documentation-guide.md` | +| 对象存储发布 URL | `.axhub/make/axhub.config.json` → `cloudPublishing.s3` | `rules/cloud-publish-url-guide.md` | | 原型标注布局 | `src/common/prototype-annotation-host.tsx` | `rules/prototype-annotation-layout-guide.md` | ## ⚠️ 重要原则 @@ -41,6 +43,11 @@ 6. **讲人话,用户不懂技术** - 用用户能理解的方式说明取舍、风险和结果;用户无法执行 CLI 命令,不得省略验收流程 - 向用户请求反馈或验收时,提醒用户尽量提供截图、预览链接、页面路径或具体问题位置,便于准确定位和复现 +7. **复杂业务逻辑必须文档化** + - 多步判定、跨模块校验、状态/公式推导、导入规则等,除代码外必须写入 `.spec/*.md` 与 `requirements-prd.md`,并同步标注目录(见 `rules/business-logic-documentation-guide.md`) + - 不得默认「先实现、后补文档」;逻辑变更与 PRD/标注更新同一轮交付 +8. **对象存储发布 URL 与 Make 工具一致** + - 发布或写链接时格式为 `{baseUrl}/{prototype-id}/index.html`;禁止加 `prototypes/` 前缀或去掉 `index.html`(见 `rules/cloud-publish-url-guide.md`) ## 项目结构 diff --git a/CLAUDE.md b/CLAUDE.md index d42b985..56bcfa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ | 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` | | UI Review 结论 | `src/prototypes//.spec/ui-review.md` | `rules/ui-review-guide.md` | | 原型 Review 结论 | `src/prototypes//.spec/prototype-review.md` | `rules/prototype-review-guide.md` | +| 业务逻辑规格 | `src/prototypes//.spec/.md` | `rules/business-logic-documentation-guide.md` | +| 对象存储发布 URL | `.axhub/make/axhub.config.json` → `cloudPublishing.s3` | `rules/cloud-publish-url-guide.md` | | ACP 对话缓存 | `src/prototypes//.spec/acp/` | 本地私有运行数据,不提交、不导出、不发布 | ## ⚠️ 重要原则 @@ -43,6 +45,10 @@ - 产品需求、设计方案或原型应尽早交给用户确认,不要等到全部完成后才暴露方向问题 6. **讲人话,用户不懂技术** - 用用户能理解的方式说明取舍、风险和结果;用户无法执行 CLI 命令,不得省略验收流程 +7. **复杂业务逻辑必须文档化** + - 多步判定、跨模块校验、状态/公式推导、导入规则等,除代码外必须写入 `.spec/*.md` 与 `requirements-prd.md`,并同步标注目录(见 `rules/business-logic-documentation-guide.md`) +8. **对象存储发布 URL 与 Make 工具一致** + - `{baseUrl}/{prototype-id}/index.html`;禁止擅自改路径后缀(见 `rules/cloud-publish-url-guide.md`) ## 项目结构 diff --git a/rules/business-logic-documentation-guide.md b/rules/business-logic-documentation-guide.md new file mode 100644 index 0000000..4c2aa28 --- /dev/null +++ b/rules/business-logic-documentation-guide.md @@ -0,0 +1,135 @@ +# 业务逻辑文档化指南(全局) + +凡原型或页面实现**复杂判断逻辑、跨模块校验、状态/公式推导、导入导出规则**时,除代码外**必须**同步写入 Markdown 与 PRD,供评审、标注目录与后续 Agent 读取。 + +参考范例:`src/prototypes/lease-business-detail/.spec/field-checks.md` + `requirements-prd.md` §5 单元格系统校验。 + +--- + +## 何时必须文档化 + +满足以下**任一**条件,即视为「复杂业务逻辑」,不得只留在代码或口头说明: + +| 类型 | 示例 | +|------|------| +| 多步判定 / 优先级链 | 客户名称 5 步校验、收款状态三态 | +| 跨模块对照 | 对照客户管理、车辆管理、合同台账 | +| 公式 / 聚合口径 | KPI、表尾 SUBTOTAL、应收租金 = 租金 × 月数 | +| 状态机 | 未收款 → 部分收款 → 已结清 | +| 导入 / 保存拦截 | 重复键策略、实收 ≤ 应收 | +| 权限 / 角色分支 | 主管可改、数据范围(若实现) | +| 一键修复 / 建议值 | 校验警告 + 替换动作 | + +纯样式、文案、布局微调**不**触发本指南;但若改动会改变上述判定结果,必须更新文档。 + +--- + +## 文档落点(三层) + +```text +src/prototypes// +├── .spec/requirements-prd.md # ① PRD:摘要 + 验收项(产品可读) +├── .spec/.md # ② 规格:完整判定顺序、数据源、边界(开发/评审) +├── annotation-source.json # ③ 标注:目录节点 + 页面/字段标注(经同步脚本) +└── columnHeaderTips.ts 等 # 可选:表头/字段级一句话提示 +``` + +### ① PRD(`requirements-prd.md`) + +在对应功能章节增加**摘要**,至少包含: + +- 业务目的(一句话) +- 判定顺序或状态表(表格优先) +- 数据源 / 对照模块 +- 用户可见结果(图标、文案、是否可一键修复) +- 链接到 `.spec/.md` 全文 + +在 **§验收重点** 增加可勾选项。 + +### ② 规格 Markdown(`.spec/.md`) + +完整规则文档,建议结构: + +```markdown +# <模块> · <逻辑名称> + +> 实现:`utils/xxx.ts`;UI:`components/xxx.tsx` + +## 对照数据源 +## 交互与状态(ok / warn / none) +## <字段或场景 A> 判定顺序(表格) +## <字段或场景 B> … +## 启用范围 / 与代码映射 +``` + +要求: + +- **判定顺序**写清优先级(命中即返回) +- **前置条件**写清何时不校验 +- **弱校验 / 强校验**区分标注 +- 标明原型种子 vs 真实 API(若未接后端) + +### ③ 标注目录(`annotation-source.json`) + +通过 `scripts/sync-annotation-directory.mjs`(或等价脚本)同步: + +| 同步目标 | 内容 | +|----------|------| +| `directory` → 规格说明 | 全文或 `markdownPath` 指向 `.spec/*.md` | +| 页面标注节点 | 功能区摘要(如列表工具栏、明细表) | +| 字段标注节点 | 单字段判定摘要(如客户名称) | +| `markdownMap` | 与节点 `annotationText` 一致 | + +**禁止**:代码已改、PRD/标注仍写旧逻辑。 + +--- + +## 实施顺序(与开发绑定) + +```text +实现 / 修改业务逻辑代码 + ↓ +编写或更新 .spec/.md + ↓ +更新 requirements-prd.md 摘要 + 验收项 + ↓ +更新表头提示 / 字段标注文案(如有) + ↓ +运行 sync-annotation-directory.mjs + ↓ +验收:目录与 PRD 可读、与页面行为一致 +``` + +逻辑变更与文档变更**同一轮交付**;不得「先合代码、后补文档」作为默认流程。 + +--- + +## 最小检查清单 + +- [ ] 复杂逻辑有独立 `.spec/*.md` 或 PRD 内等价完整章节 +- [ ] PRD 摘要含判定顺序表 / 状态表 +- [ ] PRD §验收 含可验证条目 +- [ ] `annotation-source.json` 目录已同步 +- [ ] 关键字段在「字段标注」或表头提示有一句业务说明 +- [ ] 文档标明数据来源(种子 / API / 手工列) +- [ ] 代码路径在 PRD §源码入口 或规格文首注明 + +--- + +## 反例 + +| ❌ 不做 | ✅ 应做 | +|--------|--------| +| 只在 `utils/field-checks.ts` 里写 5 层 if | 同步 `field-checks.md` + PRD §5 摘要 | +| 评审时口头解释客户名校验 | 目录「单元格系统校验」可点开全文 | +| 改判定顺序不更新 PRD | 同 PR 更新 `.spec` + 跑 sync 脚本 | +| 表头提示与真实逻辑矛盾 | 以 `.spec` 为单一真相,提示引用摘要 | + +--- + +## 关联规则 + +- 产品需求对齐:`rules/requirements-alignment-guide.md` +- 原型开发验收:`rules/prototype-development-guide.md` +- 标注布局:`rules/prototype-annotation-layout-guide.md` +- Agent 门禁:根目录 `AGENTS.md` diff --git a/rules/cloud-publish-url-guide.md b/rules/cloud-publish-url-guide.md new file mode 100644 index 0000000..3477242 --- /dev/null +++ b/rules/cloud-publish-url-guide.md @@ -0,0 +1,86 @@ +# 对象存储发布 URL 规范(全局) + +Make 客户端「发布到对象存储」与脚本/Agent 批量发布**必须使用同一套公网路径**,避免维护两套链接。 + +配置来源:`.axhub/make/axhub.config.json` → `cloudPublishing.s3` + +--- + +## 标准 URL 形态 + +```text +{baseUrl}/{objectPrefix}/index.html +``` + +| 项 | 默认值 | 示例 | +|---|---|---| +| `baseUrl` | `https://prototype.lnoneos.com` | — | +| 原型页 `objectPrefix` | `{prototype-id}` | `lease-business-detail` | +| 主题页 `objectPrefix` | `themes/{theme-id}` | `themes/claude` | +| 入口文件名 | **固定** `index.html` | 不得改为 `/`、`.html` 省略或 `index.htm` | + +**租赁业务明细(范例)** + +```text +https://prototype.lnoneos.com/lease-business-detail/index.html +``` + +--- + +## 禁止的改写 + +| ❌ 禁止 | 说明 | +|--------|------| +| 加 `prototypes/` 前缀 | 不得发布为 `/prototypes/lease-business-detail/index.html` | +| 去掉 `index.html` | 不得改为目录根 `/lease-business-detail/` | +| 改 `baseUrl` 域名 | 以 `axhub.config.json` 的 `s3.baseUrl` 为准 | +| 自造路径别名 | 仅 `pathAliases` 中已声明的 id 可例外 | + +--- + +## pathAliases(例外) + +仅在 `axhub.config.json` → `cloudPublishing.s3.pathAliases` 中显式配置时覆盖默认前缀,例如: + +```json +"pathAliases": { + "oneos-prototype-nav": "oneos-prototype-nav" +} +``` + +新增别名须**同时**更新: + +1. `axhub.config.json` → `pathAliases` +2. `src/prototypes/oneos-prototype-nav/nav-data.ts` → `CLOUD_PUBLISH_PATH_ALIASES` +3. `prototype-registry.json` 变更说明(如有) + +--- + +## 实现对照 + +| 场景 | 路径解析 | +|------|----------| +| Make 客户端发布 | `/{prototype-id}/index.html` | +| `scripts/publish-all-to-s3.mjs` | `resolveObjectPrefix()` → 同上 | +| 导航页 `resolvePrototypeHref()` | 云主机上 `/{segment}/index.html` | +| 本地开发 | `/prototypes/{prototype-id}`(仅 dev,不是发布 URL) | + +--- + +## Agent / AI 发布检查清单 + +发布或向用户回报链接前: + +- [ ] 使用 `axhub.config.json` 中的 `baseUrl` +- [ ] 原型链接以 `/index.html` 结尾 +- [ ] 未擅自添加 `prototypes/` 前缀 +- [ ] 与 Make 工具弹窗中的 URL 逐字一致 +- [ ] 未将 dev 路径(`/prototypes/...`)当作对象存储链接写入 PRD/导航 + +--- + +## 关联文件 + +- `scripts/publish-all-to-s3.mjs` — `resolveObjectPrefix`、`resolvePublicUrl` +- `src/prototypes/oneos-prototype-nav/nav-data.ts` — 静态站跳转 +- `.cursor/rules/cloud-publish-url.mdc` — Agent 强制规则 diff --git a/rules/global-design-spec.md b/rules/global-design-spec.md index 9c19dfc..b1c9609 100644 --- a/rules/global-design-spec.md +++ b/rules/global-design-spec.md @@ -1,5 +1,7 @@ # ONE-OS 全局设计规范(开发版) +> **对外分享请使用**:[`src/resources/design-system/`](../src/resources/design-system/README.md)(含 `DESIGN.md`、`tokens.json`、AI 提示模板)。本文件为项目内 Agent 规则副本。 + | 项 | 说明 | |---|---| | 文档版本 | v1.0 | @@ -144,23 +146,53 @@ import '../vehicle-management/style.css'; | 元素 | 类名 | 规格 | |---|---|---| -| 容器 | `vm-filter-card` | 白底、12px 圆角、1px 边框、轻阴影 | +| 容器 | `vm-filter-card`(兼容 `lc-filter-card`) | 白底、12px 圆角、1px 边框、轻阴影 | | 标题 | `vm-filter-title` | 「筛选条件」,1.125rem / 600 | | 栅格 | `vm-filter-grid` | 默认 4 列;1280px 以下 2 列;640px 以下 1 列 | | 字段 | `vm-filter-field` | 标签在上、控件在下;标签 12px / muted | -| 操作区 | `vm-filter-actions` | 顶部分割线;右对齐「重置」「查询」 | +| 操作区 | `vm-filter-actions` | 顶部分割线;左侧「更多筛选/收起」,右侧「重置」「查询」 | -**日期区间(强制)** +**参考实现(强制对齐)**:[`lease-business-detail/components/FilterPanel.tsx`](../src/prototypes/lease-business-detail/components/FilterPanel.tsx) + +#### 4.1.1 筛选提示文本(placeholder)— 强制统一 + +筛选卡内所有空态提示(输入框、FilterPicker、日期区间、Ant Select)必须一致: + +| 属性 | Token / 值 | +|---|---| +| 字体 | `var(--vm-font)` | +| 字号 | `0.875rem`(14px) | +| 字重 | `400` | +| 颜色 | `var(--ln-muted-soft)`(`#a1a1aa`) | +| 透明度 | `opacity: 1`(覆盖 Ant Select 默认半透明) | + +样式落点:`vehicle-management/style.css` 中 `.vm-filter-card` / `.lc-filter-card` 下的 `::placeholder` 与 `.ant-select-selection-placeholder`。 +**禁止**各页单独改占位色、字号或字重。 + +#### 4.1.2 日期区间(强制) - 使用 `DateRangeFilterField`(`vehicle-management/components/DateRangeFilterField`)。 - 展示分隔符为中文「**至**」。 - 类名:`vm-date-range-field`、`vm-filter-picker-control`。 +- 弹层必须 portal + `fixed`(避免滚动容器错位)。 - **禁止**筛选区用 Ant `RangePicker`、双 `` 等替代方案。 -**折叠筛选** +#### 4.1.3 更多筛选 / 收起(强制) -- 超过 4 项时其余放入 `vm-filter-expand`(见 `src/common/vm-filter-panel.ts`)。 -- 切换按钮:`vm-filter-toggle` + 角标 `vm-filter-toggle-badge`。 +超过 4 项时必须折叠;实现与物流业务明细相同: + +| 项 | 规则 | +|---|---| +| 拆分 | `splitFilterFields()` / `shouldShowFilterExpand()`([`src/common/vm-filter-panel.ts`](../src/common/vm-filter-panel.ts)) | +| 首行 | 最多 4 项;**禁止**首行 5 项导致第二行仅 1 项 | +| 展开区 | `vm-filter-expand` + `is-expanded`;内层 `vm-filter-expand-inner` + 独立 `vm-filter-grid` | +| 文案 | 仅「更多筛选」/「收起」(禁用「展开」) | +| 按钮 | `vm-btn vm-btn-link vm-filter-toggle`;`data-vm-icon` 为 `filter` / `chevron-up` | +| 角标 | 收起且扩展项有值时显示 `vm-filter-toggle-badge`(主色底、白字) | +| 操作区布局 | 切换在左(`margin-right: auto`),重置/查询在右 | +| 别名 | `ldb-filter-expand` / `ldb-filter-toggle` 等同 `vm-filter-*`;**新代码优先 `vm-filter-*`** | + +新建或改动列表页必须遵循;旧页触及时迁移。 ### 4.2 输入框 `.vm-input` @@ -170,15 +202,16 @@ import '../vehicle-management/style.css'; | 表单页 | min 44px | 1rem(移动端友好) | - 边框:`--ln-hairline-strong`;focus 时主题色边框。 -- 占位符:`--ln-muted-soft`。 +- 占位符:见 §4.1.1(`--ln-muted-soft`,0.875rem / 400)。 ### 4.3 下拉选择 `.vm-filter-picker-*` | 元素 | 说明 | |---|---| -| `vm-filter-picker-control` | 触发器,与输入框同高 | +| `vm-filter-picker-control` | 触发器,与输入框同高(32px) | | `vm-filter-picker-popover` | 下拉面板,卡片圆角 + hover 阴影 | | `vm-filter-picker-option.checked` | 主色浅底 + 深绿字 | +| 占位符 | 见 §4.1.1;与日期区间、Ant Select 同色同字号 | ### 4.4 按钮 `.vm-btn` @@ -391,7 +424,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/rules/prototype-development-guide.md b/rules/prototype-development-guide.md index 692bce3..378067f 100644 --- a/rules/prototype-development-guide.md +++ b/rules/prototype-development-guide.md @@ -105,6 +105,18 @@ export default function MyApp() { 参考:`vehicle-management/index.tsx`、`lease-business-ledger/index.tsx`。 +## 业务逻辑文档化(强制) + +实现**复杂判断逻辑**(多步判定、跨模块校验、状态/公式推导、导入保存规则等)时,必须与代码**同一轮**更新文档。详见 `rules/business-logic-documentation-guide.md`。 + +```text +.spec/.md # 完整规则(判定顺序、数据源、代码路径) +.spec/requirements-prd.md # 摘要 + 验收项 +annotation-source.json # sync-annotation-directory.mjs 同步目录/字段标注 +``` + +范例:`lease-business-detail/.spec/field-checks.md`。 + ## 验收流程 运行原型验收脚本: @@ -132,3 +144,4 @@ node scripts/check-app-ready.mjs /prototypes/[原型目录] - [ ] 占位原型已更新为有意义的目录名和显示名。 - [ ] 新增依赖已写入 `package.json`。 - [ ] `check-app-ready.mjs` 原型验收通过。 +- [ ] 若含复杂业务逻辑:`.spec` 规格文 + PRD 摘要 + 标注目录已同步(见 `business-logic-documentation-guide.md`)。 diff --git a/rules/requirements-alignment-guide.md b/rules/requirements-alignment-guide.md index c281407..e7027e7 100644 --- a/rules/requirements-alignment-guide.md +++ b/rules/requirements-alignment-guide.md @@ -46,6 +46,7 @@ - 本次范围、功能清单和不做什么。 - 页面或资源的核心内容、数据来源和素材来源。 - 关键状态、核心路径和必要交互。 +- **复杂判定逻辑**(校验链、状态机、公式口径、导入规则):在 PRD 中写摘要,并规划 `.spec/.md` 全文(见 `rules/business-logic-documentation-guide.md`)。 - 用户最终如何判断结果可用。 只问会影响范围、成本或验收的问题。能从上下文推断的内容直接记为假设继续;如果缺失信息会导致不同功能范围、不同页面结构或不同验收标准,必须先问。 diff --git a/scripts/merge-registry-sidebar-conflicts.mjs b/scripts/merge-registry-sidebar-conflicts.mjs new file mode 100644 index 0000000..8074e8e --- /dev/null +++ b/scripts/merge-registry-sidebar-conflicts.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * 合并 merge 冲突中的 prototype-registry.json 与 sidebar-tree.json(保留两侧内容)。 + * 用法:node scripts/merge-registry-sidebar-conflicts.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); + +function gitShow(stage, filePath) { + return execSync(`git show :${stage}:${filePath}`, { + cwd: projectRoot, + encoding: 'utf8', + }); +} + +function readJsonFromGit(stage, relativePath) { + return JSON.parse(gitShow(stage, relativePath)); +} + +function writeJson(relativePath, value) { + const abs = path.join(projectRoot, relativePath); + fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function collectItemKeys(items, acc = new Set()) { + for (const item of items || []) { + if (item?.itemKey) acc.add(item.itemKey); + if (Array.isArray(item?.children)) collectItemKeys(item.children, acc); + } + return acc; +} + +function findItemByKey(items, itemKey) { + for (const item of items || []) { + if (item?.itemKey === itemKey) return item; + if (Array.isArray(item?.children)) { + const found = findItemByKey(item.children, itemKey); + if (found) return found; + } + } + return null; +} + +function findFolder(items, title) { + for (const item of items || []) { + if (item?.kind === 'folder' && item?.title === title) return item; + if (Array.isArray(item?.children)) { + const found = findFolder(item.children, title); + if (found) return found; + } + } + return null; +} + +function ensureChildFolder(parent, folderTitle, folderId) { + parent.children = parent.children || []; + let folder = parent.children.find((c) => c.kind === 'folder' && c.title === folderTitle); + if (!folder) { + folder = { + id: folderId, + kind: 'folder', + title: folderTitle, + children: [], + }; + parent.children.push(folder); + } + folder.children = folder.children || []; + return folder; +} + +function ensureItem(folder, item) { + if (!item?.itemKey) return; + const exists = folder.children.some((c) => c.itemKey === item.itemKey); + if (!exists) folder.children.push(JSON.parse(JSON.stringify(item))); +} + +function mergeRegistry() { + const relativePath = 'src/prototypes/oneos-prototype-nav/prototype-registry.json'; + const ours = readJsonFromGit(2, relativePath); + const theirs = readJsonFromGit(3, relativePath); + + const merged = { + version: 1, + updatedAt: new Date().toISOString(), + prototypes: { ...theirs.prototypes }, + recentUpdates: [], + }; + + for (const key of ['oneos-web-h2-station-weekly', 'oneos-web-h2-station-analysis']) { + if (ours.prototypes[key]) merged.prototypes[key] = ours.prototypes[key]; + } + + const seen = new Set(); + const combinedRecent = [...(ours.recentUpdates || []), ...(theirs.recentUpdates || [])]; + for (const entry of combinedRecent) { + const sig = `${entry.prototypeId}|${entry.version}|${entry.date}|${entry.time}`; + if (seen.has(sig)) continue; + seen.add(sig); + merged.recentUpdates.push(entry); + } + merged.recentUpdates = merged.recentUpdates.slice(0, 40); + + writeJson(relativePath, merged); + return { + prototypeCount: Object.keys(merged.prototypes).length, + recentCount: merged.recentUpdates.length, + hasXll: Boolean(merged.prototypes['xll-miniapp']), + h2AnalysisVersion: merged.prototypes['oneos-web-h2-station-analysis']?.version, + }; +} + +function mergeSidebar() { + const relativePath = '.axhub/make/sidebar-tree.json'; + const ours = readJsonFromGit(2, relativePath); + const theirs = readJsonFromGit(3, relativePath); + + const merged = JSON.parse(JSON.stringify(theirs)); + merged.updatedAt = new Date().toISOString(); + + const oursKeys = collectItemKeys(ours.prototypes); + const mergedKeys = collectItemKeys(merged.prototypes); + const localOnlyKeys = [ + 'prototypes/oneos-web-h2-station-weekly', + 'prototypes/oneos-web-h2-station-analysis', + ].filter((key) => oursKeys.has(key) && !mergedKeys.has(key)); + + const oneOsFolder = findFolder(merged.prototypes, 'OneOS'); + const h2Folder = oneOsFolder ? findFolder(oneOsFolder.children, '加氢站管理') : findFolder(merged.prototypes, '加氢站管理'); + + if (h2Folder) { + for (const itemKey of localOnlyKeys) { + const source = + findItemByKey(ours.prototypes, itemKey) || + ({ + 'prototypes/oneos-web-h2-station-weekly': { + id: 'item:prototypes:oneos-web-h2-station-weekly', + kind: 'item', + title: '站点周报统计', + itemKey: 'prototypes/oneos-web-h2-station-weekly', + }, + 'prototypes/oneos-web-h2-station-analysis': { + id: 'item-prototypes-oneos-web-h2-station-analysis', + kind: 'item', + title: '加氢站分析', + itemKey: 'prototypes/oneos-web-h2-station-analysis', + }, + }[itemKey]); + ensureItem(h2Folder, source); + } + } + + writeJson(relativePath, merged); + return { + addedKeys: localOnlyKeys, + itemCount: collectItemKeys(merged.prototypes).size, + }; +} + +const registry = mergeRegistry(); +const sidebar = mergeSidebar(); +console.log('[merge] prototype-registry.json', registry); +console.log('[merge] sidebar-tree.json', sidebar); diff --git a/scripts/publish-all-to-s3.mjs b/scripts/publish-all-to-s3.mjs index 57dcddd..2d92b5c 100644 --- a/scripts/publish-all-to-s3.mjs +++ b/scripts/publish-all-to-s3.mjs @@ -7,6 +7,7 @@ * node scripts/publish-all-to-s3.mjs * node scripts/publish-all-to-s3.mjs --dry-run * node scripts/publish-all-to-s3.mjs --group prototypes + * node scripts/publish-all-to-s3.mjs --ids vehicle-management,oneos-prototype-nav */ import fs from 'node:fs'; import os from 'node:os'; @@ -26,6 +27,7 @@ function parseArgs(argv) { adminOrigin: '', dryRun: false, group: 'all', + ids: [], includeSource: true, }; for (let i = 0; i < argv.length; i += 1) { @@ -33,6 +35,9 @@ function parseArgs(argv) { if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim(); else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim(); else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim(); + else if (arg === '--ids' && argv[i + 1]) { + args.ids = argv[++i].split(',').map((id) => id.trim()).filter(Boolean); + } else if (arg === '--dry-run') args.dryRun = true; else if (arg === '--no-source') args.includeSource = false; } @@ -69,8 +74,9 @@ function resolveObjectPrefix(entry, pathAliases = {}) { if (alias) return String(alias).replace(/^\/+|\/+$/gu, ''); const normalized = entry.path.replace(/^\/+|\/+$/gu, ''); - const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u); - if (prototypeMatch?.[1]) return `prototypes/${prototypeMatch[1]}`; + const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/([^/]+)$/u); + // 与 Make 客户端「发布到对象存储」一致:/{prototype-id}/index.html(不加 prototypes/ 前缀) + if (prototypeMatch?.[1]) return prototypeMatch[1]; const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u); if (themeMatch?.[1]) return `themes/${themeMatch[1]}`; return entry.id; @@ -227,7 +233,15 @@ async function main() { const adminOrigin = resolveAdminOrigin(args.adminOrigin); const s3Config = readS3Config(); const includeSource = args.includeSource && s3Config.includeSource; - const entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases); + let entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases); + if (args.ids.length > 0) { + const idSet = new Set(args.ids); + entries = entries.filter((entry) => idSet.has(entry.id)); + const missing = args.ids.filter((id) => !entries.some((entry) => entry.id === id)); + if (missing.length > 0) { + throw new Error(`未找到原型: ${missing.join(', ')}`); + } + } console.log(`Admin: ${adminOrigin}`); console.log(`Bucket: ${s3Config.bucket}`); diff --git a/scripts/sync-oneos-web-prototypes.mjs b/scripts/sync-oneos-web-prototypes.mjs index f7d32e9..3bc5ee7 100644 --- a/scripts/sync-oneos-web-prototypes.mjs +++ b/scripts/sync-oneos-web-prototypes.mjs @@ -23,9 +23,10 @@ const XLL_MODULES = [ /** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */ const STANDALONE_MODULES = [ { slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true }, + { slug: 'customer-payment-collection', title: '客户回款情况', skipCodegen: true }, { slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true }, { slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true }, - { slug: 'oneos-web-h2-station-weekly', title: '站点周报统计', skipCodegen: true }, + { slug: 'oneos-web-h2-station-stats', title: '加氢站数量统计', skipCodegen: true }, { slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true }, { slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true }, { slug: 'oneos-web-approval', title: '审批中心', skipCodegen: true }, @@ -61,7 +62,7 @@ const MODULES = [ { slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' }, { slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' }, { slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'], excludeFiles: ['站点信息.jsx'] }, - { slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx'] }, + { slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx', '客户回款情况.jsx'] }, { slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'], excludeFiles: ['车辆维修明细.jsx'] }, { slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] }, { slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' }, diff --git a/scripts/sync-xll-nav-menu.mjs b/scripts/sync-xll-nav-menu.mjs index 55728db..567e2b0 100644 --- a/scripts/sync-xll-nav-menu.mjs +++ b/scripts/sync-xll-nav-menu.mjs @@ -24,6 +24,8 @@ const ROUTE_TO_PAGE = { 'third-return': 'third-return', replace: 'replace', 'audit-return': 'audit-return', + training: 'driver-training', + 'driver-training': 'driver-training', }; function parseArgs(argv) { diff --git a/src/common/OperationActions.tsx b/src/common/OperationActions.tsx index 7ece30b..04e7a45 100644 --- a/src/common/OperationActions.tsx +++ b/src/common/OperationActions.tsx @@ -5,12 +5,14 @@ import { ArrowRightLeft, Car, CircleDollarSign, + Download, Eye, FileText, MoreHorizontal, OctagonX, Pencil, RefreshCw, + Settings2, Trash2, Undo2, Upload, @@ -31,6 +33,7 @@ export type OperationActionItem = { const MORE_ACTION_ICONS: Record = { edit: Pencil, del: Trash2, + delete: Trash2, addVehicle: Car, renew: RefreshCw, authorized: FileText, @@ -41,6 +44,9 @@ const MORE_ACTION_ICONS: Record = { toFormal: ArrowRightLeft, stampSupplement: Upload, uploadStamped: Upload, + manage: Settings2, + preview: Eye, + download: Download, }; function renderMenuItemLabel(item: OperationActionItem) { diff --git a/src/common/h2DispenserBrandStore.js b/src/common/h2DispenserBrandStore.js new file mode 100644 index 0000000..8277bf2 --- /dev/null +++ b/src/common/h2DispenserBrandStore.js @@ -0,0 +1,150 @@ +/** + * 加氢机品牌 / 型号维护(原型本地 Store) + * 供「站点信息」维护、「加氢订单 H5」OCR 读取品牌型号线索。 + */ +(function initH2DispenserBrandStore(global) { + var STORAGE_KEY = 'oneos.h2.dispenserBrands.v1'; + + var SEED = [ + { id: 'db-1', brand: '海德利森', model: 'H2-D50', remark: '嘉兴站常用', updatedAt: '2026-07-10 09:00:00' }, + { id: 'db-2', brand: '海德利森', model: 'H2-D80', remark: '', updatedAt: '2026-07-10 09:00:00' }, + { id: 'db-3', brand: '舜华新能源', model: 'SH-H35', remark: '面板 OCR 模板 A', updatedAt: '2026-07-12 14:20:00' }, + { id: 'db-4', brand: '厚普股份', model: 'HP-H70', remark: '', updatedAt: '2026-07-14 11:05:00' }, + ]; + + function clone(list) { + return (list || []).map(function (row) { + return Object.assign({}, row); + }); + } + + function readRaw() { + try { + var raw = global.localStorage && global.localStorage.getItem(STORAGE_KEY); + if (!raw) return clone(SEED); + var parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || !parsed.length) return clone(SEED); + return parsed.map(function (row) { + return { + id: String(row.id || ''), + brand: String(row.brand || '').trim(), + model: String(row.model || '').trim(), + remark: String(row.remark || '').trim(), + updatedAt: String(row.updatedAt || ''), + }; + }).filter(function (row) { + return row.id && row.brand && row.model; + }); + } catch (e) { + return clone(SEED); + } + } + + function writeRaw(list) { + try { + if (global.localStorage) { + global.localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + } + } catch (e) { + /* ignore quota */ + } + } + + function nowText() { + 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()) + + ' ' + + pad(d.getHours()) + + ':' + + pad(d.getMinutes()) + + ':' + + pad(d.getSeconds()) + ); + } + + function listBrands() { + return clone(readRaw()).sort(function (a, b) { + var brandCmp = a.brand.localeCompare(b.brand, 'zh'); + if (brandCmp !== 0) return brandCmp; + return a.model.localeCompare(b.model, 'zh'); + }); + } + + function upsertBrand(payload) { + var brand = String(payload.brand || '').trim(); + var model = String(payload.model || '').trim(); + var remark = String(payload.remark || '').trim(); + if (!brand || !model) { + return { ok: false, message: '品牌与型号均为必填' }; + } + var list = readRaw(); + var id = String(payload.id || '').trim(); + var dup = list.some(function (row) { + return ( + row.brand === brand && + row.model === model && + row.id !== id + ); + }); + if (dup) { + return { ok: false, message: '已存在相同品牌与型号' }; + } + if (id) { + var found = false; + list = list.map(function (row) { + if (row.id !== id) return row; + found = true; + return { + id: row.id, + brand: brand, + model: model, + remark: remark, + updatedAt: nowText(), + }; + }); + if (!found) { + return { ok: false, message: '记录不存在' }; + } + } else { + list.push({ + id: 'db-' + Date.now() + '-' + Math.floor(Math.random() * 1000), + brand: brand, + model: model, + remark: remark, + updatedAt: nowText(), + }); + } + writeRaw(list); + return { ok: true, list: clone(list) }; + } + + function removeBrand(id) { + var next = readRaw().filter(function (row) { + return row.id !== id; + }); + writeRaw(next); + return { ok: true, list: clone(next) }; + } + + function brandModelLabels() { + return listBrands().map(function (row) { + return row.brand + ' · ' + row.model; + }); + } + + global.H2DispenserBrandStore = { + list: listBrands, + upsert: upsertBrand, + remove: removeBrand, + labels: brandModelLabels, + SEED: clone(SEED), + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/common/h2VehicleLedgerBridge.js b/src/common/h2VehicleLedgerBridge.js index 06cb1ec..f17ffe4 100644 --- a/src/common/h2VehicleLedgerBridge.js +++ b/src/common/h2VehicleLedgerBridge.js @@ -5,31 +5,44 @@ var H2_RECONCILE_RECONCILED = 'reconciled'; var H2_RECONCILE_PENDING = 'pending'; +var H2_VERIFY_VERIFIED = 'verified'; +var H2_VERIFY_UNVERIFIED = 'unverified'; +/** 记录来源:站端上传 | 羚牛上传(车辆氢费明细人工录入) */ +var H2_RECORD_SOURCE_STATION = 'station'; +var H2_RECORD_SOURCE_LINGNIU = 'lingniu'; var H2_STATION_CODE_MAP = { - '嘉兴加氢站(一期)': 'JX-H2-001', + '中国石油中油高新能源牙谷加油加氢站': 'JX-H2-001', '杭州临平加氢站': 'HZ-H2-002', '上海宝山加氢站': 'SH-H2-003', '苏州工业园区备用站': 'SZ-H2-004' }; -/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(10 条) */ -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 } -]; +var H2_STATION_CODE_SET = (function () { + var set = {}; + Object.keys(H2_STATION_CODE_MAP).forEach(function (name) { + set[H2_STATION_CODE_MAP[name]] = true; + }); + return set; +})(); -function h2BridgeCloneRows(rows) { - return (rows || []).map(function (r) { return Object.assign({}, r); }); -} +/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(含本站未核对 / 已核对未对账用例) */ +var H2_CANONICAL_LEDGER_SEED = [ + /* 嘉兴本站 · 列表首页用例:未核对(无核对时间)→ 已核对未对账 → 已核对已对账 */ + { 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 h2BridgeFormatDateTime(value) { if (!value) return ''; @@ -38,6 +51,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); @@ -47,6 +80,127 @@ function h2BridgeMatchStatementPatch(row, patch) { && (rowTime === patchTime || String(row.hydrogenTime || '') === patchTime); } +function h2BridgeInferRecordSource(row) { + if (!row) return H2_RECORD_SOURCE_LINGNIU; + if (row.recordSource === H2_RECORD_SOURCE_STATION || row.recordSource === H2_RECORD_SOURCE_LINGNIU) { + return row.recordSource; + } + var creator = String(row.creatorName || '').trim(); + var customer = String(row.customerName || ''); + if (creator === '站端账号' || customer.indexOf('·站端上报') >= 0) return H2_RECORD_SOURCE_STATION; + return H2_RECORD_SOURCE_LINGNIU; +} + +function h2BridgeNormalizeRow(row) { + if (!row) return row; + var next = Object.assign({}, row); + next.recordSource = h2BridgeInferRecordSource(next); + if (!Array.isArray(next.changeLogs)) next.changeLogs = []; + if (next.verifiedByName == null) next.verifiedByName = ''; + if (next.reconciledByName == null) next.reconciledByName = ''; + return next; +} + +function h2BridgeFormatLogScalar(value) { + if (value == null || value === '') return '—'; + if (typeof value === 'number' && isFinite(value)) { + return String(Math.round(value * 100) / 100); + } + return String(value); +} + +/** 站端/台账编辑时对比字段,生成变更日志条目(时间倒序由调用方 unshift) */ +function h2BridgeBuildFieldChangeLogs(prev, next, operatorName) { + if (!prev || !next) return []; + var fields = [ + { key: 'hydrogenTime', label: '加氢时间' }, + { key: 'plateNo', label: '车牌号' }, + { key: 'hydrogenKg', label: '加氢量' }, + { key: 'costUnitPrice', label: '氢气单价' }, + { key: 'costTotal', label: '加氢总额', alt: 'customerAmount' }, + { key: 'mileageKm', label: '里程' } + ]; + var logs = []; + var now = new Date(); + var nowStr = now.getFullYear() + '-' + h2BridgePad2(now.getMonth() + 1) + '-' + h2BridgePad2(now.getDate()) + + ' ' + h2BridgePad2(now.getHours()) + ':' + h2BridgePad2(now.getMinutes()) + ':' + h2BridgePad2(now.getSeconds()); + fields.forEach(function (f) { + var before = prev[f.key]; + var after = next[f.key]; + if (before == null && f.alt) before = prev[f.alt]; + if (after == null && f.alt) after = next[f.alt]; + var beforeText = h2BridgeFormatLogScalar(before); + var afterText = h2BridgeFormatLogScalar(after); + if (beforeText === afterText) return; + logs.push({ + id: 'clog-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8), + at: nowStr, + operatorName: operatorName || next.creatorName || '系统', + userName: operatorName || next.creatorName || '系统', + field: f.key, + fieldLabel: f.label, + oldValue: beforeText, + newValue: afterText, + before: beforeText, + after: afterText + }); + }); + return logs; +} + +function h2BridgeEnrichSeedDefaults(row) { + var next = h2BridgeNormalizeRow(row); + if (next.verifyStatus === H2_VERIFY_VERIFIED && !next.verifiedByName) { + next.verifiedByName = next.creatorName === '站端账号' ? '李业务' : (next.creatorName || '李业务'); + } + if (next.reconcileStatus === H2_RECONCILE_RECONCILED && !next.reconciledByName) { + next.reconciledByName = '系统管理员'; + } + if (next.key === 'rf-13' && (!next.changeLogs || !next.changeLogs.length)) { + next.changeLogs = [ + { + id: 'clog-seed-rf13-1', + at: '2026-07-14 11:20:00', + operatorName: '站端账号', + userName: '站端账号', + field: 'hydrogenKg', + fieldLabel: '加氢量', + oldValue: '13.50', + newValue: '14.00', + before: '13.50', + after: '14.00' + } + ]; + } + if (next.key === 'rf-1' && (!next.changeLogs || !next.changeLogs.length)) { + next.changeLogs = [ + { + id: 'clog-seed-rf1-1', + at: '2026-05-28 11:05:00', + operatorName: '张三', + userName: '张三', + field: 'costUnitPrice', + fieldLabel: '氢气单价', + oldValue: '42.00', + newValue: '42.50', + before: '42.00', + after: '42.50' + } + ]; + } + return next; +} + +function h2BridgeCloneRows(rows) { + return (rows || []).map(function (r) { + var cloned = Object.assign({}, r); + if (Array.isArray(r.changeLogs)) { + cloned.changeLogs = r.changeLogs.map(function (log) { return Object.assign({}, log); }); + } + return h2BridgeNormalizeRow(cloned); + }); +} + function h2BridgeApplyStatementPatches(rows, patches) { if (!patches || !patches.length) return h2BridgeCloneRows(rows); return (rows || []).map(function (r) { @@ -58,26 +212,95 @@ function h2BridgeApplyStatementPatches(rows, patches) { break; } } - if (!patch) return r; - return Object.assign({}, r, { + if (!patch) return h2BridgeNormalizeRow(r); + return h2BridgeNormalizeRow(Object.assign({}, r, { statementPatched: true, + reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: patch.reconcileDate, receiptDate: patch.receiptDate, stationPaymentStatus: patch.paymentStatus || 'paid', paymentStatus: patch.paymentStatus || 'paid', - reconciledAt: patch.reconcileDate - }); + statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId, + reconciledByName: patch.reconciler || patch.reconciledByName || r.reconciledByName || '' + })); }); } function h2BridgeBuildInitialRows() { var patches = (typeof window !== 'undefined' && window.H2_STATION_STATEMENT_LEDGER_UPDATES) || []; - return h2BridgeApplyStatementPatches(h2BridgeCloneRows(H2_CANONICAL_LEDGER_SEED), patches); + var seeded = (H2_CANONICAL_LEDGER_SEED || []).map(h2BridgeEnrichSeedDefaults); + return h2BridgeApplyStatementPatches(seeded, 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(); +} + +/** 解析站点编码:支持传入编码或站名(勿把站名原样当 id) */ +function h2BridgeResolveStationId(stationId, stationName) { + var a = String(stationId || '').trim(); + var b = String(stationName || '').trim(); + if (a && H2_STATION_CODE_MAP[a]) return H2_STATION_CODE_MAP[a]; + if (b && H2_STATION_CODE_MAP[b]) return H2_STATION_CODE_MAP[b]; + if (a && H2_STATION_CODE_SET[a]) return a; + if (b && H2_STATION_CODE_SET[b]) return b; + return a || ''; +} + +/** 手工台账种子:昨日已上传;今日默认未上传,便于演示拦截新增 */ +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) { @@ -91,6 +314,7 @@ function h2BridgeCreateStore() { state.rows = h2BridgeCloneRows(rows); notify(); }, + getManualLedgers: function () { return h2BridgeCloneManualLedgers(state.manualLedgers); }, subscribe: function (fn) { listeners.push(fn); return function () { @@ -101,6 +325,145 @@ function h2BridgeCreateStore() { if (!patches || !patches.length) return; state.rows = h2BridgeApplyStatementPatches(state.rows, patches); notify(); + }, + upsertRow: function (row) { + if (!row) return null; + var next = Object.assign({}, row); + if (!next.id && !next.key) { + next.id = 'rf-h5-' + Date.now() + '-' + Math.floor(Math.random() * 1000); + next.key = next.id; + } else { + next.id = next.id || next.key; + next.key = next.key || next.id; + } + if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING; + if (!next.verifyStatus) next.verifyStatus = H2_VERIFY_UNVERIFIED; + if (!next.recordSource) next.recordSource = h2BridgeInferRecordSource(next); + var found = false; + var saved = null; + state.rows = state.rows.map(function (r) { + if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) { + found = true; + var merged = h2BridgeNormalizeRow(Object.assign({}, r, next)); + if (!next.recordSource && r.recordSource) merged.recordSource = r.recordSource; + var operator = next.changeLogOperator || next.creatorName || r.creatorName || '系统'; + var fieldLogs = h2BridgeBuildFieldChangeLogs(r, merged, operator); + var prevLogs = Array.isArray(r.changeLogs) ? r.changeLogs.slice() : []; + if (Array.isArray(next.changeLogs) && next.replaceChangeLogs) { + merged.changeLogs = next.changeLogs.slice(); + } else { + merged.changeLogs = fieldLogs.concat(prevLogs); + } + saved = merged; + return merged; + } + return r; + }); + if (!found) { + saved = h2BridgeNormalizeRow(next); + if (!Array.isArray(saved.changeLogs)) saved.changeLogs = []; + state.rows = [saved].concat(state.rows); + } + notify(); + return Object.assign({}, saved); + }, + removeRow: function (id) { + var target = String(id || ''); + if (!target) return false; + var before = state.rows.length; + state.rows = state.rows.filter(function (r) { + return String(r.id || r.key || '') !== target; + }); + var changed = state.rows.length !== before; + if (changed) notify(); + return changed; + }, + 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); } }; } @@ -154,17 +517,39 @@ function h2BridgeMapToStationLedgerRow(row, index) { function h2BridgeMapToHrRecord(row, index, fillerNames) { var fillers = fillerNames || ['张三', '李四', '王静', '赵敏', '陈浩']; + var costTotal = row.costTotal != null ? row.costTotal : row.costAmount; + var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice; + var totalAmount = costTotal != null ? costTotal : row.customerAmount; + var reconcileTime = h2BridgeResolveReconcileTime(row); + var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(row.reconcileDate); + var verifiedAt = row.verifiedAt || ''; + var changeLogs = Array.isArray(row.changeLogs) + ? row.changeLogs.map(function (log) { return Object.assign({}, log); }) + : []; return { id: row.id || row.key || ('hr-' + (index + 1)), hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime), plateNo: row.plateNo, hydrogenKg: row.hydrogenKg, - customerAmount: row.customerAmount, + unitPrice: unitPrice, + totalAmount: totalAmount, + /* 兼容旧列名:金额同站端口径 */ + customerAmount: totalAmount, + costUnitPrice: unitPrice, + costTotal: totalAmount, mileageKm: row.mileageKm, fillerName: row.creatorName || fillers[index % fillers.length], settlementStatus: row.settlementStatus, stationName: row.stationName, - stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '' + stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '', + recordSource: h2BridgeInferRecordSource(row), + verifyStatus: row.verifyStatus === H2_VERIFY_VERIFIED ? H2_VERIFY_VERIFIED : H2_VERIFY_UNVERIFIED, + verifiedAt: verifiedAt ? h2BridgeFormatDateTime(verifiedAt) : '', + verifiedByName: row.verifiedByName || '', + reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING, + reconcileTime: isReconciled ? reconcileTime : '', + reconciledByName: row.reconciledByName || '', + changeLogs: changeLogs }; } @@ -251,6 +636,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(); @@ -263,6 +726,26 @@ 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); } }; } @@ -274,6 +757,12 @@ h2BridgeInit(); export { H2_CANONICAL_LEDGER_SEED, H2_STATION_CODE_MAP, + H2_RECONCILE_RECONCILED, + H2_RECONCILE_PENDING, + H2_VERIFY_VERIFIED, + H2_VERIFY_UNVERIFIED, + H2_RECORD_SOURCE_STATION, + H2_RECORD_SOURCE_LINGNIU, h2BridgeInit, h2BridgeGetStore, h2BridgeGetAllRefuelRecords, @@ -281,5 +770,11 @@ export { h2BridgeBuildStationLedgerStore, h2BridgeGetHrRecords, h2BridgeGetOrderRecords, - h2BridgeGetStationList + h2BridgeGetStationList, + h2BridgeNormalizePlate, + h2BridgeNormalizeHydrogenTime, + h2BridgeFindDuplicateRow, + h2BridgeTotalAmountMismatch, + h2BridgeLookupStationUnitPrice, + h2BridgeInferRecordSource }; diff --git a/src/common/ln-numeric.css b/src/common/ln-numeric.css index 08a2b86..b7ff33e 100644 --- a/src/common/ln-numeric.css +++ b/src/common/ln-numeric.css @@ -42,6 +42,9 @@ } .vm-kpi-val, +.lbd-kpi-val, +.bdl-kpi-val, +.sob-kpi-val, .vm-mileage, .vm-expire-date, .vm-handover-situation-line--mile, diff --git a/src/common/vm-filter-panel.ts b/src/common/vm-filter-panel.ts index 979b35f..7a3cbcf 100644 --- a/src/common/vm-filter-panel.ts +++ b/src/common/vm-filter-panel.ts @@ -1,7 +1,18 @@ /** * 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致) * - * 规则:筛选项超过 1 行(默认 4 项)时,其余收入折叠区,通过「更多筛选」展开。 + * 完整视觉与交互规范:`rules/global-design-spec.md` §4.1 + * 参考实现:`src/prototypes/lease-business-detail/components/FilterPanel.tsx` + * + * ## 布局约束 + * 1. 收起时:首行(primary)最多展示 `VM_FILTER_COLUMNS_PER_ROW`(默认 4)项,占满 1 行。 + * 2. 展开时:其余项放入 `vm-filter-expand`(`ldb-filter-expand` 为同义别名);primary 与 extra 各自使用 4 列栅格。 + * 3. 禁止「末行仅 1 项」:若展开区项数为 4n+1,从首行尾部借调 1 项到展开区(首行变为 3 项)。 + * 典型:总项数 5 → 首行 3 + 展开 2;总项数 6 → 首行 4 + 展开 2。 + * 4. 切换文案仅「更多筛选」/「收起」;按钮类名 `vm-btn vm-btn-link vm-filter-toggle`。 + * + * ## 占位符(筛选卡内强制) + * font: `var(--vm-font)`;size: 0.875rem;weight: 400;color: `var(--ln-muted-soft)`;opacity: 1 */ export const VM_FILTER_COLUMNS_PER_ROW = 4; @@ -12,13 +23,37 @@ export function splitFilterFields( fields: T[], primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT, ): { primary: T[]; extra: T[] } { - if (fields.length <= primaryCount) { + const columnsPerRow = primaryCount; + if (fields.length <= columnsPerRow) { return { primary: fields, extra: [] }; } - return { - primary: fields.slice(0, primaryCount), - extra: fields.slice(primaryCount), - }; + + const primary = fields.slice(0, columnsPerRow); + const extra = fields.slice(columnsPerRow); + + return rebalanceFilterSplit(primary, extra, columnsPerRow); +} + +/** 避免展开区出现 4n+1 项导致末行孤零零 1 项 */ +function rebalanceFilterSplit( + primary: T[], + extra: T[], + columnsPerRow: number, +): { primary: T[]; extra: T[] } { + const nextPrimary = [...primary]; + const nextExtra = [...extra]; + + while ( + nextExtra.length > 0 + && nextExtra.length % columnsPerRow === 1 + && nextPrimary.length > 0 + ) { + const moved = nextPrimary.pop(); + if (moved === undefined) break; + nextExtra.unshift(moved); + } + + return { primary: nextPrimary, extra: nextExtra }; } export function shouldShowFilterExpand( diff --git a/src/prototypes/business-dept-ledger/styles/index.css b/src/prototypes/business-dept-ledger/styles/index.css index 0a4b8fe..cd2fc41 100644 --- a/src/prototypes/business-dept-ledger/styles/index.css +++ b/src/prototypes/business-dept-ledger/styles/index.css @@ -7,6 +7,16 @@ padding-bottom: 48px; } +.vm-page.bdl-page.bdl-page--list-dense { + padding-left: 20px; + padding-right: 20px; +} + +.vm-page.bdl-page.bdl-page--list-dense .vm-table-section { + min-width: 0; + max-width: 100%; +} + .vm-page.bdl-page.bdl-page--list-dense .vm-filter-card { padding: 16px 20px 20px; margin-bottom: 16px; @@ -20,6 +30,301 @@ font-size: 1rem; } +/* —— 汇总 KPI 卡片(对齐租赁/物流业务明细 lbd-kpi / sob-kpi) —— */ +.vm-page.bdl-page .bdl-kpi-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-kpi-row { + margin-bottom: 12px; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-table-toolbar { + padding-bottom: 12px; +} + +@media (max-width: 900px) { + .vm-page.bdl-page .bdl-kpi-row { + grid-template-columns: 1fr; + } +} + +.vm-page.bdl-page .bdl-kpi-card { + --bdl-kpi-accent: var(--ln-primary); + --bdl-kpi-accent-soft: color-mix(in srgb, var(--bdl-kpi-accent) 12%, var(--ln-surface-card)); + --bdl-kpi-accent-border: color-mix(in srgb, var(--bdl-kpi-accent) 22%, var(--ln-hairline)); + --bdl-kpi-icon-bg: color-mix(in srgb, var(--bdl-kpi-accent) 14%, var(--ln-surface-card)); + --bdl-kpi-icon-color: var(--bdl-kpi-accent); + + position: relative; + display: flex; + min-height: 0; + border-radius: var(--ln-radius-control); + border: 1px solid var(--bdl-kpi-accent-border); + background: + linear-gradient( + 145deg, + var(--bdl-kpi-accent-soft) 0%, + var(--ln-surface-card) 58% + ); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + overflow: visible; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.vm-page.bdl-page .bdl-kpi-card:hover { + border-color: color-mix(in srgb, var(--bdl-kpi-accent) 38%, var(--ln-hairline)); + box-shadow: + 0 2px 6px rgba(15, 23, 42, 0.05), + 0 10px 24px rgba(15, 23, 42, 0.07); + transform: translateY(-1px); +} + +.vm-page.bdl-page .bdl-kpi-card-accent { + flex-shrink: 0; + width: 3px; + background: linear-gradient( + 180deg, + var(--bdl-kpi-accent) 0%, + color-mix(in srgb, var(--bdl-kpi-accent) 55%, transparent) 100% + ); +} + +.vm-page.bdl-page .bdl-kpi-card-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 6px 8px 6px 8px; +} + +.vm-page.bdl-page .bdl-kpi-card-foot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.vm-page.bdl-page .bdl-kpi-card-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.vm-page.bdl-page .bdl-kpi-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: var(--ln-radius-xs); + background: var(--bdl-kpi-icon-bg); + color: var(--bdl-kpi-icon-color); +} + +.vm-page.bdl-page .bdl-kpi-icon svg { + width: 14px; + height: 14px; +} + +.vm-page.bdl-page .bdl-kpi-label { + flex: 1; + min-width: 0; + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0; + font-size: 0.6875rem; + font-weight: 600; + color: var(--ln-muted); + line-height: 1.25; + letter-spacing: 0.01em; +} + +.vm-page.bdl-page .bdl-kpi-period { + font-weight: 500; + font-size: 0.625rem; + font-variant-numeric: tabular-nums; + color: color-mix(in srgb, var(--ln-muted) 88%, var(--ln-ink)); +} + +/* KPI、月份列、金额列:统一等宽数字字体(与 ldb-col-money 一致) */ +.vm-page.bdl-page .bdl-kpi-val, +.vm-page.bdl-page .bdl-kpi-period, +.vm-page.bdl-page .bdl-kpi-spark-tooltip-value, +.vm-page.bdl-page .bdl-list-table .bdl-col-month, +.vm-page.bdl-page .bdl-list-table .ldb-col-money { + font-family: var( + --vm-font-mono, + ui-monospace, + "JetBrains Mono", + SFMono-Regular, + Menlo, + Consolas, + monospace + ); + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + letter-spacing: 0; +} + +.vm-page.bdl-page .bdl-kpi-val { + margin: 0; + flex: 1 1 auto; + padding-left: 28px; + min-width: 0; + font-size: 1rem; + font-weight: 700; + color: var(--ln-ink); + line-height: 1.15; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-kpi-spark-wrap { + position: relative; + flex: 0 0 96px; + width: 96px; + height: 28px; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 4; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 108px; + max-width: 168px; + padding: 6px 8px; + border-radius: var(--ln-radius-xs); + background: var(--ln-ink); + color: #fff; + font-size: 0.625rem; + line-height: 1.35; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 4px 12px color-mix(in srgb, var(--ln-ink) 22%, transparent); + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease, visibility 0.12s ease; +} + +.vm-page.bdl-page .bdl-kpi-spark-wrap--active .bdl-kpi-spark-tooltip { + opacity: 1; + visibility: visible; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-scope { + display: block; + font-size: 0.5625rem; + font-weight: 500; + color: color-mix(in srgb, #fff 72%, transparent); + overflow: hidden; + text-overflow: ellipsis; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-row { + display: flex; + align-items: baseline; + gap: 4px; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-month { + font-weight: 600; +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-metric { + color: color-mix(in srgb, #fff 78%, transparent); +} + +.vm-page.bdl-page .bdl-kpi-spark-tooltip-value { + margin-left: auto; + font-weight: 700; +} + +.vm-page.bdl-page .bdl-kpi-sparkline { + display: block; + width: 100%; + height: 28px; + overflow: visible; + color: color-mix(in srgb, var(--ln-muted) 55%, transparent); +} + +.vm-page.bdl-page .bdl-kpi-spark-zero { + opacity: 0.35; +} + +.vm-page.bdl-page .bdl-kpi-spark-marker { + pointer-events: none; +} + +.vm-page.bdl-page .bdl-kpi-spark-hit { + cursor: crosshair; + outline: none; +} + +.vm-page.bdl-page .bdl-kpi-spark-hit:focus-visible { + outline: 1px solid var(--ln-focus, #2563eb); + outline-offset: -1px; +} + +@media (prefers-reduced-motion: reduce) { + .vm-page.bdl-page .bdl-kpi-spark-tooltip { + transition: none; + } +} + +.vm-page.bdl-page .bdl-kpi-val--profit { + color: var(--ln-error, #dc2626); +} + +.vm-page.bdl-page .bdl-kpi-val--loss { + color: var(--ln-success, #16a34a); +} + +.vm-page.bdl-page .bdl-kpi-card--income { + --bdl-kpi-accent: #2563eb; +} + +.vm-page.bdl-page .bdl-kpi-card--cost { + --bdl-kpi-accent: #7c3aed; +} + +.vm-page.bdl-page .bdl-kpi-card--profit { + --bdl-kpi-accent: #059669; +} + +.vm-page.bdl-page .bdl-kpi-card--profit.bdl-kpi-card--warn { + --bdl-kpi-accent: var(--ln-error, #dc2626); + --bdl-kpi-accent-soft: color-mix(in srgb, var(--ln-error, #dc2626) 10%, var(--ln-surface-card)); +} + +@media (prefers-reduced-motion: reduce) { + .vm-page.bdl-page .bdl-kpi-card { + transition: none; + } + + .vm-page.bdl-page .bdl-kpi-card:hover { + transform: none; + } +} + .vm-page.bdl-page .vm-filter-card.ldb-filter-card { margin-bottom: 16px; overflow: visible; @@ -84,6 +389,60 @@ padding: 0 0 16px; } +.vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar--with-view { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; +} + +@media (max-width: 900px) { + .vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar--with-view { + grid-template-columns: 1fr; + align-items: flex-start; + } +} + +.vm-page.bdl-page .bdl-list-view-switch { + flex-shrink: 0; +} + +.vm-page.bdl-page .bdl-view-tabs.ant-radio-group { + display: inline-flex; + width: auto; + gap: 0; + padding: 3px; + background: var(--ln-surface-strong); + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); +} + +.vm-page.bdl-page .bdl-view-tabs .ant-radio-button-wrapper { + flex: 0 0 auto; + min-width: 88px; + height: calc(var(--vm-control-height) - 6px); + line-height: calc(var(--vm-control-height) - 8px); + padding-inline: 14px; + text-align: center; + font-size: 13px; + font-weight: 500; + border: none !important; + border-radius: calc(var(--ln-radius-control) - 2px) !important; + background: transparent; + color: var(--ln-muted); + box-shadow: none !important; +} + +.vm-page.bdl-page .bdl-view-tabs .ant-radio-button-wrapper:not(:first-child)::before { + display: none; +} + +.vm-page.bdl-page .bdl-view-tabs .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { + font-weight: 600; + color: var(--ln-ink); + background: var(--ln-surface-card) !important; + box-shadow: var(--ln-shadow-soft) !important; +} + .vm-page.bdl-page .vm-table-toolbar.bdl-table-toolbar .vm-table-actions { margin-left: auto; } @@ -129,6 +488,12 @@ border: 1px solid var(--ln-hairline); border-radius: var(--ln-radius-card); background: var(--ln-surface-card); + box-shadow: var(--ln-shadow-soft); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-header { + box-shadow: 0 1px 0 var(--ln-hairline), 0 6px 16px -10px rgba(15, 23, 42, 0.14); + z-index: 5; } .vm-page.bdl-page .bdl-list-table.ant-table-wrapper, @@ -152,12 +517,30 @@ .vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td { white-space: nowrap; font-variant-numeric: tabular-nums; - padding: 8px 12px !important; + padding: 9px 12px !important; font-size: 13px; + line-height: 1.45; border-color: var(--ln-hairline) !important; box-sizing: border-box; overflow: visible; text-overflow: clip; + transition: background-color 0.15s ease; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr > th, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-tbody > tr:not(.ant-table-measure-row) > td, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-summary > tr > td { + padding: 7px 8px !important; + font-size: 12px; + line-height: 1.4; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:first-child > th { + font-size: 12px !important; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:nth-child(2) > th { + font-size: 11px !important; } .vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-thead > tr > th, @@ -165,6 +548,17 @@ .vm-page.bdl-page .bdl-list-table.ant-table-bordered .ant-table-summary > tr > td { border-inline-end: 1px solid var(--ln-hairline) !important; border-bottom: 1px solid var(--ln-hairline) !important; + border-top: none !important; + border-left: none !important; +} + +/* 分组表头:统一 1px 网格线,避免粗细分隔 */ +.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr > th { + border-color: var(--ln-hairline) !important; + border-inline-end-width: 1px !important; + border-bottom-width: 1px !important; + border-top-width: 0 !important; + border-left-width: 0 !important; } /* 分组表:第一行业务板块 */ @@ -174,7 +568,59 @@ font-size: 13px !important; font-weight: 600 !important; text-align: center; - border-bottom: 2px solid var(--ln-hairline-strong) !important; + border-bottom: 1px solid var(--ln-hairline) !important; + vertical-align: middle; +} + +.vm-page.bdl-page .bdl-sector-head-inner { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: center; + gap: 4px; + max-width: 100%; + line-height: 1.35; +} + +.vm-page.bdl-page .bdl-sector-head-label { + font-weight: 600; + color: var(--ln-ink); +} + +.vm-page.bdl-page .bdl-sector-head-source { + font-size: 11px; + font-weight: 400; + color: var(--ln-muted-soft); + white-space: nowrap; + padding: 1px 6px; + border-radius: var(--ln-radius-pill); + background: rgba(24, 24, 27, 0.04); +} + +.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:first-child > th.bdl-sector-group-head { + padding-top: 10px !important; + padding-bottom: 10px !important; + overflow: hidden; +} + +.vm-page.bdl-page .bdl-list-table--compact-cols .bdl-sector-head-source { + display: none; +} + +.vm-page.bdl-page .bdl-list-table--compact-cols .bdl-sector-head-inner { + justify-content: center; +} + +/* 业务板块分组:沿用统一 1px 网格,不再叠加加粗竖线 */ +.vm-page.bdl-page .bdl-list-table .bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:nth-child(2) > th.bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr > td.bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td.bdl-sector-edge, +.vm-page.bdl-page .bdl-list-table .bdl-summary-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:nth-child(2) > th.bdl-summary-edge, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td.bdl-summary-edge { + border-left: none !important; + border-inline-start: none !important; } /* 分组表:第二行 业绩/成本/盈亏 */ @@ -199,16 +645,53 @@ background: var(--ln-surface-card); } +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td { + background: var(--ln-divider-soft); +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td { background: var(--ln-canvas-soft) !important; } +.vm-page.bdl-page .bdl-list-table .bdl-col-month, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.bdl-col-month { + font-weight: 600; + color: var(--ln-ink); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month > td.bdl-col-month { + font-weight: 500; + color: var(--ln-muted-80); +} + +.vm-page.bdl-page .bdl-month-total-label { + font-weight: 700; + color: var(--ln-ink); +} + +.vm-page.bdl-page .bdl-list-table .bdl-customer-name-col { + color: var(--ln-ink); + font-weight: 500; + max-width: 168px; +} + +.vm-page.bdl-page .bdl-list-table .bdl-division-name-col { + color: var(--ln-ink); + font-weight: 500; + max-width: 148px; +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td, .vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td { font-weight: 700; background: var(--ln-surface-strong) !important; color: var(--ln-ink) !important; border-top: 2px solid var(--ln-hairline-strong) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total > td.bdl-col-month { + color: var(--ln-ink); } /* 冻结列(对齐 lc-list-table--nested) */ @@ -229,6 +712,11 @@ background: var(--ln-surface-card) !important; } +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-start, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-left { + background: var(--ln-divider-soft) !important; +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-start, .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-left { background: var(--ln-canvas-soft) !important; @@ -258,13 +746,27 @@ background: var(--ln-surface-card) !important; } +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt:hover > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar), +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt:hover > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) { + background: var(--ln-canvas-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar), +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) { + background: var(--ln-divider-soft) !important; +} + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar), .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.ant-table-cell-fix-right:not(.ant-table-cell-scrollbar) { background: var(--ln-canvas-soft) !important; } .vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-left-last::after { - box-shadow: inset 10px 0 8px -8px rgba(15, 23, 42, 0.12); + box-shadow: inset 1px 0 0 var(--ln-hairline); +} + +.vm-page.bdl-page .bdl-list-table .ant-table-thead .ant-table-cell-fix-left-last::after { + box-shadow: none; } .vm-page.bdl-page .bdl-list-table .ant-table-cell-fix-right-last::after, @@ -288,6 +790,7 @@ } .vm-page.bdl-page .bdl-list-table--nested .ant-table-cell-scrollbar { + display: none !important; width: 0 !important; min-width: 0 !important; max-width: 0 !important; @@ -300,18 +803,58 @@ overflow: hidden; } -.vm-page.bdl-page .bdl-list-table--nested .ant-table-content { - overflow: auto hidden; +/* 横向滚动交给 ant-table-body,勿在 content 层滚动(否则会破坏右侧冻结列) */ +.vm-page.bdl-page .bdl-list-table--nested .ant-table-body { + overflow: auto auto !important; } -/* 金额列最小宽度,避免合计行大额被裁切 */ +.vm-page.bdl-page .bdl-list-table--nested .ant-table-content { + overflow: visible !important; +} + +/* 金额列:宽度由 JS 按容器自适应,不在 CSS 中强制最小 128px */ .vm-page.bdl-page .bdl-list-table .col-amount, .vm-page.bdl-page .bdl-list-table .col-summary { - min-width: 128px; + min-width: 0; + letter-spacing: 0; } -.vm-page.bdl-page .bdl-list-table .col-summary { - min-width: 148px; +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:nth-child(2) > th.col-amount, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-thead > tr:nth-child(2) > th.col-amount-profit, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-tbody > tr:not(.ant-table-measure-row) > td.col-amount, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-tbody > tr:not(.ant-table-measure-row) > td.col-amount-profit, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-summary > tr > td.col-amount, +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .ant-table-summary > tr > td.col-amount-profit { + font-size: 10.5px !important; + line-height: 1.35; + padding-left: 5px !important; + padding-right: 5px !important; +} + +.vm-page.bdl-page.bdl-page--list-dense .bdl-list-table--fit-viewport .biz-standbook-perf-link { + font-size: inherit; +} + +/* 分组表头与表体铺满容器宽度,避免右侧留白 */ +.vm-page.bdl-page .bdl-list-table--fit-viewport.ant-table-wrapper, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-wrapper, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-container, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-content, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-sticky-holder, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-header { + width: 100% !important; + max-width: 100% !important; +} + +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-header table, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-header table[style], +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-body table, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-body table[style], +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-summary table, +.vm-page.bdl-page .bdl-list-table--fit-viewport .ant-table-summary table[style] { + width: 100% !important; + min-width: 100% !important; + table-layout: fixed; } .vm-page.bdl-page .bdl-list-table .bdl-amount-cell { @@ -323,116 +866,316 @@ .vm-page.bdl-page .bdl-list-table .biz-standbook-perf-link, .vm-page.bdl-page .biz-standbook-perf-link { cursor: pointer; - color: var(--ln-primary); + color: inherit; padding: 0; margin: 0; border: none; background: transparent; font: inherit; border-radius: 4px; - transition: background 0.2s, color 0.2s; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + transition: text-decoration-color 0.2s, text-decoration-thickness 0.2s; max-width: none; overflow: visible; text-align: inherit; white-space: nowrap; } -/* 钻取子页表格:复用主表边框规范 */ -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-thead > tr > th, -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-tbody > tr:not(.ant-table-measure-row) > td, -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table.ant-table-bordered .ant-table-summary > tr > td { - border-color: var(--ln-hairline) !important; - border-inline-end: 1px solid var(--ln-hairline) !important; - border-bottom: 1px solid var(--ln-hairline) !important; - padding: 8px 10px !important; - font-size: 13px; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table .ant-table-thead > tr > th { - background: var(--ln-canvas-soft) !important; - color: var(--ln-muted) !important; - font-weight: 500 !important; -} - -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table-wrap, -.vm-page.bdl-page .bdl-drill-card .biz-standbook-table { +/* 钻取子页表格容器 */ +.vm-page.bdl-page .bdl-drill-card .vm-table-wrap.bdl-drill-table-wrap { border: 1px solid var(--ln-hairline); border-radius: var(--ln-radius-card); overflow: hidden; + background: var(--ln-surface-card); +} + +.vm-page.bdl-page .bdl-drill-card .bdl-list-table .ant-table-header { + box-shadow: 0 1px 0 var(--ln-hairline), 0 6px 16px -10px rgba(15, 23, 42, 0.14); + z-index: 5; } .vm-page.bdl-page .biz-standbook-perf-link:hover { - color: var(--ln-primary-focus); - background: var(--ln-primary-soft); + text-decoration-thickness: 2px; } -.vm-page.bdl-page .biz-standbook-perf-link:focus { - outline: 2px solid var(--ln-primary); +.vm-page.bdl-page .biz-standbook-perf-link:focus-visible { + outline: 2px solid var(--ln-hairline-strong); outline-offset: 2px; } .vm-page.bdl-page .bdl-amount-neg { - color: #b91c1c; + color: var(--ln-success); font-weight: 600; } +.vm-page.bdl-page .bdl-amount-pos { + color: var(--ln-error); + font-weight: 600; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total .bdl-amount-pos, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-total .bdl-amount-neg, +.vm-page.bdl-page .bdl-list-table .ant-table-summary .bdl-amount-pos, +.vm-page.bdl-page .bdl-list-table .ant-table-summary .bdl-amount-neg { + font-weight: 700; +} + +/* 合计分组表头:右冻结三列 */ +.vm-page.bdl-page .bdl-list-table--grouped .ant-table-thead > tr:first-child > th.bdl-summary-group { + background: var(--ln-surface-strong) !important; + border-bottom: 2px solid var(--ln-hairline-strong) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:first-child > th.bdl-summary-group.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr:first-child > th.bdl-summary-group.ant-table-cell-fix-right { + z-index: 5; + background: var(--ln-surface-strong) !important; +} + +.vm-page.bdl-page .bdl-list-table .bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .bdl-summary-col.ant-table-cell-fix-right { + background: var(--ln-surface-card) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th.bdl-summary-col.ant-table-cell-fix-right { + z-index: 4; + background: var(--ln-canvas-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-alt > td.bdl-summary-col.ant-table-cell-fix-right { + background: var(--ln-divider-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.bdl-summary-col.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr.bdl-row-month:hover > td.bdl-summary-col.ant-table-cell-fix-right { + background: var(--ln-canvas-soft) !important; +} + +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr > td.bdl-summary-col, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr.row-total > td.ant-table-cell-fix-end, +.vm-page.bdl-page .bdl-list-table .ant-table-summary > tr.row-total > td.ant-table-cell-fix-right { + background: var(--ln-surface-strong) !important; + z-index: 3; +} + /* 钻取子页 */ .vm-page.bdl-page .bdl-drill-card { - padding: 16px 20px 20px; + padding: 0; + overflow: hidden; } .vm-page.bdl-page .bdl-drill-section { margin-top: 0; } -.vm-page.bdl-page .bdl-drill-toolbar { - align-items: center; - padding-bottom: 16px; - display: grid; - grid-template-columns: auto 1fr auto; - gap: 12px 16px; -} - -.vm-page.bdl-page .bdl-drill-toolbar-spacer { - width: 88px; -} - -.vm-page.bdl-page .bdl-drill-toolbar--inline { +.vm-page.bdl-page .bdl-drill-header { display: flex; - flex-wrap: wrap; - gap: 12px 16px; - padding-bottom: 16px; + align-items: flex-start; + gap: 14px 18px; + padding: 16px 18px 14px; + border-bottom: 1px solid var(--ln-hairline); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--ln-canvas-soft) 72%, var(--ln-surface-card)) 0%, + var(--ln-surface-card) 100% + ); } -.vm-page.bdl-page .bdl-drill-toolbar--inline .bdl-drill-title { - margin: 0; - flex: 1; - min-width: 0; +.vm-page.bdl-page .bdl-drill-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 0 12px; + flex-shrink: 0; + border: 1px solid var(--ln-hairline); + border-radius: var(--ln-radius-control); + background: var(--ln-surface-card); + color: var(--ln-ink); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +.vm-page.bdl-page .bdl-drill-back-btn:hover { + border-color: color-mix(in srgb, var(--ln-primary) 28%, var(--ln-hairline)); + background: var(--ln-canvas-soft); +} + +.vm-page.bdl-page .bdl-drill-back-btn:focus-visible { + outline: 2px solid var(--ln-hairline-strong); + outline-offset: 2px; +} + +.vm-page.bdl-page .bdl-drill-back-label { + white-space: nowrap; } .vm-page.bdl-page .bdl-drill-heading { flex: 1; - text-align: center; - padding: 0 12px; + min-width: 0; +} + +.vm-page.bdl-page .bdl-drill-title-capsule { + display: inline-flex; + max-width: 100%; + padding: 6px 8px 6px 14px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--ln-primary) 14%, var(--ln-hairline)); + background: color-mix(in srgb, var(--ln-canvas-soft) 68%, var(--ln-surface-card)); + box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 55%, transparent); +} + +.vm-page.bdl-page .bdl-drill-title-row { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + min-width: 0; } .vm-page.bdl-page .bdl-drill-title { margin: 0; - font-size: 16px; + font-size: 15px; font-weight: 600; + line-height: 1.35; color: var(--ln-ink); + letter-spacing: -0.01em; + white-space: nowrap; } -.vm-page.bdl-page .bdl-drill-subtitle { - margin: 6px 0 0; - font-size: 13px; +.vm-page.bdl-page .bdl-drill-period { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--ln-primary) 22%, var(--ln-hairline)); + background: var(--ln-surface-card); + color: var(--ln-muted); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-drill-capsule-sep { + display: inline-block; + width: 1px; + height: 14px; + flex-shrink: 0; + background: color-mix(in srgb, var(--ln-muted) 28%, var(--ln-hairline)); +} + +.vm-page.bdl-page .bdl-drill-source { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + min-width: 0; + font-size: 12px; + line-height: 1.45; color: var(--ln-muted); } +.vm-page.bdl-page .bdl-drill-source-label { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border-radius: 999px; + border: 1px solid var(--ln-hairline); + background: var(--ln-surface-card); + color: var(--ln-muted); + font-weight: 600; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-drill-source-value { + color: var(--ln-ink); + font-weight: 500; +} + +.vm-page.bdl-page .bdl-drill-source-detail { + color: var(--ln-muted-80, var(--ln-muted)); +} + +.vm-page.bdl-page .bdl-drill-scope-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.vm-page.bdl-page .bdl-drill-scope-tag { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--ln-hairline); + background: var(--ln-surface-card); + font-size: 12px; +} + +.vm-page.bdl-page .bdl-drill-scope-tag-label { + color: var(--ln-muted); +} + +.vm-page.bdl-page .bdl-drill-scope-tag-value { + color: var(--ln-ink); + font-weight: 500; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vm-page.bdl-page .bdl-drill-card .vm-table-wrap.bdl-drill-table-wrap { + margin: 0; + border: none; + border-radius: 0; +} + +@media (max-width: 768px) { + .vm-page.bdl-page .bdl-drill-header { + flex-direction: column; + align-items: stretch; + } + + .vm-page.bdl-page .bdl-drill-back-btn { + align-self: flex-start; + } + + .vm-page.bdl-page .bdl-drill-title-capsule { + display: flex; + width: 100%; + } + + .vm-page.bdl-page .bdl-drill-title-row { + width: 100%; + } + + .vm-page.bdl-page .bdl-drill-scope-tag-value { + max-width: 160px; + } +} + @media (prefers-reduced-motion: reduce) { - .vm-page.bdl-page .biz-standbook-perf-link { + .vm-page.bdl-page .biz-standbook-perf-link, + .vm-page.bdl-page .bdl-drill-back-btn, + .vm-page.bdl-page .bdl-list-table .ant-table-thead > tr > th, + .vm-page.bdl-page .bdl-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td { transition: none; } } diff --git a/src/prototypes/customer-payment-collection/index.tsx b/src/prototypes/customer-payment-collection/index.tsx new file mode 100644 index 0000000..7609bab --- /dev/null +++ b/src/prototypes/customer-payment-collection/index.tsx @@ -0,0 +1,28 @@ +/** + * @name 客户回款情况 + * 自 ONE-OS Web 端 / 数据分析 / 客户回款情况 独立预览(OneOS 菜单入口) + */ +import '../../common/oneosWebLegacy/legacyGlobals'; +import React, { useEffect } from 'react'; +import * as antd from 'antd'; +import dayjs from 'dayjs'; +import { ConfigProvider } from 'antd'; +import 'antd/dist/reset.css'; +import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; +import CustomerPaymentCollectionPage from './pages/客户回款情况.jsx'; + +window.React = React; +window.antd = antd; +window.dayjs = dayjs; + +export default function CustomerPaymentCollection() { + useEffect(() => { + clearHostPrototypeRouteInfo(); + }, []); + + return ( + + + + ); +} diff --git a/src/prototypes/customer-payment-collection/pages/客户回款情况.jsx b/src/prototypes/customer-payment-collection/pages/客户回款情况.jsx new file mode 100644 index 0000000..eb84729 --- /dev/null +++ b/src/prototypes/customer-payment-collection/pages/客户回款情况.jsx @@ -0,0 +1,847 @@ +// 【重要】必须使用 const Component 作为组件变量名 +// 数据分析 - 客户回款情况 +// 默认展示当期客户应收/实收/期末余额;钻取至账单周期与多业务条线;实收变更实时联动期末余额 + +const Component = function () { + var useState = React.useState; + var useMemo = React.useMemo; + var useCallback = React.useCallback; + + var antd = window.antd; + var App = antd.App; + var Card = antd.Card; + var Button = antd.Button; + var Table = antd.Table; + var Select = antd.Select; + var DatePicker = antd.DatePicker; + var Row = antd.Row; + var Col = antd.Col; + var Space = antd.Space; + var Breadcrumb = antd.Breadcrumb; + var InputNumber = antd.InputNumber; + var Tag = antd.Tag; + var Statistic = antd.Statistic; + var message = antd.message; + + var LINE_DEFS = [ + { key: 'lease', label: '租赁业务' }, + { key: 'logistics', label: '物流业务' }, + { key: 'h2', label: '氢费业务' }, + { key: 'electricity', label: '电费业务' }, + { key: 'etc', label: 'ETC业务' } + ]; + + var LINE_KEYS = LINE_DEFS.map(function (d) { return d.key; }); + + var CUSTOMER_SEEDS = [ + { name: '嘉兴古道物流有限公司', dept: '业务二部', salesperson: '谈云' }, + { name: '杭州绿道城配科技有限公司', dept: '业务二部', salesperson: '刘念念' }, + { name: '宁波港联氢运物流有限公司', dept: '业务一部', salesperson: '谯云' }, + { name: '上海虹钦物流有限公司', dept: '业务一部', salesperson: '董剑煜' }, + { name: '嘉兴市乍浦港口经营有限公司', dept: '业务三部', salesperson: '尚建华' }, + { name: '荣达餐饮(广东)集团有限公司', dept: '业务三部', salesperson: '谈云' }, + { name: '苏州氢能城配科技有限公司', dept: '业务二部', salesperson: '刘念念' }, + { name: '无锡绿运供应链有限公司', dept: '业务一部', salesperson: '谯云' } + ]; + + function filterOption(input, option) { + var label = (option && (option.label || option.children)) || ''; + return String(label).toLowerCase().indexOf(String(input || '').toLowerCase()) >= 0; + } + + function fmtMoney(n) { + if (n === null || n === undefined || n === '') return '—'; + var x = Number(n); + if (isNaN(x)) return '—'; + return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + function numOrZero(v) { + if (v === null || v === undefined || v === '') return 0; + var n = Number(v); + return isNaN(n) ? 0 : n; + } + + function escapeCsv(v) { + var s = v == null ? '' : String(v); + if (s.indexOf(',') !== -1 || s.indexOf('"') !== -1 || s.indexOf('\n') !== -1) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; + } + + function downloadCsv(filename, lines) { + var csv = lines.map(function (row) { return row.map(escapeCsv).join(','); }).join('\n'); + var blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + function defaultPeriodMonth() { + try { + if (window.dayjs) return window.dayjs('2026-07-01'); + } catch (e1) {} + return null; + } + + function periodKeyFromMonth(m) { + if (!m || !m.format) return '2026-07'; + return m.format('YYYY-MM'); + } + + function periodLabel(key) { + if (!key || key.indexOf('-') < 0) return key || '—'; + var parts = key.split('-'); + return parts[0] + '年' + Number(parts[1]) + '月'; + } + + function lineLabel(key) { + var found = LINE_DEFS.find(function (d) { return d.key === key; }); + return found ? found.label : key; + } + + function sumLineAmounts(lines, field) { + var total = 0; + LINE_KEYS.forEach(function (k) { + total += numOrZero(lines && lines[k] && lines[k][field]); + }); + return total; + } + + function periodUncollected(lines) { + return sumLineAmounts(lines, 'receivable') - sumLineAmounts(lines, 'received'); + } + + function buildSeedRecords() { + var records = []; + var months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07']; + CUSTOMER_SEEDS.forEach(function (c, ci) { + var periods = months.map(function (pk, mi) { + var lines = {}; + LINE_KEYS.forEach(function (lk, li) { + var base = 28000 + ci * 4200 + mi * 1800 + li * 2600; + var recv = Math.round(base * (0.85 + (ci % 3) * 0.05)); + var got = recv - (mi % 4 === 0 && li % 2 === 0 ? Math.round(recv * 0.18) : Math.round(recv * 0.06)); + lines[lk] = { receivable: recv, received: got }; + }); + return { periodKey: pk, lines: lines }; + }); + records.push({ + id: 'cust-' + ci, + customerName: c.name, + dept: c.dept, + salesperson: c.salesperson, + periods: periods + }); + }); + return records; + } + + function enrichCustomerPeriods(customer) { + var sorted = (customer.periods || []).slice().sort(function (a, b) { + return String(a.periodKey).localeCompare(String(b.periodKey)); + }); + var cumulative = 0; + return sorted.map(function (p) { + var receivable = sumLineAmounts(p.lines, 'receivable'); + var received = sumLineAmounts(p.lines, 'received'); + var uncollected = receivable - received; + cumulative += uncollected; + return Object.assign({}, p, { + receivable: receivable, + received: received, + uncollected: uncollected, + endingBalance: cumulative + }); + }); + } + + function getCustomerPeriod(customer, periodKey) { + var enriched = enrichCustomerPeriods(customer); + return enriched.find(function (p) { return p.periodKey === periodKey; }) || null; + } + + function buildCustomerSummaryRows(records, periodKey, filters) { + return records.map(function (cust) { + var periods = enrichCustomerPeriods(cust); + var current = periods.find(function (p) { return p.periodKey === periodKey; }); + if (!current) return null; + if (filters.dept && cust.dept !== filters.dept) return null; + if (filters.salesperson && cust.salesperson !== filters.salesperson) return null; + if (filters.customer && cust.customerName !== filters.customer) return null; + if (filters.lineKey) { + var line = current.lines[filters.lineKey]; + if (!line || (line.receivable === 0 && line.received === 0)) return null; + } + return { + key: cust.id, + customerId: cust.id, + customerName: cust.customerName, + dept: cust.dept, + salesperson: cust.salesperson, + receivable: current.receivable, + received: current.received, + uncollected: current.uncollected, + endingBalance: current.endingBalance, + periodKey: periodKey + }; + }).filter(Boolean); + } + + function buildLineRows(customer, periodKey) { + var period = getCustomerPeriod(customer, periodKey); + if (!period) return []; + return LINE_DEFS.map(function (def) { + var line = period.lines[def.key] || { receivable: 0, received: 0 }; + var recv = numOrZero(line.receivable); + var got = numOrZero(line.received); + return { + key: def.key, + lineKey: def.key, + lineLabel: def.label, + receivable: recv, + received: got, + uncollected: recv - got + }; + }); + } + + function updateLineReceived(records, customerId, periodKey, lineKey, newReceived) { + return records.map(function (cust) { + if (cust.id !== customerId) return cust; + var periods = cust.periods.map(function (p) { + if (p.periodKey !== periodKey) return p; + var lines = Object.assign({}, p.lines); + var prev = lines[lineKey] || { receivable: 0, received: 0 }; + lines[lineKey] = { + receivable: prev.receivable, + received: Math.max(0, numOrZero(newReceived)) + }; + return Object.assign({}, p, { lines: lines }); + }); + return Object.assign({}, cust, { periods: periods }); + }); + } + + var layoutStyle = { padding: '16px 24px', background: '#f5f7fa', minHeight: '100vh' }; + var filterLabelStyle = { marginBottom: 6, fontSize: 14, color: 'rgba(0,0,0,0.65)' }; + var filterItemStyle = { marginBottom: 12 }; + var filterControlStyle = { width: '100%' }; + var drillLinkStyle = { cursor: 'pointer', color: '#165dff', border: 'none', background: 'none', padding: 0, font: 'inherit' }; + var moneyCellStyle = { fontVariantNumeric: 'tabular-nums' }; + var warnStyle = { color: '#cf1322', fontVariantNumeric: 'tabular-nums' }; + + var tableStyle = + '.cpr-table .ant-table-thead>tr>th{background:#e8f3ff!important;font-weight:500;font-size:13px}' + + '.cpr-table .ant-table-tbody>tr>td{white-space:nowrap}' + + '.cpr-table .ant-table-summary{background:#fafafa;font-weight:600}' + + '.cpr-kpi .ant-statistic-title{font-size:13px;color:rgba(0,0,0,0.55)}' + + '.cpr-kpi .ant-statistic-content{font-variant-numeric:tabular-nums}'; + + var recordsState = useState(buildSeedRecords); + var records = recordsState[0]; + var setRecords = recordsState[1]; + + var periodDraftState = useState(defaultPeriodMonth); + var periodDraft = periodDraftState[0]; + var setPeriodDraft = periodDraftState[1]; + var periodAppliedState = useState(defaultPeriodMonth); + var periodApplied = periodAppliedState[0]; + var setPeriodApplied = periodAppliedState[1]; + + var deptDraftState = useState(undefined); + var deptDraft = deptDraftState[0]; + var setDeptDraft = deptDraftState[1]; + var deptAppliedState = useState(undefined); + var deptApplied = deptAppliedState[0]; + var setDeptApplied = deptAppliedState[1]; + + var spDraftState = useState(undefined); + var spDraft = spDraftState[0]; + var setSpDraft = spDraftState[1]; + var spAppliedState = useState(undefined); + var spApplied = spAppliedState[0]; + var setSpApplied = spAppliedState[1]; + + var cuDraftState = useState(undefined); + var cuDraft = cuDraftState[0]; + var setCuDraft = cuDraftState[1]; + var cuAppliedState = useState(undefined); + var cuApplied = cuAppliedState[0]; + var setCuApplied = cuAppliedState[1]; + + var lineDraftState = useState(undefined); + var lineDraft = lineDraftState[0]; + var setLineDraft = lineDraftState[1]; + var lineAppliedState = useState(undefined); + var lineApplied = lineAppliedState[0]; + var setLineApplied = lineAppliedState[1]; + + var viewState = useState({ level: 'list', customerId: null, periodKey: null }); + var view = viewState[0]; + var setView = viewState[1]; + + var appliedPeriodKey = useMemo(function () { + return periodKeyFromMonth(periodApplied); + }, [periodApplied]); + + var filters = useMemo(function () { + return { + dept: deptApplied, + salesperson: spApplied, + customer: cuApplied, + lineKey: lineApplied + }; + }, [deptApplied, spApplied, cuApplied, lineApplied]); + + var summaryRows = useMemo(function () { + return buildCustomerSummaryRows(records, appliedPeriodKey, filters); + }, [records, appliedPeriodKey, filters]); + + var activeCustomer = useMemo(function () { + if (!view.customerId) return null; + return records.find(function (r) { return r.id === view.customerId; }) || null; + }, [records, view.customerId]); + + var customerPeriodRows = useMemo(function () { + if (!activeCustomer) return []; + return enrichCustomerPeriods(activeCustomer); + }, [activeCustomer]); + + var lineRows = useMemo(function () { + if (!activeCustomer || !view.periodKey) return []; + return buildLineRows(activeCustomer, view.periodKey); + }, [activeCustomer, view.periodKey]); + + var kpi = useMemo(function () { + var recv = 0; + var got = 0; + var endBal = 0; + summaryRows.forEach(function (r) { + recv += numOrZero(r.receivable); + got += numOrZero(r.received); + endBal += numOrZero(r.endingBalance); + }); + return { + customerCount: summaryRows.length, + receivable: recv, + received: got, + uncollected: recv - got, + endingBalance: endBal + }; + }, [summaryRows]); + + var deptOptions = useMemo(function () { + var set = {}; + records.forEach(function (r) { set[r.dept] = true; }); + return [{ value: '', label: '全部部门' }].concat(Object.keys(set).sort().map(function (d) { + return { value: d, label: d }; + })); + }, [records]); + + var spOptions = useMemo(function () { + var set = {}; + records.forEach(function (r) { set[r.salesperson] = true; }); + return [{ value: '', label: '全部业务员' }].concat(Object.keys(set).sort().map(function (s) { + return { value: s, label: s }; + })); + }, [records]); + + var cuOptions = useMemo(function () { + return [{ value: '', label: '全部客户' }].concat(records.map(function (r) { + return { value: r.customerName, label: r.customerName }; + })); + }, [records]); + + var lineOptions = useMemo(function () { + return [{ value: '', label: '全部条线' }].concat(LINE_DEFS.map(function (d) { + return { value: d.key, label: d.label }; + })); + }, []); + + var handleQuery = useCallback(function () { + setPeriodApplied(periodDraft); + setDeptApplied(deptDraft || undefined); + setSpApplied(spDraft || undefined); + setCuApplied(cuDraft || undefined); + setLineApplied(lineDraft || undefined); + setView({ level: 'list', customerId: null, periodKey: null }); + }, [periodDraft, deptDraft, spDraft, cuDraft, lineDraft]); + + var handleReset = useCallback(function () { + var p0 = defaultPeriodMonth(); + setPeriodDraft(p0); + setPeriodApplied(p0); + setDeptDraft(undefined); + setDeptApplied(undefined); + setSpDraft(undefined); + setSpApplied(undefined); + setCuDraft(undefined); + setCuApplied(undefined); + setLineDraft(undefined); + setLineApplied(undefined); + setView({ level: 'list', customerId: null, periodKey: null }); + }, []); + + var openCustomer = useCallback(function (customerId) { + setView({ level: 'customer', customerId: customerId, periodKey: null }); + }, []); + + var openPeriod = useCallback(function (periodKey) { + setView(function (prev) { + return Object.assign({}, prev, { level: 'period', periodKey: periodKey }); + }); + }, []); + + var backToList = useCallback(function () { + setView({ level: 'list', customerId: null, periodKey: null }); + }, []); + + var backToCustomer = useCallback(function () { + setView(function (prev) { + return Object.assign({}, prev, { level: 'customer', periodKey: null }); + }); + }, []); + + var handleLineReceivedChange = useCallback(function (lineKey, value) { + if (!activeCustomer || !view.periodKey) return; + var period = getCustomerPeriod(activeCustomer, view.periodKey); + if (!period) return; + var line = period.lines[lineKey] || { receivable: 0, received: 0 }; + var recv = numOrZero(line.receivable); + var got = numOrZero(value); + if (got > recv) { + message.warning('实收不能超过应收'); + return; + } + setRecords(function (prev) { + return updateLineReceived(prev, activeCustomer.id, view.periodKey, lineKey, got); + }); + }, [activeCustomer, view.periodKey]); + + var exportListCsv = useCallback(function () { + var headers = ['客户名称', '业务部门', '业务员', '统计周期', '应收', '实收', '未收', '期末余额']; + var lines = [headers]; + summaryRows.forEach(function (r) { + lines.push([ + r.customerName, r.dept, r.salesperson, periodLabel(appliedPeriodKey), + r.receivable, r.received, r.uncollected, r.endingBalance + ]); + }); + downloadCsv('客户回款情况_' + appliedPeriodKey + '.csv', lines); + message.success('已导出客户汇总'); + }, [summaryRows, appliedPeriodKey]); + + var exportCustomerCsv = useCallback(function () { + if (!activeCustomer) return; + var headers = ['账单周期', '应收', '实收', '未收', '期末余额']; + var lines = [headers]; + customerPeriodRows.forEach(function (r) { + lines.push([periodLabel(r.periodKey), r.receivable, r.received, r.uncollected, r.endingBalance]); + }); + downloadCsv(activeCustomer.customerName + '_账单周期回款.csv', lines); + message.success('已导出账单周期明细'); + }, [activeCustomer, customerPeriodRows]); + + var exportLineCsv = useCallback(function () { + if (!activeCustomer || !view.periodKey) return; + var headers = ['业务条线', '应收', '实收', '未收']; + var lines = [headers]; + lineRows.forEach(function (r) { + lines.push([r.lineLabel, r.receivable, r.received, r.uncollected]); + }); + downloadCsv(activeCustomer.customerName + '_' + view.periodKey + '_条线回款.csv', lines); + message.success('已导出条线明细'); + }, [activeCustomer, view.periodKey, lineRows]); + + var summaryFooter = useCallback(function () { + var recv = 0; + var got = 0; + var uncol = 0; + var endBal = 0; + summaryRows.forEach(function (r) { + recv += numOrZero(r.receivable); + got += numOrZero(r.received); + uncol += numOrZero(r.uncollected); + endBal += numOrZero(r.endingBalance); + }); + return React.createElement(Table.Summary, { fixed: true }, + React.createElement(Table.Summary.Row, null, + React.createElement(Table.Summary.Cell, { index: 0, colSpan: 3 }, '合计(' + summaryRows.length + ' 家客户)'), + React.createElement(Table.Summary.Cell, { index: 3, align: 'right' }, React.createElement('span', { style: moneyCellStyle }, fmtMoney(recv))), + React.createElement(Table.Summary.Cell, { index: 4, align: 'right' }, React.createElement('span', { style: moneyCellStyle }, fmtMoney(got))), + React.createElement(Table.Summary.Cell, { index: 5, align: 'right' }, React.createElement('span', { style: uncol > 0 ? warnStyle : moneyCellStyle }, fmtMoney(uncol))), + React.createElement(Table.Summary.Cell, { index: 6, align: 'right' }, React.createElement('span', { style: endBal > 0 ? warnStyle : moneyCellStyle }, fmtMoney(endBal))), + React.createElement(Table.Summary.Cell, { index: 7 }) + ) + ); + }, [summaryRows]); + + var listColumns = [ + { + title: '客户名称', + dataIndex: 'customerName', + key: 'customerName', + width: 220, + fixed: 'left', + render: function (v, row) { + return React.createElement('button', { + type: 'button', + className: 'cpr-drill-link', + style: drillLinkStyle, + onClick: function () { openCustomer(row.customerId); } + }, v); + } + }, + { title: '业务部门', dataIndex: 'dept', key: 'dept', width: 110 }, + { title: '业务员', dataIndex: 'salesperson', key: 'salesperson', width: 100 }, + { + title: '当期应收', + dataIndex: 'receivable', + key: 'receivable', + width: 120, + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '当期实收', + dataIndex: 'received', + key: 'received', + width: 120, + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '当期未收', + dataIndex: 'uncollected', + key: 'uncollected', + width: 120, + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + }, + { + title: '期末余额', + dataIndex: 'endingBalance', + key: 'endingBalance', + width: 130, + align: 'right', + render: function (v) { + return React.createElement('span', { + style: v > 0 ? warnStyle : moneyCellStyle, + title: '截至本期的累计未收余额(含历史各期未收之和)' + }, fmtMoney(v)); + } + }, + { + title: '操作', + key: 'action', + width: 88, + fixed: 'right', + render: function (_, row) { + return React.createElement(Button, { + type: 'link', + size: 'small', + onClick: function () { openCustomer(row.customerId); } + }, '钻取'); + } + } + ]; + + var periodColumns = [ + { + title: '账单周期', + dataIndex: 'periodKey', + key: 'periodKey', + width: 140, + render: function (v, row) { + return React.createElement('button', { + type: 'button', + style: drillLinkStyle, + onClick: function () { openPeriod(row.periodKey); } + }, periodLabel(v)); + } + }, + { + title: '应收', + dataIndex: 'receivable', + key: 'receivable', + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '实收', + dataIndex: 'received', + key: 'received', + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '未收', + dataIndex: 'uncollected', + key: 'uncollected', + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + }, + { + title: '期末余额', + dataIndex: 'endingBalance', + key: 'endingBalance', + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + }, + { + title: '操作', + key: 'action', + width: 120, + render: function (_, row) { + return React.createElement(Button, { + type: 'link', + size: 'small', + onClick: function () { openPeriod(row.periodKey); } + }, '条线明细'); + } + } + ]; + + var lineColumns = [ + { title: '业务条线', dataIndex: 'lineLabel', key: 'lineLabel', width: 120 }, + { + title: '应收', + dataIndex: 'receivable', + key: 'receivable', + align: 'right', + render: function (v) { return React.createElement('span', { style: moneyCellStyle }, fmtMoney(v)); } + }, + { + title: '实收', + dataIndex: 'received', + key: 'received', + align: 'right', + width: 160, + render: function (v, row) { + return React.createElement(InputNumber, { + min: 0, + max: numOrZero(row.receivable), + step: 100, + value: numOrZero(v), + formatter: function (val) { return val == null ? '' : String(val); }, + parser: function (val) { return val ? val.replace(/,/g, '') : ''; }, + style: { width: '100%' }, + onChange: function (val) { handleLineReceivedChange(row.lineKey, val); } + }); + } + }, + { + title: '未收', + dataIndex: 'uncollected', + key: 'uncollected', + align: 'right', + render: function (v) { + return React.createElement('span', { style: v > 0 ? warnStyle : moneyCellStyle }, fmtMoney(v)); + } + } + ]; + + var breadcrumbItems = useMemo(function () { + var items = [{ title: React.createElement('button', { type: 'button', style: drillLinkStyle, onClick: backToList }, '客户回款情况') }]; + if (view.level !== 'list' && activeCustomer) { + items.push({ + title: view.level === 'customer' + ? activeCustomer.customerName + : React.createElement('button', { type: 'button', style: drillLinkStyle, onClick: backToCustomer }, activeCustomer.customerName) + }); + } + if (view.level === 'period' && view.periodKey) { + items.push({ title: periodLabel(view.periodKey) }); + } + return items; + }, [view, activeCustomer, backToList, backToCustomer]); + + var pageTitle = view.level === 'list' + ? '客户回款情况' + : view.level === 'customer' + ? activeCustomer ? activeCustomer.customerName + ' · 账单周期' : '账单周期' + : '业务条线回款明细'; + + var pageHint = view.level === 'list' + ? '默认展示当期(' + periodLabel(appliedPeriodKey) + ')全部客户的应收、实收与期末余额;期末余额为截至当期的累计未收。' + : view.level === 'customer' + ? '按账单周期查看该客户各期回款;期末余额逐期累加历史未收。' + : '调整实收后,当期未收与本客户后续各期期末余额将实时重算。'; + + var exportHandler = view.level === 'list' ? exportListCsv : view.level === 'customer' ? exportCustomerCsv : exportLineCsv; + + var tableData = view.level === 'list' + ? summaryRows + : view.level === 'customer' + ? customerPeriodRows + : lineRows; + + var tableColumns = view.level === 'list' + ? listColumns + : view.level === 'customer' + ? periodColumns + : lineColumns; + + var activePeriodSummary = useMemo(function () { + if (!activeCustomer || !view.periodKey) return null; + return getCustomerPeriod(activeCustomer, view.periodKey); + }, [activeCustomer, view.periodKey, records]); + + return React.createElement(App, null, + React.createElement('style', null, tableStyle), + React.createElement('div', { style: layoutStyle }, + React.createElement(Card, { + bordered: false, + style: { marginBottom: 16 }, + title: pageTitle, + extra: React.createElement(Space, null, + view.level !== 'list' ? React.createElement(Button, { onClick: view.level === 'period' ? backToCustomer : backToList }, '返回上一级') : null, + React.createElement(Button, { onClick: exportHandler }, '导出 CSV') + ) + }, + React.createElement(Breadcrumb, { items: breadcrumbItems, style: { marginBottom: 12 } }), + React.createElement('div', { style: { marginBottom: 16, color: 'rgba(0,0,0,0.55)', fontSize: 13 } }, pageHint), + view.level === 'list' ? React.createElement(Row, { gutter: 16, style: { marginBottom: 16 }, className: 'cpr-kpi' }, + React.createElement(Col, { xs: 12, sm: 8, md: 4 }, + React.createElement(Statistic, { title: '客户数', value: kpi.customerCount, suffix: '家' }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '当期应收合计', value: kpi.receivable, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '当期实收合计', value: kpi.received, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '当期未收合计', value: kpi.uncollected, precision: 2, suffix: '元', valueStyle: kpi.uncollected > 0 ? { color: '#cf1322' } : undefined }) + ), + React.createElement(Col, { xs: 12, sm: 8, md: 5 }, + React.createElement(Statistic, { title: '期末余额合计', value: kpi.endingBalance, precision: 2, suffix: '元', valueStyle: kpi.endingBalance > 0 ? { color: '#cf1322' } : undefined }) + ) + ) : null, + view.level === 'period' && activePeriodSummary ? React.createElement(Row, { gutter: 16, style: { marginBottom: 16 }, className: 'cpr-kpi' }, + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '本期应收', value: activePeriodSummary.receivable, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '本期实收', value: activePeriodSummary.received, precision: 2, suffix: '元' }) + ), + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '本期未收', value: activePeriodSummary.uncollected, precision: 2, suffix: '元', valueStyle: activePeriodSummary.uncollected > 0 ? { color: '#cf1322' } : undefined }) + ), + React.createElement(Col, { span: 6 }, + React.createElement(Statistic, { title: '期末余额', value: activePeriodSummary.endingBalance, precision: 2, suffix: '元', valueStyle: activePeriodSummary.endingBalance > 0 ? { color: '#cf1322' } : undefined }) + ) + ) : null, + view.level === 'list' ? React.createElement(Card, { + size: 'small', + style: { marginBottom: 16, background: '#fafafa' }, + title: '筛选条件' + }, + React.createElement(Row, { gutter: 16 }, + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '统计周期'), + React.createElement(DatePicker, { + picker: 'month', + style: filterControlStyle, + value: periodDraft, + onChange: setPeriodDraft, + allowClear: false + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '客户名称'), + React.createElement(Select, { + showSearch: true, + allowClear: true, + placeholder: '全部客户', + style: filterControlStyle, + options: cuOptions, + value: cuDraft, + onChange: setCuDraft, + filterOption: filterOption + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '业务部门'), + React.createElement(Select, { + allowClear: true, + placeholder: '全部部门', + style: filterControlStyle, + options: deptOptions, + value: deptDraft, + onChange: setDeptDraft + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '业务员'), + React.createElement(Select, { + showSearch: true, + allowClear: true, + placeholder: '全部业务员', + style: filterControlStyle, + options: spOptions, + value: spDraft, + onChange: setSpDraft, + filterOption: filterOption + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: filterItemStyle }, + React.createElement('div', { style: filterLabelStyle }, '业务条线'), + React.createElement(Select, { + allowClear: true, + placeholder: '全部条线', + style: filterControlStyle, + options: lineOptions, + value: lineDraft, + onChange: setLineDraft + }) + ) + ), + React.createElement(Col, { xs: 24, sm: 12, md: 6 }, + React.createElement('div', { style: Object.assign({}, filterItemStyle, { paddingTop: 28 }) }, + React.createElement(Space, null, + React.createElement(Button, { type: 'primary', onClick: handleQuery }, '查询'), + React.createElement(Button, { onClick: handleReset }, '重置') + ) + ) + ) + ) + ) : null, + view.level === 'period' ? React.createElement(Tag, { color: 'processing', style: { marginBottom: 12 } }, '可在「实收」列直接修改,期末余额将实时联动') : null, + React.createElement(Table, { + className: 'cpr-table', + size: 'small', + bordered: true, + scroll: { x: view.level === 'list' ? 980 : 720 }, + columns: tableColumns, + dataSource: tableData, + pagination: view.level === 'list' ? { pageSize: 10, showSizeChanger: true, showTotal: function (t) { return '共 ' + t + ' 条'; } } : false, + summary: view.level === 'list' ? summaryFooter : undefined, + locale: { emptyText: '暂无符合条件的数据' } + }) + ) + ) + ); +}; + +export default Component; diff --git a/src/prototypes/insurance-procurement/InsuranceProcurement.jsx b/src/prototypes/insurance-procurement/InsuranceProcurement.jsx index a49de8a..e7b0753 100644 --- a/src/prototypes/insurance-procurement/InsuranceProcurement.jsx +++ b/src/prototypes/insurance-procurement/InsuranceProcurement.jsx @@ -6,6 +6,11 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; import { OperationActions } from '../../common/OperationActions'; +import { + shouldShowFilterExpand, + splitFilterFields, + VM_FILTER_PRIMARY_VISIBLE_COUNT, +} from '../../common/vm-filter-panel'; import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import { @@ -6335,7 +6340,7 @@ const Component = function () { }; const renderFilterField = (label, control) => ( - + ))} + + +
+
+ 3 上传填写好的 Excel 文件
- {failedCount > 0 && onDownloadErrorLog ? ( + {hasResultSummary ? (
-

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

- + {failedCount > 0 ? ( +

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

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

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

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

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

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