迭代 ONE-OS 多原型:加氢记录/订单同源对账、租赁明细校验与月度损益、车辆与台账增强;新增客户回款与加氢站统计;补齐业务逻辑与对象存储发布规范,同步原型导航。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"updatedAt": "2026-07-12T17:06:54.186Z",
|
"updatedAt": "2026-07-14T15:10:00.000Z",
|
||||||
"prototypes": [
|
"prototypes": [
|
||||||
{
|
{
|
||||||
"id": "folder-1783875936186-27raza",
|
"id": "folder-1783875936186-27raza",
|
||||||
@@ -31,12 +31,6 @@
|
|||||||
"title": "财务管理(合包)",
|
"title": "财务管理(合包)",
|
||||||
"itemKey": "prototypes/oneos-web-finance"
|
"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",
|
"id": "item:prototypes:oneos-web-help-center",
|
||||||
"kind": "item",
|
"kind": "item",
|
||||||
@@ -89,7 +83,7 @@
|
|||||||
{
|
{
|
||||||
"id": "folder-1782874599304-roj8fd",
|
"id": "folder-1782874599304-roj8fd",
|
||||||
"kind": "folder",
|
"kind": "folder",
|
||||||
"title": "运维管理",
|
"title": "车辆资产",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "item:prototypes:vehicle-management",
|
"id": "item:prototypes:vehicle-management",
|
||||||
@@ -153,6 +147,18 @@
|
|||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "站点信息",
|
"title": "站点信息",
|
||||||
"itemKey": "prototypes/oneos-web-h2-station-site"
|
"itemKey": "prototypes/oneos-web-h2-station-site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:oneos-h5-h2-order",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "加氢订单",
|
||||||
|
"itemKey": "prototypes/oneos-h5-h2-order"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:oneos-web-h2-station",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "加氢记录",
|
||||||
|
"itemKey": "prototypes/oneos-web-h2-station"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -164,8 +170,20 @@
|
|||||||
{
|
{
|
||||||
"id": "item:prototypes:business-dept-ledger",
|
"id": "item:prototypes:business-dept-ledger",
|
||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "业务部台账",
|
"title": "项目盈亏情况",
|
||||||
"itemKey": "prototypes/business-dept-ledger"
|
"itemKey": "prototypes/business-dept-ledger"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:oneos-web-h2-station-stats",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "加氢站数量统计",
|
||||||
|
"itemKey": "prototypes/oneos-web-h2-station-stats"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:customer-payment-collection",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "客户回款情况",
|
||||||
|
"itemKey": "prototypes/customer-payment-collection"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -228,12 +246,6 @@
|
|||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "还车应结款",
|
"title": "还车应结款",
|
||||||
"itemKey": "prototypes/vehicle-return-settlement"
|
"itemKey": "prototypes/vehicle-return-settlement"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "item:prototypes:vehicle-return-settlement-v2",
|
|
||||||
"kind": "item",
|
|
||||||
"title": "还车应结款-新",
|
|
||||||
"itemKey": "prototypes/vehicle-return-settlement-v2"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
37
.cursor/rules/business-logic-documentation.mdc
Normal file
37
.cursor/rules/business-logic-documentation.mdc
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: 复杂业务/判断逻辑必须同步写入 PRD 与 .spec Markdown,并同步标注目录
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 业务逻辑文档化(全局强制)
|
||||||
|
|
||||||
|
实现或修改**复杂判断逻辑**时,代码与文档**同一轮交付**。完整指南:`rules/business-logic-documentation-guide.md`。
|
||||||
|
|
||||||
|
## 触发条件(满足任一即必须文档化)
|
||||||
|
|
||||||
|
多步判定链、跨模块校验、状态/公式推导、导入保存规则、权限分支、一键修复建议值。
|
||||||
|
|
||||||
|
## 必须更新的文件
|
||||||
|
|
||||||
|
1. **`src/prototypes/<name>/.spec/<topic>.md`** — 完整规则(判定顺序、数据源、边界、代码路径)
|
||||||
|
2. **`src/prototypes/<name>/.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 单元格系统校验 + 目录「单元格系统校验」
|
||||||
31
.cursor/rules/cloud-publish-url.mdc
Normal file
31
.cursor/rules/cloud-publish-url.mdc
Normal file
@@ -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「已发布到对象存储」弹窗**完全一致**。
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
|
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
|
||||||
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
||||||
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw`、`canvas-assets/` | 原型画布和画布素材 |
|
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw`、`canvas-assets/` | 原型画布和画布素材 |
|
||||||
|
| 业务逻辑规格 | `src/prototypes/<prototype-name>/.spec/<topic>.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` |
|
| 原型标注布局 | `src/common/prototype-annotation-host.tsx` | `rules/prototype-annotation-layout-guide.md` |
|
||||||
|
|
||||||
## ⚠️ 重要原则
|
## ⚠️ 重要原则
|
||||||
@@ -41,6 +43,11 @@
|
|||||||
6. **讲人话,用户不懂技术**
|
6. **讲人话,用户不懂技术**
|
||||||
- 用用户能理解的方式说明取舍、风险和结果;用户无法执行 CLI 命令,不得省略验收流程
|
- 用用户能理解的方式说明取舍、风险和结果;用户无法执行 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`)
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
||||||
| UI Review 结论 | `src/prototypes/<prototype-name>/.spec/ui-review.md` | `rules/ui-review-guide.md` |
|
| UI Review 结论 | `src/prototypes/<prototype-name>/.spec/ui-review.md` | `rules/ui-review-guide.md` |
|
||||||
| 原型 Review 结论 | `src/prototypes/<prototype-name>/.spec/prototype-review.md` | `rules/prototype-review-guide.md` |
|
| 原型 Review 结论 | `src/prototypes/<prototype-name>/.spec/prototype-review.md` | `rules/prototype-review-guide.md` |
|
||||||
|
| 业务逻辑规格 | `src/prototypes/<prototype-name>/.spec/<topic>.md` | `rules/business-logic-documentation-guide.md` |
|
||||||
|
| 对象存储发布 URL | `.axhub/make/axhub.config.json` → `cloudPublishing.s3` | `rules/cloud-publish-url-guide.md` |
|
||||||
| ACP 对话缓存 | `src/prototypes/<prototype-name>/.spec/acp/` | 本地私有运行数据,不提交、不导出、不发布 |
|
| ACP 对话缓存 | `src/prototypes/<prototype-name>/.spec/acp/` | 本地私有运行数据,不提交、不导出、不发布 |
|
||||||
|
|
||||||
## ⚠️ 重要原则
|
## ⚠️ 重要原则
|
||||||
@@ -43,6 +45,10 @@
|
|||||||
- 产品需求、设计方案或原型应尽早交给用户确认,不要等到全部完成后才暴露方向问题
|
- 产品需求、设计方案或原型应尽早交给用户确认,不要等到全部完成后才暴露方向问题
|
||||||
6. **讲人话,用户不懂技术**
|
6. **讲人话,用户不懂技术**
|
||||||
- 用用户能理解的方式说明取舍、风险和结果;用户无法执行 CLI 命令,不得省略验收流程
|
- 用用户能理解的方式说明取舍、风险和结果;用户无法执行 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`)
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
135
rules/business-logic-documentation-guide.md
Normal file
135
rules/business-logic-documentation-guide.md
Normal file
@@ -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/<name>/
|
||||||
|
├── .spec/requirements-prd.md # ① PRD:摘要 + 验收项(产品可读)
|
||||||
|
├── .spec/<logic-topic>.md # ② 规格:完整判定顺序、数据源、边界(开发/评审)
|
||||||
|
├── annotation-source.json # ③ 标注:目录节点 + 页面/字段标注(经同步脚本)
|
||||||
|
└── columnHeaderTips.ts 等 # 可选:表头/字段级一句话提示
|
||||||
|
```
|
||||||
|
|
||||||
|
### ① PRD(`requirements-prd.md`)
|
||||||
|
|
||||||
|
在对应功能章节增加**摘要**,至少包含:
|
||||||
|
|
||||||
|
- 业务目的(一句话)
|
||||||
|
- 判定顺序或状态表(表格优先)
|
||||||
|
- 数据源 / 对照模块
|
||||||
|
- 用户可见结果(图标、文案、是否可一键修复)
|
||||||
|
- 链接到 `.spec/<logic-topic>.md` 全文
|
||||||
|
|
||||||
|
在 **§验收重点** 增加可勾选项。
|
||||||
|
|
||||||
|
### ② 规格 Markdown(`.spec/<logic-topic>.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/<logic-topic>.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`
|
||||||
86
rules/cloud-publish-url-guide.md
Normal file
86
rules/cloud-publish-url-guide.md
Normal file
@@ -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 强制规则
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# ONE-OS 全局设计规范(开发版)
|
# ONE-OS 全局设计规范(开发版)
|
||||||
|
|
||||||
|
> **对外分享请使用**:[`src/resources/design-system/`](../src/resources/design-system/README.md)(含 `DESIGN.md`、`tokens.json`、AI 提示模板)。本文件为项目内 Agent 规则副本。
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 文档版本 | v1.0 |
|
| 文档版本 | v1.0 |
|
||||||
@@ -160,6 +162,8 @@ import '../vehicle-management/style.css';
|
|||||||
**折叠筛选**
|
**折叠筛选**
|
||||||
|
|
||||||
- 超过 4 项时其余放入 `vm-filter-expand`(见 `src/common/vm-filter-panel.ts`)。
|
- 超过 4 项时其余放入 `vm-filter-expand`(见 `src/common/vm-filter-panel.ts`)。
|
||||||
|
- 筛选项数组经 `splitFilterFields()` 拆为首行 + 展开区;首行固定最多 4 项,**禁止**首行放 5 项导致第二行孤零零 1 项。
|
||||||
|
- 展开区项数不得为 4n+1(避免展开后末行仅 1 项);由 `splitFilterFields` 自动从首行借调平衡。
|
||||||
- 切换按钮:`vm-filter-toggle` + 角标 `vm-filter-toggle-badge`。
|
- 切换按钮:`vm-filter-toggle` + 角标 `vm-filter-toggle-badge`。
|
||||||
|
|
||||||
### 4.2 输入框 `.vm-input`
|
### 4.2 输入框 `.vm-input`
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ export default function MyApp() {
|
|||||||
|
|
||||||
参考:`vehicle-management/index.tsx`、`lease-business-ledger/index.tsx`。
|
参考:`vehicle-management/index.tsx`、`lease-business-ledger/index.tsx`。
|
||||||
|
|
||||||
|
## 业务逻辑文档化(强制)
|
||||||
|
|
||||||
|
实现**复杂判断逻辑**(多步判定、跨模块校验、状态/公式推导、导入保存规则等)时,必须与代码**同一轮**更新文档。详见 `rules/business-logic-documentation-guide.md`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
.spec/<logic-topic>.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`。
|
- [ ] 新增依赖已写入 `package.json`。
|
||||||
- [ ] `check-app-ready.mjs` 原型验收通过。
|
- [ ] `check-app-ready.mjs` 原型验收通过。
|
||||||
|
- [ ] 若含复杂业务逻辑:`.spec` 规格文 + PRD 摘要 + 标注目录已同步(见 `business-logic-documentation-guide.md`)。
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
- 本次范围、功能清单和不做什么。
|
- 本次范围、功能清单和不做什么。
|
||||||
- 页面或资源的核心内容、数据来源和素材来源。
|
- 页面或资源的核心内容、数据来源和素材来源。
|
||||||
- 关键状态、核心路径和必要交互。
|
- 关键状态、核心路径和必要交互。
|
||||||
|
- **复杂判定逻辑**(校验链、状态机、公式口径、导入规则):在 PRD 中写摘要,并规划 `.spec/<topic>.md` 全文(见 `rules/business-logic-documentation-guide.md`)。
|
||||||
- 用户最终如何判断结果可用。
|
- 用户最终如何判断结果可用。
|
||||||
|
|
||||||
只问会影响范围、成本或验收的问题。能从上下文推断的内容直接记为假设继续;如果缺失信息会导致不同功能范围、不同页面结构或不同验收标准,必须先问。
|
只问会影响范围、成本或验收的问题。能从上下文推断的内容直接记为假设继续;如果缺失信息会导致不同功能范围、不同页面结构或不同验收标准,必须先问。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* node scripts/publish-all-to-s3.mjs
|
* node scripts/publish-all-to-s3.mjs
|
||||||
* node scripts/publish-all-to-s3.mjs --dry-run
|
* 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 --group prototypes
|
||||||
|
* node scripts/publish-all-to-s3.mjs --ids vehicle-management,oneos-prototype-nav
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
@@ -26,6 +27,7 @@ function parseArgs(argv) {
|
|||||||
adminOrigin: '',
|
adminOrigin: '',
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
group: 'all',
|
group: 'all',
|
||||||
|
ids: [],
|
||||||
includeSource: true,
|
includeSource: true,
|
||||||
};
|
};
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
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();
|
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 === '--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 === '--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 === '--dry-run') args.dryRun = true;
|
||||||
else if (arg === '--no-source') args.includeSource = false;
|
else if (arg === '--no-source') args.includeSource = false;
|
||||||
}
|
}
|
||||||
@@ -69,8 +74,9 @@ function resolveObjectPrefix(entry, pathAliases = {}) {
|
|||||||
if (alias) return String(alias).replace(/^\/+|\/+$/gu, '');
|
if (alias) return String(alias).replace(/^\/+|\/+$/gu, '');
|
||||||
|
|
||||||
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
|
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
|
||||||
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
|
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/([^/]+)$/u);
|
||||||
if (prototypeMatch?.[1]) return `prototypes/${prototypeMatch[1]}`;
|
// 与 Make 客户端「发布到对象存储」一致:/{prototype-id}/index.html(不加 prototypes/ 前缀)
|
||||||
|
if (prototypeMatch?.[1]) return prototypeMatch[1];
|
||||||
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
|
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
|
||||||
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
|
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
|
||||||
return entry.id;
|
return entry.id;
|
||||||
@@ -227,7 +233,15 @@ async function main() {
|
|||||||
const adminOrigin = resolveAdminOrigin(args.adminOrigin);
|
const adminOrigin = resolveAdminOrigin(args.adminOrigin);
|
||||||
const s3Config = readS3Config();
|
const s3Config = readS3Config();
|
||||||
const includeSource = args.includeSource && s3Config.includeSource;
|
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(`Admin: ${adminOrigin}`);
|
||||||
console.log(`Bucket: ${s3Config.bucket}`);
|
console.log(`Bucket: ${s3Config.bucket}`);
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ const XLL_MODULES = [
|
|||||||
/** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */
|
/** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */
|
||||||
const STANDALONE_MODULES = [
|
const STANDALONE_MODULES = [
|
||||||
{ slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true },
|
{ slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true },
|
||||||
|
{ slug: 'customer-payment-collection', title: '客户回款情况', skipCodegen: true },
|
||||||
{ slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true },
|
{ slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true },
|
||||||
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
|
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
|
||||||
|
{ slug: 'oneos-web-h2-station-stats', title: '加氢站数量统计', skipCodegen: true },
|
||||||
{ slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true },
|
{ slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true },
|
||||||
{ slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true },
|
{ slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true },
|
||||||
];
|
];
|
||||||
@@ -55,7 +57,7 @@ const MODULES = [
|
|||||||
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
|
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
|
||||||
{ slug: 'oneos-web-lease-contract', 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-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-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'], excludeFiles: ['车辆维修明细.jsx'] },
|
||||||
{ slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] },
|
{ slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] },
|
||||||
{ slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' },
|
{ slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' },
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
Car,
|
Car,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
OctagonX,
|
OctagonX,
|
||||||
Pencil,
|
Pencil,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Settings2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Undo2,
|
Undo2,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -31,6 +33,7 @@ export type OperationActionItem = {
|
|||||||
const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
|
const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
|
||||||
edit: Pencil,
|
edit: Pencil,
|
||||||
del: Trash2,
|
del: Trash2,
|
||||||
|
delete: Trash2,
|
||||||
addVehicle: Car,
|
addVehicle: Car,
|
||||||
renew: RefreshCw,
|
renew: RefreshCw,
|
||||||
authorized: FileText,
|
authorized: FileText,
|
||||||
@@ -41,6 +44,9 @@ const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
|
|||||||
toFormal: ArrowRightLeft,
|
toFormal: ArrowRightLeft,
|
||||||
stampSupplement: Upload,
|
stampSupplement: Upload,
|
||||||
uploadStamped: Upload,
|
uploadStamped: Upload,
|
||||||
|
manage: Settings2,
|
||||||
|
preview: Eye,
|
||||||
|
download: Download,
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderMenuItemLabel(item: OperationActionItem) {
|
function renderMenuItemLabel(item: OperationActionItem) {
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ function h2BridgeApplyStatementPatches(rows, patches) {
|
|||||||
if (!patch) return r;
|
if (!patch) return r;
|
||||||
return Object.assign({}, r, {
|
return Object.assign({}, r, {
|
||||||
statementPatched: true,
|
statementPatched: true,
|
||||||
|
reconcileStatus: H2_RECONCILE_RECONCILED,
|
||||||
reconcileDate: patch.reconcileDate,
|
reconcileDate: patch.reconcileDate,
|
||||||
receiptDate: patch.receiptDate,
|
receiptDate: patch.receiptDate,
|
||||||
stationPaymentStatus: patch.paymentStatus || 'paid',
|
stationPaymentStatus: patch.paymentStatus || 'paid',
|
||||||
paymentStatus: patch.paymentStatus || 'paid',
|
paymentStatus: patch.paymentStatus || 'paid',
|
||||||
reconciledAt: patch.reconcileDate
|
reconciledAt: patch.reconcileDate,
|
||||||
|
statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,6 +103,40 @@ function h2BridgeCreateStore() {
|
|||||||
if (!patches || !patches.length) return;
|
if (!patches || !patches.length) return;
|
||||||
state.rows = h2BridgeApplyStatementPatches(state.rows, patches);
|
state.rows = h2BridgeApplyStatementPatches(state.rows, patches);
|
||||||
notify();
|
notify();
|
||||||
|
},
|
||||||
|
upsertRow: function (row) {
|
||||||
|
if (!row) return null;
|
||||||
|
var next = Object.assign({}, row);
|
||||||
|
if (!next.id && !next.key) {
|
||||||
|
next.id = 'rf-h5-' + Date.now() + '-' + Math.floor(Math.random() * 1000);
|
||||||
|
next.key = next.id;
|
||||||
|
} else {
|
||||||
|
next.id = next.id || next.key;
|
||||||
|
next.key = next.key || next.id;
|
||||||
|
}
|
||||||
|
if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING;
|
||||||
|
var found = false;
|
||||||
|
state.rows = state.rows.map(function (r) {
|
||||||
|
if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) {
|
||||||
|
found = true;
|
||||||
|
return Object.assign({}, r, next);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
if (!found) state.rows = [next].concat(state.rows);
|
||||||
|
notify();
|
||||||
|
return Object.assign({}, next);
|
||||||
|
},
|
||||||
|
removeRow: function (id) {
|
||||||
|
var target = String(id || '');
|
||||||
|
if (!target) return false;
|
||||||
|
var before = state.rows.length;
|
||||||
|
state.rows = state.rows.filter(function (r) {
|
||||||
|
return String(r.id || r.key || '') !== target;
|
||||||
|
});
|
||||||
|
var changed = state.rows.length !== before;
|
||||||
|
if (changed) notify();
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,17 +190,29 @@ function h2BridgeMapToStationLedgerRow(row, index) {
|
|||||||
|
|
||||||
function h2BridgeMapToHrRecord(row, index, fillerNames) {
|
function h2BridgeMapToHrRecord(row, index, fillerNames) {
|
||||||
var fillers = fillerNames || ['张三', '李四', '王静', '赵敏', '陈浩'];
|
var fillers = fillerNames || ['张三', '李四', '王静', '赵敏', '陈浩'];
|
||||||
|
var costTotal = row.costTotal != null ? row.costTotal : row.costAmount;
|
||||||
|
var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice;
|
||||||
|
var totalAmount = costTotal != null ? costTotal : row.customerAmount;
|
||||||
|
var reconcileTime = row.reconcileDate || row.reconciledAt || '';
|
||||||
|
var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(reconcileTime);
|
||||||
return {
|
return {
|
||||||
id: row.id || row.key || ('hr-' + (index + 1)),
|
id: row.id || row.key || ('hr-' + (index + 1)),
|
||||||
hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime),
|
hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime),
|
||||||
plateNo: row.plateNo,
|
plateNo: row.plateNo,
|
||||||
hydrogenKg: row.hydrogenKg,
|
hydrogenKg: row.hydrogenKg,
|
||||||
customerAmount: row.customerAmount,
|
unitPrice: unitPrice,
|
||||||
|
totalAmount: totalAmount,
|
||||||
|
/* 兼容旧列名:金额同站端口径 */
|
||||||
|
customerAmount: totalAmount,
|
||||||
|
costUnitPrice: unitPrice,
|
||||||
|
costTotal: totalAmount,
|
||||||
mileageKm: row.mileageKm,
|
mileageKm: row.mileageKm,
|
||||||
fillerName: row.creatorName || fillers[index % fillers.length],
|
fillerName: row.creatorName || fillers[index % fillers.length],
|
||||||
settlementStatus: row.settlementStatus,
|
settlementStatus: row.settlementStatus,
|
||||||
stationName: row.stationName,
|
stationName: row.stationName,
|
||||||
stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || ''
|
stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '',
|
||||||
|
reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING,
|
||||||
|
reconcileTime: isReconciled ? h2BridgeFormatDateTime(reconcileTime) : ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +311,8 @@ function h2BridgeInit() {
|
|||||||
getHrRecords: h2BridgeGetHrRecords,
|
getHrRecords: h2BridgeGetHrRecords,
|
||||||
getOrderRecords: h2BridgeGetOrderRecords,
|
getOrderRecords: h2BridgeGetOrderRecords,
|
||||||
getStationList: h2BridgeGetStationList,
|
getStationList: h2BridgeGetStationList,
|
||||||
|
upsertRow: function (row) { return store.upsertRow(row); },
|
||||||
|
removeRow: function (id) { return store.removeRow(id); },
|
||||||
subscribe: function (fn) { return store.subscribe(fn); }
|
subscribe: function (fn) { return store.subscribe(fn); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -274,6 +324,8 @@ h2BridgeInit();
|
|||||||
export {
|
export {
|
||||||
H2_CANONICAL_LEDGER_SEED,
|
H2_CANONICAL_LEDGER_SEED,
|
||||||
H2_STATION_CODE_MAP,
|
H2_STATION_CODE_MAP,
|
||||||
|
H2_RECONCILE_RECONCILED,
|
||||||
|
H2_RECONCILE_PENDING,
|
||||||
h2BridgeInit,
|
h2BridgeInit,
|
||||||
h2BridgeGetStore,
|
h2BridgeGetStore,
|
||||||
h2BridgeGetAllRefuelRecords,
|
h2BridgeGetAllRefuelRecords,
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vm-kpi-val,
|
.vm-kpi-val,
|
||||||
|
.lbd-kpi-val,
|
||||||
|
.bdl-kpi-val,
|
||||||
|
.sob-kpi-val,
|
||||||
.vm-mileage,
|
.vm-mileage,
|
||||||
.vm-expire-date,
|
.vm-expire-date,
|
||||||
.vm-handover-situation-line--mile,
|
.vm-handover-situation-line--mile,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致)
|
* 列表页筛选区全局规则(与 vehicle-management/style.css 中 .vm-filter-grid 4 列布局一致)
|
||||||
*
|
*
|
||||||
* 规则:筛选项超过 1 行(默认 4 项)时,其余收入折叠区,通过「更多筛选」展开。
|
* ## 布局约束
|
||||||
|
* 1. 收起时:首行(primary)最多展示 `VM_FILTER_COLUMNS_PER_ROW`(默认 4)项,占满 1 行。
|
||||||
|
* 2. 展开时:其余项放入 `vm-filter-expand` / `ldb-filter-expand`;primary 与 extra 各自使用 4 列栅格。
|
||||||
|
* 3. 禁止「末行仅 1 项」:若展开区项数为 4n+1,从首行尾部借调 1 项到展开区(首行变为 3 项)。
|
||||||
|
* 典型:总项数 5 → 首行 3 + 展开 2;总项数 6 → 首行 4 + 展开 2。
|
||||||
|
*
|
||||||
|
* 实现参考:`payment-records/components/FilterPanel.tsx`、`lease-business-ledger/components/FilterPanel.tsx`
|
||||||
*/
|
*/
|
||||||
export const VM_FILTER_COLUMNS_PER_ROW = 4;
|
export const VM_FILTER_COLUMNS_PER_ROW = 4;
|
||||||
|
|
||||||
@@ -12,13 +18,37 @@ export function splitFilterFields<T>(
|
|||||||
fields: T[],
|
fields: T[],
|
||||||
primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
primaryCount: number = VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
||||||
): { primary: T[]; extra: T[] } {
|
): { primary: T[]; extra: T[] } {
|
||||||
if (fields.length <= primaryCount) {
|
const columnsPerRow = primaryCount;
|
||||||
|
if (fields.length <= columnsPerRow) {
|
||||||
return { primary: fields, extra: [] };
|
return { primary: fields, extra: [] };
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
primary: fields.slice(0, primaryCount),
|
const primary = fields.slice(0, columnsPerRow);
|
||||||
extra: fields.slice(primaryCount),
|
const extra = fields.slice(columnsPerRow);
|
||||||
};
|
|
||||||
|
return rebalanceFilterSplit(primary, extra, columnsPerRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 避免展开区出现 4n+1 项导致末行孤零零 1 项 */
|
||||||
|
function rebalanceFilterSplit<T>(
|
||||||
|
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(
|
export function shouldShowFilterExpand(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
28
src/prototypes/customer-payment-collection/index.tsx
Normal file
28
src/prototypes/customer-payment-collection/index.tsx
Normal file
@@ -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 (
|
||||||
|
<ConfigProvider>
|
||||||
|
<CustomerPaymentCollectionPage />
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
847
src/prototypes/customer-payment-collection/pages/客户回款情况.jsx
Normal file
847
src/prototypes/customer-payment-collection/pages/客户回款情况.jsx
Normal file
@@ -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;
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { OperationActions } from '../../common/OperationActions';
|
import { OperationActions } from '../../common/OperationActions';
|
||||||
|
import {
|
||||||
|
shouldShowFilterExpand,
|
||||||
|
splitFilterFields,
|
||||||
|
VM_FILTER_PRIMARY_VISIBLE_COUNT,
|
||||||
|
} from '../../common/vm-filter-panel';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||||
import {
|
import {
|
||||||
@@ -6335,7 +6340,7 @@ const Component = function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderFilterField = (label, control) => (
|
const renderFilterField = (label, control) => (
|
||||||
<label className="lc-filter-field">
|
<label key={label} className="lc-filter-field">
|
||||||
<span className="lc-filter-field-label">{label}</span>
|
<span className="lc-filter-field-label">{label}</span>
|
||||||
<div className="lc-filter-field-control">{control}</div>
|
<div className="lc-filter-field-control">{control}</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -6498,14 +6503,17 @@ const Component = function () {
|
|||||||
onHeaderCell: listColumnHeaderCell,
|
onHeaderCell: listColumnHeaderCell,
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<OperationActions
|
<OperationActions
|
||||||
view={{ label: '管理', onClick: () => openVehicleInsuranceMgmt(record) }}
|
more={[{ key: 'manage', label: '管理', onClick: () => openVehicleInsuranceMgmt(record) }]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const listFilterFieldNodes = [
|
const listFilterFields = [
|
||||||
renderFilterField('车牌号', (
|
{
|
||||||
|
key: 'plateNo',
|
||||||
|
active: Boolean(String(listFilters.plateNo || '').trim()),
|
||||||
|
node: renderFilterField('车牌号', (
|
||||||
<Input
|
<Input
|
||||||
placeholder={appliedMultiPlates.length ? '已启用多车牌筛选' : '请输入车牌号'}
|
placeholder={appliedMultiPlates.length ? '已启用多车牌筛选' : '请输入车牌号'}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -6515,7 +6523,11 @@ const Component = function () {
|
|||||||
onPressEnter={handleListFilterQuery}
|
onPressEnter={handleListFilterQuery}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
renderFilterField('多车牌', (
|
},
|
||||||
|
{
|
||||||
|
key: 'plateNos',
|
||||||
|
active: Boolean(String(listFilters.plateNos || multiPlateDraft || '').trim()),
|
||||||
|
node: renderFilterField('多车牌', (
|
||||||
<Popover
|
<Popover
|
||||||
open={multiPlateOpen}
|
open={multiPlateOpen}
|
||||||
onOpenChange={handleMultiPlateOpenChange}
|
onOpenChange={handleMultiPlateOpenChange}
|
||||||
@@ -6545,7 +6557,11 @@ const Component = function () {
|
|||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
)),
|
)),
|
||||||
renderFilterField('VIN码', (
|
},
|
||||||
|
{
|
||||||
|
key: 'vin',
|
||||||
|
active: Boolean(String(listFilters.vin || '').trim()),
|
||||||
|
node: renderFilterField('VIN码', (
|
||||||
<Input
|
<Input
|
||||||
placeholder="请输入车辆识别代码"
|
placeholder="请输入车辆识别代码"
|
||||||
allowClear
|
allowClear
|
||||||
@@ -6554,7 +6570,11 @@ const Component = function () {
|
|||||||
onPressEnter={handleListFilterQuery}
|
onPressEnter={handleListFilterQuery}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
renderFilterField('品牌', (
|
},
|
||||||
|
{
|
||||||
|
key: 'brand',
|
||||||
|
active: Boolean(listFilters.brand),
|
||||||
|
node: renderFilterField('品牌', (
|
||||||
<Select
|
<Select
|
||||||
placeholder="请选择或输入品牌"
|
placeholder="请选择或输入品牌"
|
||||||
allowClear
|
allowClear
|
||||||
@@ -6566,7 +6586,11 @@ const Component = function () {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
renderFilterField('型号', (
|
},
|
||||||
|
{
|
||||||
|
key: 'model',
|
||||||
|
active: Boolean(listFilters.model),
|
||||||
|
node: renderFilterField('型号', (
|
||||||
<Select
|
<Select
|
||||||
placeholder="请选择或输入型号"
|
placeholder="请选择或输入型号"
|
||||||
allowClear
|
allowClear
|
||||||
@@ -6578,7 +6602,11 @@ const Component = function () {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
renderFilterField('运营状态', (
|
},
|
||||||
|
{
|
||||||
|
key: 'operateStatus',
|
||||||
|
active: listFilters.operateStatus !== '全部',
|
||||||
|
node: renderFilterField('运营状态', (
|
||||||
<Select
|
<Select
|
||||||
value={listFilters.operateStatus}
|
value={listFilters.operateStatus}
|
||||||
onChange={(val) => setListFilters((prev) => ({ ...prev, operateStatus: val }))}
|
onChange={(val) => setListFilters((prev) => ({ ...prev, operateStatus: val }))}
|
||||||
@@ -6591,7 +6619,11 @@ const Component = function () {
|
|||||||
<Select.Option value="退出运营">退出运营</Select.Option>
|
<Select.Option value="退出运营">退出运营</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
)),
|
)),
|
||||||
renderFilterField('保险状态', (
|
},
|
||||||
|
{
|
||||||
|
key: 'insuranceStatus',
|
||||||
|
active: listFilters.insuranceStatus !== '全部',
|
||||||
|
node: renderFilterField('保险状态', (
|
||||||
<Select
|
<Select
|
||||||
value={listFilters.insuranceStatus}
|
value={listFilters.insuranceStatus}
|
||||||
onChange={(val) => setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
|
onChange={(val) => setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
|
||||||
@@ -6602,7 +6634,11 @@ const Component = function () {
|
|||||||
<Select.Option value="异常">异常</Select.Option>
|
<Select.Option value="异常">异常</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
)),
|
)),
|
||||||
renderFilterField('保险类型', (
|
},
|
||||||
|
{
|
||||||
|
key: 'insuranceType',
|
||||||
|
active: Boolean(listFilters.insuranceType),
|
||||||
|
node: renderFilterField('保险类型', (
|
||||||
<Select
|
<Select
|
||||||
placeholder="请选择险种"
|
placeholder="请选择险种"
|
||||||
allowClear
|
allowClear
|
||||||
@@ -6619,7 +6655,11 @@ const Component = function () {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
renderFilterField('到期时间', (
|
},
|
||||||
|
{
|
||||||
|
key: 'endDateRange',
|
||||||
|
active: Boolean(listFilters.endDateRange?.[0] || listFilters.endDateRange?.[1]),
|
||||||
|
node: renderFilterField('到期时间', (
|
||||||
<Tooltip title={listFilters.insuranceType ? '' : '须先选择保险类型'}>
|
<Tooltip title={listFilters.insuranceType ? '' : '须先选择保险类型'}>
|
||||||
<DatePicker.RangePicker
|
<DatePicker.RangePicker
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -6637,21 +6677,55 @@ const Component = function () {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)),
|
)),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const listFilterSplit = splitFilterFields(listFilterFields, VM_FILTER_PRIMARY_VISIBLE_COUNT);
|
||||||
|
const listFilterPrimary = listFilterSplit.primary;
|
||||||
|
const listFilterExtra = listFilterSplit.extra;
|
||||||
|
const showListFilterExpand = shouldShowFilterExpand(listFilterFields.length);
|
||||||
|
const listFilterExtraActiveCount = listFilterExtra.filter((field) => field.active).length;
|
||||||
|
const listFilterExpandPanelId = 'ipc-filter-expand-panel';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vm-page ipc-page">
|
<div className="vm-page ipc-page">
|
||||||
<style>{PAGE_STYLE}</style>
|
<style>{PAGE_STYLE}</style>
|
||||||
|
|
||||||
<div className="ipc-list-shell">
|
<div className="ipc-list-shell">
|
||||||
<Card className="lc-filter-card" data-annotation-id="ipc-filter" title="筛选条件" bordered={false}>
|
<Card className="lc-filter-card" data-annotation-id="ipc-filter" title="筛选条件" bordered={false}>
|
||||||
<div className="lc-filter-grid">
|
<div className="vm-filter-body">
|
||||||
{(listFilterExpanded ? listFilterFieldNodes : listFilterFieldNodes.slice(0, 4))}
|
<div className="lc-filter-grid vm-filter-grid--primary" data-filter-tier="primary">
|
||||||
|
{listFilterPrimary.map((field) => field.node)}
|
||||||
|
</div>
|
||||||
|
{showListFilterExpand ? (
|
||||||
|
<div
|
||||||
|
className={`vm-filter-expand${listFilterExpanded ? ' is-expanded' : ''}`}
|
||||||
|
aria-hidden={!listFilterExpanded}
|
||||||
|
id={listFilterExpandPanelId}
|
||||||
|
>
|
||||||
|
<div className="vm-filter-expand-inner">
|
||||||
|
<div className="lc-filter-grid vm-filter-expand-grid" data-filter-tier="extra">
|
||||||
|
{listFilterExpanded ? listFilterExtra.map((field) => field.node) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="lc-filter-actions">
|
<div className="lc-filter-actions">
|
||||||
{listFilterFieldNodes.length > 4 ? (
|
{showListFilterExpand ? (
|
||||||
<Button type="link" className="lc-filter-expand-btn" onClick={() => setListFilterExpanded((prev) => !prev)}>
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="lc-filter-expand-btn vm-filter-toggle"
|
||||||
|
onClick={() => setListFilterExpanded((prev) => !prev)}
|
||||||
|
aria-expanded={listFilterExpanded}
|
||||||
|
aria-controls={listFilterExpandPanelId}
|
||||||
|
>
|
||||||
{listFilterExpanded ? '收起' : '更多筛选'}
|
{listFilterExpanded ? '收起' : '更多筛选'}
|
||||||
|
{listFilterExtraActiveCount > 0 && !listFilterExpanded ? (
|
||||||
|
<span className="vm-filter-toggle-badge" aria-label={`已设置 ${listFilterExtraActiveCount} 项扩展筛选`}>
|
||||||
|
{listFilterExtraActiveCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button onClick={handleListFilterReset}>重置</Button>
|
<Button onClick={handleListFilterReset}>重置</Button>
|
||||||
|
|||||||
@@ -480,10 +480,10 @@
|
|||||||
},
|
},
|
||||||
"markdownMap": {
|
"markdownMap": {
|
||||||
"ipc-header": "# 模块边界\n\n保险采购包含两条**相互独立**的业务线:\n\n1. **保单管理**:一车一档台账,OCR/导入/手工录入,停保/复驶/退保留痕,与车辆管理交车合规联动。\n2. **比价单**:采购前多方报价、批次管理、最晚付费预警、采购审批;**审批通过不自动写入保单台账**。\n\n右上角「查看需求说明」含完整研发 PRD。",
|
"ipc-header": "# 模块边界\n\n保险采购包含两条**相互独立**的业务线:\n\n1. **保单管理**:一车一档台账,OCR/导入/手工录入,停保/复驶/退保留痕,与车辆管理交车合规联动。\n2. **比价单**:采购前多方报价、批次管理、最晚付费预警、采购审批;**审批通过不自动写入保单台账**。\n\n右上角「查看需求说明」含完整研发 PRD。",
|
||||||
"ipc-filter": "# 筛选条件\n\n| 字段 | 说明 |\n|------|------|\n| 车牌号 | 单车模糊匹配;与多车牌互斥 |\n| 多车牌 | 每行一个或逗号分隔 |\n| VIN | 车辆识别代码 |\n| 品牌/型号 | 下拉可搜 |\n| 运营状态 | 租赁/自营/库存/退出运营 |\n| 保险状态 | 正常/临期 或 异常 |\n| 保险类型 + 到期时间 | 须先选险种再选日期范围 |\n\n点击「查询」应用条件;「重置」清空。",
|
"ipc-filter": "# 筛选条件\n\n默认首行展示主筛选项;其余收入「更多筛选」展开区(布局与重排规则同全局 `vm-filter-panel`)。\n\n| 字段 | 说明 |\n|------|------|\n| 车牌号 | 单车模糊匹配;与多车牌互斥 |\n| 多车牌 | 每行一个或逗号分隔 |\n| VIN | 车辆识别代码 |\n| 品牌/型号 | 下拉可搜 |\n| 运营状态 | 租赁/自营/库存/退出运营 |\n| 保险状态 | 正常/临期 或 异常 |\n| 保险类型 + 到期时间 | 须先选险种再选日期范围 |\n\n点击「查询」应用条件;「重置」清空。收起时若扩展区已有条件,按钮显示数量角标。",
|
||||||
"ipc-btn-compare-mgmt": "# 比价单管理\n\n独立业务线入口。支持:\n\n- 按创建时间、车牌筛选批次\n- 最晚付费临期/超期 KPI 筛选\n- 新建/编辑比价单\n- 查看采购状态(审批中/通过/驳回/撤回)\n\n**不自动同步**至保单台账。",
|
"ipc-btn-compare-mgmt": "# 比价单管理\n\n独立业务线入口。支持:\n\n- 按创建时间、车牌筛选批次\n- 最晚付费临期/超期 KPI 筛选\n- 新建/编辑比价单\n- 查看采购状态(审批中/通过/驳回/撤回)\n\n**不自动同步**至保单台账。",
|
||||||
"ipc-btn-policy-ocr": "# 保单批量识别(OCR)\n\n- 业务类型:保单录入、停保、复驶、退保\n- 自动识别车牌、保单号并匹配台账\n- 识别结果须**人工确认**后落库\n- OCR 确认页**不展示**承保险种明细、分项保费等(手工新增保留)",
|
"ipc-btn-policy-ocr": "# 保单批量识别(OCR)\n\n- 业务类型:保单录入、停保、复驶、退保\n- 自动识别车牌、保单号并匹配台账\n- 识别结果须**人工确认**后落库\n- OCR 确认页**不展示**承保险种明细、分项保费等(手工新增保留)",
|
||||||
"ipc-vehicle-table": "# 台账列表\n\n| 列 | 说明 |\n|----|------|\n| 车牌/VIN/品牌型号 | 车辆标识 |\n| 运营状态 | 退出运营行灰显 |\n| 保险状态 | 与 KPI 口径一致 |\n| 五类险种到期日 | 副文案:临期天数/停保/退保标签 |\n| 操作·管理 | 打开车辆保险档案 |",
|
"ipc-vehicle-table": "# 台账列表\n\n| 列 | 说明 |\n|----|------|\n| 车牌/VIN/品牌型号 | 车辆标识 |\n| 运营状态 | 退出运营行灰显 |\n| 保险状态 | 与 KPI 口径一致 |\n| 五类险种到期日 | 副文案:临期天数/停保/退保标签 |\n| 操作·更多 → 管理 | 打开车辆保险档案 |",
|
||||||
"ipc-vehicle-mgmt": "# 车辆保险档案\n\n展示车辆元信息、保险状态胶囊、客户/产权方等。\n\n**保险状态**:交强险+商业险均在有效期内为正常,否则异常(禁止交车)。",
|
"ipc-vehicle-mgmt": "# 车辆保险档案\n\n展示车辆元信息、保险状态胶囊、客户/产权方等。\n\n**保险状态**:交强险+商业险均在有效期内为正常,否则异常(禁止交车)。",
|
||||||
"ipc-vehicle-mgmt-tabs": "# Tab 结构\n\n- **全周期记录**:时间轴,左新保/续保,右停保/复驶/退保\n- **分险种 Tab**:交强险、商业险、超赔、驾意、货物\n\n历史表含:导入时间、类型、保单号、保险状态、保司、日期、金额、操作。\n\n当前有效保单可办理停保/复驶/退保;归档记录只读。",
|
"ipc-vehicle-mgmt-tabs": "# Tab 结构\n\n- **全周期记录**:时间轴,左新保/续保,右停保/复驶/退保\n- **分险种 Tab**:交强险、商业险、超赔、驾意、货物\n\n历史表含:导入时间、类型、保单号、保险状态、保司、日期、金额、操作。\n\n当前有效保单可办理停保/复驶/退保;归档记录只读。",
|
||||||
"ipc-compare-mgmt": "# 比价单批次\n\n列表展示创建时间、创建人、购买记录数、确认金额、备注等。\n\n操作:编辑、删除(未提交审批时)、查看附件。",
|
"ipc-compare-mgmt": "# 比价单批次\n\n列表展示创建时间、创建人、购买记录数、确认金额、备注等。\n\n操作:编辑、删除(未提交审批时)、查看附件。",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
@import url('../../../resources/oneos-web-legacy/styles/oneos-app.css');
|
@import url('../../../resources/oneos-web-legacy/styles/oneos-app.css');
|
||||||
@import url('../../../resources/oneos-web-legacy/styles/ant-table-global-fix.css');
|
@import url('../../../resources/oneos-web-legacy/styles/ant-table-global-fix.css');
|
||||||
|
/* 列表「更多」下拉:与租赁合同等台账页 OperationActions 效果一致 */
|
||||||
|
@import url('../../../common/vm-operation-actions.css');
|
||||||
|
|
||||||
/* 列表页全屏滚动布局 */
|
/* 列表页全屏滚动布局 */
|
||||||
.vm-page.ipc-page {
|
.vm-page.ipc-page {
|
||||||
@@ -200,6 +202,69 @@
|
|||||||
font-weight: var(--oneos-font-weight-medium);
|
font-weight: var(--oneos-font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 更多筛选展开区 — 与 vm-filter-panel / 合同模板等台账页一致 */
|
||||||
|
.vm-page.ipc-page .vm-filter-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ipc-page .vm-filter-expand {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.28s ease, opacity 0.22s ease, margin-top 0.22s ease;
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ipc-page .vm-filter-expand.is-expanded {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
opacity: 1;
|
||||||
|
margin-top: 12px;
|
||||||
|
pointer-events: auto;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ipc-page .vm-filter-expand.is-expanded .vm-filter-expand-inner {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ipc-page .vm-filter-expand-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ipc-page .vm-filter-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ipc-page .vm-filter-toggle-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--oneos-color-primary, #10b981);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.vm-page.ipc-page .vm-filter-expand {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 列表工具栏 */
|
/* 列表工具栏 */
|
||||||
.vm-page.ipc-page .lc-table-toolbar-meta {
|
.vm-page.ipc-page .lc-table-toolbar-meta {
|
||||||
font-size: var(--oneos-font-size-sm);
|
font-size: var(--oneos-font-size-sm);
|
||||||
|
|||||||
153
src/prototypes/lease-business-detail/.spec/field-checks.md
Normal file
153
src/prototypes/lease-business-detail/.spec/field-checks.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# 租赁业务明细 · 单元格系统校验
|
||||||
|
|
||||||
|
> 实现:`utils/field-checks.ts`、`utils/system-ref.ts`;UI:`components/FieldCheckIcon.tsx`
|
||||||
|
|
||||||
|
列表部分列在单元格右侧展示**校验图标**(绿色 ✓ / 黄色 ⚠)。点击可查看说明;警告项在支持时提供**一键替换**为系统建议值。
|
||||||
|
|
||||||
|
## 对照数据源(原型本地种子)
|
||||||
|
|
||||||
|
| 来源 | 路径 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| 客户管理 | `customer-management/data/customers.json` | 客户名称是否已登记 |
|
||||||
|
| 车辆管理 | `vehicle-management/data/vehicles.json` | 车牌是否存在、交车/还车日期、登记所有权 |
|
||||||
|
| 明细种子合同 | `lease-business-detail/data/rows.json` | 车牌 + 客户 → 合同(押金、租金、提车/退车日期等) |
|
||||||
|
|
||||||
|
启动时由 `system-ref.ts` 聚合为内存索引;**当前原型未接真实后端 API**。
|
||||||
|
|
||||||
|
## 交互与状态
|
||||||
|
|
||||||
|
| 状态 | 图标 | 点击行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ok` | 绿色 ✓ | 展示通过说明(tooltip) |
|
||||||
|
| `warn` | 黄色 ⚠ | 展示差异说明;部分字段可「一键替换」 |
|
||||||
|
| `none` | 不显示 | 无校验条件或无需提示 |
|
||||||
|
|
||||||
|
匹配规则:字符串比较前 **trim 首尾空格**;金额比较容差 **0.005 元**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 客户名称(`customerName`)
|
||||||
|
|
||||||
|
**前置**:车牌、客户名称均非空,否则不校验。
|
||||||
|
|
||||||
|
**期望客户 `expected`**:`resolveExpectedCustomer(车牌, 提车日期, 退车日期)`
|
||||||
|
|
||||||
|
- 在同车牌的所有合同记录中筛选;
|
||||||
|
- 若明细行有提车日期且合同有提车日期 → 必须一致;
|
||||||
|
- 若明细行有退车日期且合同有退车日期 → 必须一致;
|
||||||
|
- 取匹配合同的客户名称(多条时以后遍历结果为准)。
|
||||||
|
|
||||||
|
**判定顺序**(命中即返回,不再往下):
|
||||||
|
|
||||||
|
| 优先级 | 条件 | 结果 | 提示文案 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `expected` 非空且与当前客户名完全一致 | ✅ 通过 | 客户名称与系统登记一致 |
|
||||||
|
| 2 | `getContractRef(车牌, 客户名)` 命中且名称一致 | ✅ 通过 | 客户名称与租赁合同一致 |
|
||||||
|
| 3 | `expected` 非空但与当前客户名不一致 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;**建议客户:{expected}**;可一键替换 |
|
||||||
|
| 4 | `isKnownCustomerName(客户名)` 为真 | ✅ 通过(弱校验) | 客户名称已在客户管理中登记 |
|
||||||
|
| 5 | 以上均否 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;请核对提车日期与退车日期 |
|
||||||
|
|
||||||
|
**`isKnownCustomerName` 弱校验**:
|
||||||
|
|
||||||
|
- 与客户管理名单**完全一致**;或
|
||||||
|
- **双向包含**(任一方名称包含另一方,如简称/全称)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 车牌号码(`plateNo`)
|
||||||
|
|
||||||
|
| 条件 | 结果 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 车牌为空 | 不校验 | — |
|
||||||
|
| 车牌在车辆管理中 | ✅ 通过 | 车牌已在车辆管理中登记 |
|
||||||
|
| 否则 | ⚠️ 警告 | 车牌不存在;该车牌未在系统「车辆管理」中登记,无法导入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提车日期(`pickupDate`)
|
||||||
|
|
||||||
|
对照车辆管理 `lastDeliveryTime`(交车管理)。
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 无车辆档案或无提车日期 | 不校验 |
|
||||||
|
| 与系统交车日期一致(YYYY-MM-DD) | ✅ 提车日期与交车管理一致 |
|
||||||
|
| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 退车日期(`returnDate`)
|
||||||
|
|
||||||
|
对照车辆管理 `lastReturnTime`(还车记录)。
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 无车辆档案或无退车日期 | 不校验 |
|
||||||
|
| 与系统还车日期一致 | ✅ 退车日期与还车记录一致 |
|
||||||
|
| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 押金(`deposit`)
|
||||||
|
|
||||||
|
对照租赁合同保证金(`车牌 + 客户名` 合同)。
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 无合同或押金非数值 | 不校验 |
|
||||||
|
| 与合同保证金一致 | ✅ 押金与合同保证金一致 |
|
||||||
|
| 不一致 | ⚠️ 警告;可一键替换为合同值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合同标的租金(`contractRent`)
|
||||||
|
|
||||||
|
对照租赁合同租金。
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 无合同或无租金 | 不校验 |
|
||||||
|
| 与合同租金一致 | ✅ 租金与合同一致 |
|
||||||
|
| 不一致 | ⚠️ 警告;可一键替换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 应收租金(`receivableRent`)
|
||||||
|
|
||||||
|
**期望**:`合同标的租金 × 结算周期月数`(月数来自合同 `paymentMethod` 解析:月度 1 / 季度 3 / 半年 6 / 年度 12)。
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 期望与当前均为 0 | 不校验 |
|
||||||
|
| 与期望一致 | ✅ 应收租金 = 合同标的租金 × N 个月 |
|
||||||
|
| 不一致 | ⚠️ 警告;展示正确金额;可一键替换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 里程减免 / 其他减免(`mileageDeduction`、`otherDeduction`)
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 值为 0 | 不校验 |
|
||||||
|
| 值为负数 | ✅ 以负数显示(符合 Excel 口径) |
|
||||||
|
| 值为正数 | ⚠️ 警告:应录入负值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 资产归属(`assetOwner`)
|
||||||
|
|
||||||
|
对照车辆管理「登记所有权」。
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 无车辆档案或无资产归属 | 不校验 |
|
||||||
|
| 与登记所有权一致 | ✅ 与车辆登记所有权一致 |
|
||||||
|
| 不一致 | ⚠️ 警告;可一键替换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 启用校验的列
|
||||||
|
|
||||||
|
在 `components/tableColumns.ts` 中通过 `checkField` 挂载,由 `DetailTable` 调用 `getFieldCheck()`:
|
||||||
|
|
||||||
|
`plateNo`、`customerName`、`pickupDate`、`returnDate`、`deposit`、`contractRent`、`receivableRent`、`mileageDeduction`、`otherDeduction`、`assetOwner`。
|
||||||
@@ -1,94 +1,54 @@
|
|||||||
# Prototype Review
|
# Prototype Review
|
||||||
|
|
||||||
- 审查目标:`src/prototypes/lease-business-detail`(页面 `list`)
|
- 审查目标:`src/prototypes/lease-business-detail`(页面 `list`)
|
||||||
- 用户资料/参考资料:
|
- 用户资料/参考资料:`.spec/requirements-prd.md`、`lease-business-ledger` PRD、原型源码
|
||||||
- 对话中已确认的 Excel《2026年业务二部运营台账总表6.17 - 新版》「2.租赁车辆收入明细表」字段顺序、23 项汇总、6 项公式列与 7 项 KPI 口径
|
- 生成时间:2026-07-13 15:50
|
||||||
- `src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx`
|
- 状态:**复审通过(对账能力已移除)**
|
||||||
- `src/prototypes/oneos-web-data-analysis/pages/08-租赁车辆收入明细.jsx`
|
|
||||||
- `src/prototypes/oneos-web-data-analysis/pages/05-业务部台账.jsx`(租赁业绩数据来源说明)
|
|
||||||
- `src/prototypes/lease-business-ledger/.spec/requirements-prd.md`(对照模块)
|
|
||||||
- 原型源码、`annotation-source.json`、`data/rows.json`
|
|
||||||
- 生成时间:2026-07-09 16:18
|
|
||||||
|
|
||||||
## 总体点评
|
## 总体点评
|
||||||
|
|
||||||
原型在「租赁运营明细宽表」主路径上已具备可运行闭环:筛选 → 7 项 KPI → 47 列分页列表 → 23 项表尾汇总 → 批量导入/导出,且公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏)与 Excel SUBTOTAL 逻辑一致,553 条 2026 年样例数据汇总与台账样表核对无偏差。列表字段、双运维费列、左冻结年份/车牌、日期列排序等近期决策在实现中落地较完整。
|
租赁业务明细为单页宽表维护:筛选(含收款状态)→ 7 项 KPI → 批量导入/导出 → 48 列明细表。行内操作仅保留**编辑、删除、变更日志**;盈亏月度汇总改由业务部台账承接,本页不再提供 Tab 或对账锁定。
|
||||||
|
|
||||||
主要缺口集中在**模块边界与资料体系**:相对 `src/resources` 中「租赁车辆收入明细」双 Tab 结构,当前仅实现明细列表,缺少「租赁业务盈亏月度汇总」;相对 `lease-business-ledger`,本原型未说明「明细维护」与「台账收款关联」的分工,实收仍为导入手工列。导入流程可完成演示,但缺少重复数据与行级校验策略,长期批量维护存在数据质量风险。整体适合作为 Excel 明细表的交互原型,尚不足以单独支撑数据分析侧完整用户旅程。
|
主路径「筛选 → KPI → 宽表 → 导入/导出 → 编辑」可完整演示。剩余风险主要为跨模块集成(实收与租赁业务台账/收款模块自动同步)与明细表暂无「氢费预充值」字段——已降为待规划,不阻塞当前原型验收。
|
||||||
|
|
||||||
## P0-P3 优先级问题
|
## P0-P3 优先级问题
|
||||||
|
|
||||||
### P1 - 缺少「租赁业务盈亏月度汇总」Tab / 页面
|
(本轮复审无新增 P0–P3 问题。)
|
||||||
|
|
||||||
- 证据:`src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx` 与 `src/prototypes/oneos-web-data-analysis/pages/08-租赁车辆收入明细.jsx` 均包含「租赁业务明细」+「租赁业务盈亏月度汇总」两个 Tab;当前 `index.tsx` 仅单页 `list`,`annotation-source.json` 目录也只有一条路由。
|
|
||||||
- 影响:需要按月查看盈亏汇总、对比年度趋势的用户无法在本原型完成核心分析任务,与既有产品资料表达不一致。
|
|
||||||
- 修复方向:在 `.spec` 明确是否纳入本原型范围;若纳入,增加第二 Tab 或子路由,按年份聚合月份级押金/应收/实收/未收/收入/成本/盈亏,并保留导出。
|
|
||||||
|
|
||||||
### P1 - 与「租赁业务台账」边界不清,实收口径未闭环
|
|
||||||
|
|
||||||
- 证据:`lease-business-ledger` PRD 定义收款关联、`calcReceivedAmountFromReceipts`、明细状态;本原型 `receivedAmount` 为导入手工列(`batch-io.ts`),无关联收款、无「未收款/部分收款/已收款」状态;`oneos-web-data-analysis/05-业务部台账.jsx` 注明租赁业绩来自「租赁业务明细」。
|
|
||||||
- 影响:业务方可能混淆两个模块职责;实收可任意填写导致未收/盈亏与真实收款脱节,下游汇总分析可信度下降。
|
|
||||||
- 修复方向:在 `.spec/requirements-prd.md` 写清「明细表 = 业管 Excel 维护」「台账 = 收款闭环」或统一为一条链路;至少增加实收 ≤ 应收合计校验,并标注与台账/收款模块的集成计划。
|
|
||||||
|
|
||||||
### P2 - 批量导入仅追加,无重复键与覆盖策略
|
|
||||||
|
|
||||||
- 证据:`index.tsx` 导入成功后 `setRecords((prev) => [...imported, ...prev])`;`parseDetailImportFile` 仅以「年份 + 月份 + 车牌号码」判空跳过,不检测同键已存在行。
|
|
||||||
- 影响:业管重复导入同月同车数据会产生重复行,KPI 与表尾汇总被放大,难以恢复。
|
|
||||||
- 修复方向:定义业务主键(建议 `year + month + plateNo`,是否加业务员待确认);导入时支持跳过/覆盖/报错三种策略,并返回行级结果摘要。
|
|
||||||
|
|
||||||
### P2 - 导入与计算缺少行级校验反馈
|
|
||||||
|
|
||||||
- 证据:导入失败仅 Toast(`未解析到有效数据` / `导入失败`);无字段格式、数值范围、必填组合(如平均天数为 0 时成本为 0)的逐行报告;`receivedAmount` 可大于 `receivableTotal` 产生负未收且无提示。
|
|
||||||
- 影响:大批量维护时难以定位问题行,错误数据静默进入列表。
|
|
||||||
- 修复方向:导入结果弹层展示成功/跳过/失败行数与原因;对实收超额、非法日期、押金格式等做行级校验清单。
|
|
||||||
|
|
||||||
### P3 - 缺少正式需求文档,annotation 未覆盖字段与公式说明
|
|
||||||
|
|
||||||
- 证据:`.spec/` 仅有空的 `prototype-comments.json`,无 `requirements-prd.md`;`annotation-source.json` 仅 4 个区块简述,未像 `lease-business-ledger` 那样标注各列口径、公式列只读规则、与 Excel 样表映射。
|
|
||||||
- 影响:评审、测试与后续开发缺少单一事实来源,跨模块对齐成本高。
|
|
||||||
- 修复方向:补充 `.spec/requirements-prd.md`(用户、范围、不做项、验收清单);扩展 annotation 对公式列、导入模板列、KPI/汇总口径的说明。
|
|
||||||
|
|
||||||
## 完整性与项目对齐
|
## 完整性与项目对齐
|
||||||
|
|
||||||
| 维度 | 结论 |
|
| 维度 | 结论 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 核心用户 | 隐含为业务二部业管/运营人员(样例数据均为「业务二部」),未在 `.spec` 明文写出 |
|
| 核心用户 | 业务二部业管 / 运营人员 |
|
||||||
| 核心任务 | **已覆盖**:按条件查询明细、查看 KPI 与表尾汇总、Excel 模板导入与导出 |
|
| 核心任务 | **已覆盖**:明细维护、状态筛选、KPI/汇总、导入导出 |
|
||||||
| 主流程入口 | 侧边栏 `prototypes/lease-business-detail`(`sidebar-tree.json`) |
|
| Tab 结构 | **已对齐**:单页宽表;盈亏汇总见业务部台账 |
|
||||||
| 主流程出口 | 导出当前筛选结果;无保存至服务端、无返回上游分析页导航 |
|
| 与 ledger 分工 | 已文档化;实收仍为手工列,**集成待规划** |
|
||||||
| 范围边界 | **已对齐**:47 列宽表、6 公式列自动计算、7+23 汇总结构、2026 样例 1–6 月 |
|
| 资料冲突 | 明细「已结清」vs 台账「已收款」——跨模块术语待产品统一 |
|
||||||
| 范围缺口 | **未覆盖**:盈亏月度汇总 Tab;行内编辑/删除;持久化;收款关联 |
|
|
||||||
| 与 ledger 关系 | **待确认**:两模块均处理租赁运营金额,字段模型不同(明细 47 列 vs 台账账单字段),需产品定稿分工 |
|
|
||||||
| 与数据分析关系 | `05-业务部台账` 声明租赁业绩取自本明细;当前原型未暴露 API/聚合接口,仅本地 JSON,**集成待确认** |
|
|
||||||
| 资料冲突 | Legacy 数据分析页为简化列(应收/实收/自然月收入等 ~20 列),本原型已升级为 Excel 运营台账 47 列——**以对话与 Excel 样表为准**,Legacy 作交互参考而非字段真相 |
|
|
||||||
|
|
||||||
## 业务逻辑连贯性
|
## 业务逻辑连贯性
|
||||||
|
|
||||||
- **筛选逻辑**:`statMonth`(`YYYY-MM`)、业务部门、业务员精确匹配,客户名称模糊匹配,车牌多选;查询按钮触发 `appliedFilters`,与 KPI/汇总联动,逻辑自洽。
|
- 收款状态、公式列、导入校验链路自洽。
|
||||||
- **汇总口径**:KPI 7 项与表尾 23 项均基于**全量筛选结果**(非当前页),与 Excel SUBTOTAL 语义一致;分页仅影响表格 body,不影响汇总,合理。
|
- KPI/表尾汇总与筛选结果口径一致。
|
||||||
- **公式链路**:`calc-detail.ts` 与 Excel 公式一致(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏);加载种子数据与导入后均调用 `applyDetailCalculations`,避免公式列脏读。
|
|
||||||
- **状态迁移**:无「草稿/已提交/归档」;导入即入库(内存),无版本与审计轨迹。
|
|
||||||
- **角色权限**:未实现员工/主管数据范围(`lease-business-ledger` 有 `filterRecordsByRole`),本原型全员可见全部 553 条。
|
|
||||||
- **跨页一致性**:单页原型,无跨路由状态问题。
|
|
||||||
|
|
||||||
## 状态、异常、边界与恢复
|
## 状态、异常、边界与恢复
|
||||||
|
|
||||||
| 场景 | 现状 | 评价 |
|
| 场景 | 现状 |
|
||||||
|------|------|------|
|
|------|------|
|
||||||
| 空数据 | 表格区展示空状态文案 + 导入引导 | 已覆盖 |
|
| 重复导入 | 跳过 / 覆盖 / 报错 + 摘要 Toast |
|
||||||
| 加载 | 筛选查询 280ms 骨架屏 | 已覆盖(模拟) |
|
| 实收超额 | 导入/编辑拦截;列表负未收警示 |
|
||||||
| 导入成功 | Toast + 关闭弹窗 + 重置页码 | 已覆盖 |
|
| 行内操作 | 编辑、删除、变更日志 |
|
||||||
| 导入无有效行 | Toast 提示检查模板 | 已覆盖,但无行级原因 |
|
|
||||||
| 导入异常 | 通用「导入失败」Toast | 偏弱 |
|
|
||||||
| 导出 | 导出筛选结果 + Toast | 已覆盖 |
|
|
||||||
| 重复导入 | 直接追加 | **缺恢复路径** |
|
|
||||||
| 实收 > 应收 | 允许,未收为负 | **缺业务提示** |
|
|
||||||
| 刷新页面 | 恢复种子数据,导入丢失 | 原型可接受,需标注 |
|
|
||||||
| 宽表横向滚动 | `lbd-table-wrap` overflow 修复 | 已覆盖 |
|
|
||||||
| 并发/权限 | 未涉及 | 不适用 |
|
|
||||||
|
|
||||||
## 证据与评估说明
|
## 证据与评估说明
|
||||||
|
|
||||||
- **用户资料优先级**:本轮无新的用户附件;以对话中 Excel 对齐决策为主。与 Legacy 简化列模型存在差异,已在「完整性与项目对齐」标记,**不视为 P0 冲突**。
|
- **读取范围**:`index.tsx`、`DetailTable.tsx`、`import-merge.ts`、`validate-detail.ts`、`requirements-prd.md`
|
||||||
- **读取范围**:`rules/prototype-review-guide.md`;`index.tsx`、`types.ts`、`utils/calc-detail.ts`、`utils/batch-io.ts`、`utils/detail.ts`、`components/*`、`data/rows.json`、`annotation-source.json`;`src/resources/oneos-web-legacy/数据分析/租赁车辆收入明细.jsx`;`lease-business-ledger/.spec/requirements-prd.md`;`oneos-web-data-analysis/pages/05-业务部台账.jsx`(片段);`.axhub/make/sidebar-tree.json`。
|
- **独立评估**:`degraded`(单轮复审,对照需求清单验收)
|
||||||
- **独立评估**:`degraded`(未使用双子代理;在同一审查内先建立业务基线再对照源码,结论仍基于双源交叉验证)。
|
|
||||||
|
## 首轮问题关闭记录
|
||||||
|
|
||||||
|
| 原优先级 | 问题 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 | 缺少盈亏月度汇总 Tab | ➡️ 改由业务部台账;本页已移除 Tab |
|
||||||
|
| P1 | 对账/锁定无入口 | ➡️ 产品范围调整:本页不做对账锁定 |
|
||||||
|
| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate |
|
||||||
|
| P2 | 实收超额 | ✅ 导入/编辑校验 + 负未收样式 |
|
||||||
|
| P3 | PRD/annotation 偏差 | ✅ 公式、导出 48 列、边界文案已更新 |
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
### 不做范围(当前原型)
|
### 不做范围(当前原型)
|
||||||
|
|
||||||
- 「租赁业务盈亏月度汇总」独立 Tab(Legacy 数据分析页有,本原型待规划)
|
- 租赁业务盈亏月度汇总(改由**业务部台账**汇总展示)
|
||||||
- 服务端持久化、员工/主管数据权限隔离(主管预览:`?role=supervisor`)
|
- 服务端持久化、员工/主管数据权限隔离(主管预览:`?role=supervisor`)
|
||||||
- 关联收款记录自动回写实收
|
- 关联收款记录自动回写实收
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
## 2. 筛选区
|
## 2. 筛选区
|
||||||
|
|
||||||
默认展示 5 项(一行),「更多筛选」展开车牌多选。
|
默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。
|
||||||
|
|
||||||
| 筛选项 | 说明 |
|
| 筛选项 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
| 业务部门 | 精确匹配 |
|
| 业务部门 | 精确匹配 |
|
||||||
| 业务员 | 精确匹配 |
|
| 业务员 | 精确匹配 |
|
||||||
| 客户名称 | 模糊包含 |
|
| 客户名称 | 模糊包含 |
|
||||||
| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算) |
|
| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |
|
||||||
| 车牌号码 | 多选精确匹配(更多筛选) |
|
| 车牌号码 | 多选精确匹配(更多筛选) |
|
||||||
|
|
||||||
查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。
|
查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。
|
||||||
@@ -70,12 +70,24 @@
|
|||||||
|
|
||||||
| 按钮 | 行为 |
|
| 按钮 | 行为 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 批量导入 | 下载模板(36 列手工录入)→ 上传 Excel → 公式列自动计算 |
|
| 批量导入 | 下载模板(36 列手工录入)→ 选择重复策略 → 上传 Excel → 公式列自动计算 |
|
||||||
| 批量导出 | 导出当前筛选结果(完整 48 列,含公式列结果) |
|
| 批量导出 | 导出当前筛选结果(**状态** + 47 列业务字段,共 48 列) |
|
||||||
|
|
||||||
### 行内操作
|
### 行内操作
|
||||||
|
|
||||||
编辑、删除、变更日志(操作列);已锁定记录仅主管可改(`?role=supervisor`)。
|
编辑、删除、变更日志。
|
||||||
|
|
||||||
|
### 导入重复策略
|
||||||
|
|
||||||
|
业务主键:`年份 + 月份 + 车牌号码`。
|
||||||
|
|
||||||
|
| 策略 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 跳过重复(默认) | 已存在同键记录则跳过 |
|
||||||
|
| 覆盖重复 | 用导入行覆盖已有记录 |
|
||||||
|
| 报错 | 重复行写入错误日志 |
|
||||||
|
|
||||||
|
导入与编辑均校验:**实收金额 ≤ 应收合计**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,6 +116,32 @@
|
|||||||
|
|
||||||
押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。
|
押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。
|
||||||
|
|
||||||
|
### 单元格系统校验
|
||||||
|
|
||||||
|
部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。
|
||||||
|
|
||||||
|
**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。
|
||||||
|
|
||||||
|
| 字段 | 校验要点 |
|
||||||
|
|---|---|
|
||||||
|
| 车牌号码 | 是否在车辆管理中登记 |
|
||||||
|
| **客户名称** | 见下表 |
|
||||||
|
| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |
|
||||||
|
| 押金、合同标的租金 | 与租赁合同一致 |
|
||||||
|
| 应收租金 | = 合同标的租金 × 结算周期月数 |
|
||||||
|
| 里程 / 其他减免 | 应以负数录入 |
|
||||||
|
| 资产归属 | 与车辆登记所有权一致 |
|
||||||
|
|
||||||
|
#### 客户名称判定顺序
|
||||||
|
|
||||||
|
1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。
|
||||||
|
2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。
|
||||||
|
3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。
|
||||||
|
4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。
|
||||||
|
5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。
|
||||||
|
|
||||||
|
车牌或客户名为空时不显示校验图标。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 公式列(系统自动计算)
|
## 6. 公式列(系统自动计算)
|
||||||
@@ -112,7 +150,7 @@
|
|||||||
|
|
||||||
| 字段 | 计算方式 |
|
| 字段 | 计算方式 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **应收合计** | 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||||
| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||||
| **未收** | 应收合计 − 实收金额 |
|
| **未收** | 应收合计 − 实收金额 |
|
||||||
| **车辆实际成本** | 车辆标准成本 × 平均天数 |
|
| **车辆实际成本** | 车辆标准成本 × 平均天数 |
|
||||||
@@ -141,7 +179,9 @@
|
|||||||
| 筛选 / KPI 汇总 | `utils/detail.ts` |
|
| 筛选 / KPI 汇总 | `utils/detail.ts` |
|
||||||
| 收款状态 | `utils/payment-status.ts` |
|
| 收款状态 | `utils/payment-status.ts` |
|
||||||
| 公式计算 | `utils/calc-detail.ts` |
|
| 公式计算 | `utils/calc-detail.ts` |
|
||||||
| 导入导出 | `utils/batch-io.ts` |
|
| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` |
|
||||||
|
| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |
|
||||||
|
| 保存校验 | `utils/validate-detail.ts` |
|
||||||
| 变更日志 | `utils/change-log.ts` |
|
| 变更日志 | `utils/change-log.ts` |
|
||||||
| 类型 | `types.ts` |
|
| 类型 | `types.ts` |
|
||||||
| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |
|
| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |
|
||||||
@@ -155,6 +195,7 @@
|
|||||||
3. 左冻结:状态 + 月份 + 车牌号码
|
3. 左冻结:状态 + 月份 + 车牌号码
|
||||||
4. 氢费非零可点击明细;编辑/删除/变更日志可用
|
4. 氢费非零可点击明细;编辑/删除/变更日志可用
|
||||||
5. 未收 > 0 标红;盈亏正负着色
|
5. 未收 > 0 标红;盈亏正负着色
|
||||||
|
6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -162,6 +203,5 @@
|
|||||||
|
|
||||||
详见 `.spec/prototype-review.md`:
|
详见 `.spec/prototype-review.md`:
|
||||||
|
|
||||||
- P1:盈亏月度汇总 Tab
|
- P2:与租赁业务台账 / 收款模块集成口径(实收自动回写)
|
||||||
- P1:与租赁业务台账 / 收款模块集成口径
|
- P3:氢费预充值字段(业务部台账月度汇总占位,明细表暂无对应列)
|
||||||
- P2:导入重复键(年+月+车牌)处理策略
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ export const COLUMN_HEADER_TIPS: Record<string, string> = {
|
|||||||
'lbd-col-plate': '车牌号码与系统「车辆管理」匹配;不存在的车牌导入失败。',
|
'lbd-col-plate': '车牌号码与系统「车辆管理」匹配;不存在的车牌导入失败。',
|
||||||
'lbd-col-vehicle-type': '根据车牌号,显示系统「品牌·型号」。',
|
'lbd-col-vehicle-type': '根据车牌号,显示系统「品牌·型号」。',
|
||||||
'lbd-col-business-dept': '根据车牌对应租赁合同显示业务部门与业务员;展示时合并为两行。',
|
'lbd-col-business-dept': '根据车牌对应租赁合同显示业务部门与业务员;展示时合并为两行。',
|
||||||
'lbd-col-customer': '导入后与客户管理比对;不一致时提示正确客户并支持一键替换。',
|
'lbd-col-customer': '导入后按车牌+租期与合同比对客户名;不一致时提示建议客户并支持一键替换(规则见 field-checks.md)。',
|
||||||
'lbd-col-pickup-date': '对照交车管理校验提车日期;不一致时显示警告。',
|
'lbd-col-pickup-date': '对照交车管理校验提车日期;不一致时显示警告。',
|
||||||
'lbd-col-return-date': '对照还车记录校验退车日期;不一致时显示警告。',
|
'lbd-col-return-date': '对照还车记录校验退车日期;不一致时显示警告。',
|
||||||
'lbd-col-avg-days': '平均天数 = 本月在租天数 ÷ 当月自然日天数,保留两位小数。',
|
'lbd-col-avg-days': '平均天数 = 本月在租天数 ÷ 当月自然日天数,保留两位小数。',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
|
|||||||
import type { LeaseBusinessDetailRow } from '../types';
|
import type { LeaseBusinessDetailRow } from '../types';
|
||||||
import { detailRowYearMonth } from '../utils/detail';
|
import { detailRowYearMonth } from '../utils/detail';
|
||||||
import { DETAIL_EDIT_FIELDS, draftFromRow, parseDraftToPatch } from '../utils/edit-fields';
|
import { DETAIL_EDIT_FIELDS, draftFromRow, parseDraftToPatch } from '../utils/edit-fields';
|
||||||
|
import { validateDetailPatch } from '../utils/validate-detail';
|
||||||
|
|
||||||
interface DetailEditModalProps {
|
interface DetailEditModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -13,9 +14,13 @@ interface DetailEditModalProps {
|
|||||||
|
|
||||||
export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalProps) {
|
export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalProps) {
|
||||||
const [draft, setDraft] = useState<Record<string, string | number>>({});
|
const [draft, setDraft] = useState<Record<string, string | number>>({});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && row) setDraft(draftFromRow(row));
|
if (open && row) {
|
||||||
|
setDraft(draftFromRow(row));
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
}, [open, row]);
|
}, [open, row]);
|
||||||
|
|
||||||
if (!open || !row) return null;
|
if (!open || !row) return null;
|
||||||
@@ -24,7 +29,13 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP
|
|||||||
|
|
||||||
const handleSubmit = (event: React.FormEvent) => {
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSave(parseDraftToPatch(draft));
|
const patch = parseDraftToPatch(draft);
|
||||||
|
const validationError = validateDetailPatch(row, patch);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(patch);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +54,8 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<form id="lbd-edit-form" className="vm-modal-body lbd-edit-form" onSubmit={handleSubmit}>
|
<form id="lbd-edit-form" className="vm-modal-body lbd-edit-form" onSubmit={handleSubmit}>
|
||||||
<p className="lbd-edit-hint">公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏、氢费等)保存后由系统自动重算。</p>
|
<p className="lbd-edit-hint">公式列(应收合计、月度收入、未收、车辆实际成本、总成本、盈亏、氢费等)保存后由系统自动重算。实收金额不得大于应收合计。</p>
|
||||||
|
{error ? <p className="lbd-edit-error" role="alert">{error}</p> : null}
|
||||||
<div className="lbd-edit-grid">
|
<div className="lbd-edit-grid">
|
||||||
{DETAIL_EDIT_FIELDS.map((field) => (
|
{DETAIL_EDIT_FIELDS.map((field) => (
|
||||||
<label key={field.key} className="lbd-edit-field">
|
<label key={field.key} className="lbd-edit-field">
|
||||||
|
|||||||
@@ -1,26 +1,43 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Download, Upload, X } from 'lucide-react';
|
import { Upload, X } from 'lucide-react';
|
||||||
|
import type { DetailImportDuplicateStrategy } from '../utils/import-merge';
|
||||||
|
|
||||||
interface DetailImportModalProps {
|
interface DetailImportModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDownloadTemplate: () => void;
|
onDownloadTemplate: () => void;
|
||||||
onImport: (file: File) => void;
|
onImport: (file: File) => void;
|
||||||
|
duplicateStrategy: DetailImportDuplicateStrategy;
|
||||||
|
onDuplicateStrategyChange: (strategy: DetailImportDuplicateStrategy) => void;
|
||||||
failedCount?: number;
|
failedCount?: number;
|
||||||
|
skippedCount?: number;
|
||||||
|
overwrittenCount?: number;
|
||||||
onDownloadErrorLog?: () => void;
|
onDownloadErrorLog?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DUPLICATE_OPTIONS: { value: DetailImportDuplicateStrategy; label: string; hint: string }[] = [
|
||||||
|
{ value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌」已存在则跳过' },
|
||||||
|
{ value: 'overwrite', label: '覆盖重复', hint: '用导入行覆盖已有记录' },
|
||||||
|
{ value: 'error', label: '报错', hint: '重复行写入错误日志' },
|
||||||
|
];
|
||||||
|
|
||||||
export function DetailImportModal({
|
export function DetailImportModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onDownloadTemplate,
|
onDownloadTemplate,
|
||||||
onImport,
|
onImport,
|
||||||
|
duplicateStrategy,
|
||||||
|
onDuplicateStrategyChange,
|
||||||
failedCount = 0,
|
failedCount = 0,
|
||||||
|
skippedCount = 0,
|
||||||
|
overwrittenCount = 0,
|
||||||
onDownloadErrorLog,
|
onDownloadErrorLog,
|
||||||
}: DetailImportModalProps) {
|
}: DetailImportModalProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const hasResultSummary = failedCount > 0 || skippedCount > 0 || overwrittenCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
@@ -42,12 +59,34 @@ export function DetailImportModal({
|
|||||||
<span className="vm-step-badge">1</span>
|
<span className="vm-step-badge">1</span>
|
||||||
<span>下载导入模板</span>
|
<span>下载导入模板</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">下载明细导入模板
|
<button type="button" className="vm-btn vm-btn-primary" onClick={onDownloadTemplate} data-vm-icon="download">
|
||||||
|
下载明细导入模板
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<section className="vm-import-step">
|
<section className="vm-import-step">
|
||||||
<div className="vm-import-step-head">
|
<div className="vm-import-step-head">
|
||||||
<span className="vm-step-badge">2</span>
|
<span className="vm-step-badge">2</span>
|
||||||
|
<span>重复记录处理(主键:年份 + 月份 + 车牌)</span>
|
||||||
|
</div>
|
||||||
|
<div className="lbd-import-dup-options" role="radiogroup" aria-label="重复记录处理">
|
||||||
|
{DUPLICATE_OPTIONS.map((option) => (
|
||||||
|
<label key={option.value} className="lbd-import-dup-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="lbd-import-dup"
|
||||||
|
value={option.value}
|
||||||
|
checked={duplicateStrategy === option.value}
|
||||||
|
onChange={() => onDuplicateStrategyChange(option.value)}
|
||||||
|
/>
|
||||||
|
<span className="lbd-import-dup-label">{option.label}</span>
|
||||||
|
<span className="lbd-import-dup-hint">{option.hint}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="vm-import-step">
|
||||||
|
<div className="vm-import-step-head">
|
||||||
|
<span className="vm-step-badge">3</span>
|
||||||
<span>上传填写好的 Excel 文件</span>
|
<span>上传填写好的 Excel 文件</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -70,13 +109,28 @@ export function DetailImportModal({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
{failedCount > 0 && onDownloadErrorLog ? (
|
{hasResultSummary ? (
|
||||||
<section className="vm-import-step lbd-import-error-step">
|
<section className="vm-import-step lbd-import-error-step">
|
||||||
|
{failedCount > 0 ? (
|
||||||
<p className="lbd-import-error-summary">
|
<p className="lbd-import-error-summary">
|
||||||
本次有 <strong>{failedCount}</strong> 条记录导入失败(如车牌号不存在),可下载错误日志核对。
|
本次有 <strong>{failedCount}</strong> 条记录导入失败,可下载错误日志核对。
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={onDownloadErrorLog} data-vm-icon="download">下载错误日志
|
) : null}
|
||||||
|
{skippedCount > 0 ? (
|
||||||
|
<p className="lbd-import-error-summary">
|
||||||
|
已跳过 <strong>{skippedCount}</strong> 条重复记录。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{overwrittenCount > 0 ? (
|
||||||
|
<p className="lbd-import-error-summary">
|
||||||
|
已覆盖 <strong>{overwrittenCount}</strong> 条重复记录。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{failedCount > 0 && onDownloadErrorLog ? (
|
||||||
|
<button type="button" className="vm-btn vm-btn-secondary" onClick={onDownloadErrorLog} data-vm-icon="download">
|
||||||
|
下载错误日志
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<nav
|
||||||
|
className="lbd-main-tabs lbd-main-tabs--toolbar"
|
||||||
|
aria-label="租赁业务明细模块"
|
||||||
|
data-annotation-id="lbd-main-tabs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`lbd-main-tab${mainTab === 'detail' ? ' is-active' : ''}`}
|
||||||
|
aria-current={mainTab === 'detail' ? 'page' : undefined}
|
||||||
|
onClick={() => onChange('detail')}
|
||||||
|
>
|
||||||
|
租赁业务明细
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`lbd-main-tab${mainTab === 'monthly-pl' ? ' is-active' : ''}`}
|
||||||
|
aria-current={mainTab === 'monthly-pl' ? 'page' : undefined}
|
||||||
|
onClick={() => onChange('monthly-pl')}
|
||||||
|
>
|
||||||
|
租赁业务盈亏月度汇总
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
paymentStatusSortValue,
|
paymentStatusSortValue,
|
||||||
resolveDetailPaymentStatus,
|
resolveDetailPaymentStatus,
|
||||||
} from '../utils/payment-status';
|
} from '../utils/payment-status';
|
||||||
import { canEditDetailRow } from '../utils/change-log';
|
|
||||||
import {
|
import {
|
||||||
LedgerDetailPopover,
|
LedgerDetailPopover,
|
||||||
LedgerDetailTable,
|
LedgerDetailTable,
|
||||||
@@ -38,7 +37,6 @@ interface DetailTableProps {
|
|||||||
records: LeaseBusinessDetailRow[];
|
records: LeaseBusinessDetailRow[];
|
||||||
summary: LeaseDetailKpiSummary;
|
summary: LeaseDetailKpiSummary;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
isSupervisor: boolean;
|
|
||||||
onEdit: (row: LeaseBusinessDetailRow) => void;
|
onEdit: (row: LeaseBusinessDetailRow) => void;
|
||||||
onDelete: (row: LeaseBusinessDetailRow) => void;
|
onDelete: (row: LeaseBusinessDetailRow) => void;
|
||||||
onChangeLog: (row: LeaseBusinessDetailRow) => void;
|
onChangeLog: (row: LeaseBusinessDetailRow) => void;
|
||||||
@@ -157,8 +155,11 @@ function renderCellContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (col.key === 'outstanding') {
|
if (col.key === 'outstanding') {
|
||||||
const { text, warn } = displayOutstanding(row.outstanding);
|
const { text, warn, overpaid } = displayOutstanding(row.outstanding);
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
if (overpaid) {
|
||||||
|
return <span className="lbd-outstanding-overpaid tabular-nums" title="实收超过应收合计">{text}</span>;
|
||||||
|
}
|
||||||
return warn
|
return warn
|
||||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||||
: <span className="tabular-nums">{text}</span>;
|
: <span className="tabular-nums">{text}</span>;
|
||||||
@@ -218,7 +219,10 @@ function renderSummaryCell(col: DetailTableColumn, summary: LeaseDetailKpiSummar
|
|||||||
|
|
||||||
const value = summary[col.summaryKey];
|
const value = summary[col.summaryKey];
|
||||||
if (col.summaryKey === 'outstanding') {
|
if (col.summaryKey === 'outstanding') {
|
||||||
const { text, warn } = displayOutstanding(value);
|
const { text, warn, overpaid } = displayOutstanding(value);
|
||||||
|
if (overpaid) {
|
||||||
|
return <span className="lbd-outstanding-overpaid tabular-nums">{text}</span>;
|
||||||
|
}
|
||||||
return warn
|
return warn
|
||||||
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
? <span className="lbd-outstanding-warn tabular-nums">{text}</span>
|
||||||
: <span className="tabular-nums">{text}</span>;
|
: <span className="tabular-nums">{text}</span>;
|
||||||
@@ -249,7 +253,6 @@ export function DetailTable({
|
|||||||
records,
|
records,
|
||||||
summary,
|
summary,
|
||||||
loading = false,
|
loading = false,
|
||||||
isSupervisor,
|
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onChangeLog,
|
onChangeLog,
|
||||||
@@ -386,11 +389,8 @@ export function DetailTable({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedRecords.map((row) => {
|
{sortedRecords.map((row) => (
|
||||||
const canEdit = canEditDetailRow(row, isSupervisor);
|
<tr key={row.id}>
|
||||||
const archived = row.archiveStatus === 'archived';
|
|
||||||
return (
|
|
||||||
<tr key={row.id} className={archived ? 'lbd-row-archived' : undefined}>
|
|
||||||
{DETAIL_TABLE_COLUMNS.map((col) => (
|
{DETAIL_TABLE_COLUMNS.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -406,16 +406,14 @@ export function DetailTable({
|
|||||||
))}
|
))}
|
||||||
<td className="sticky-col-right ldb-col-actions">
|
<td className="sticky-col-right ldb-col-actions">
|
||||||
<OperationActions
|
<OperationActions
|
||||||
edit={canEdit ? { onClick: () => onEdit(row) } : undefined}
|
edit={{ onClick: () => onEdit(row) }}
|
||||||
more={[
|
more={[
|
||||||
...(canEdit
|
{
|
||||||
? [{
|
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: '删除',
|
label: '删除',
|
||||||
danger: true,
|
danger: true,
|
||||||
onClick: () => onDelete(row),
|
onClick: () => onDelete(row),
|
||||||
}]
|
},
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
key: 'changeLog',
|
key: 'changeLog',
|
||||||
label: '变更日志',
|
label: '变更日志',
|
||||||
@@ -425,8 +423,7 @@ export function DetailTable({
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="lbd-summary-row">
|
<tr className="lbd-summary-row">
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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 type { LeaseBusinessDetailRow, LeaseDetailFilters } from '../types';
|
||||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||||
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
|
import { MultiPlateFilterField } from '../../vehicle-management/components/MultiPlateFilterField';
|
||||||
import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail';
|
import { buildDetailMonthOptions, buildDetailOptions } from '../utils/detail';
|
||||||
import { DETAIL_PAYMENT_STATUS_OPTIONS } from '../utils/payment-status';
|
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 {
|
interface FilterFieldDef {
|
||||||
key: FieldKey;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
control: React.ReactNode;
|
control: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -26,7 +25,11 @@ interface FilterPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasExtraFiltersActive(filters: LeaseDetailFilters): boolean {
|
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({
|
export function FilterPanel({
|
||||||
@@ -40,14 +43,14 @@ export function FilterPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExtraFiltersActive(filters)) setExpanded(true);
|
if (hasExtraFiltersActive(filters)) setExpanded(true);
|
||||||
}, [filters.plateNos]);
|
}, [filters.paymentStatus, filters.plateNos]);
|
||||||
|
|
||||||
const monthOptions = useMemo(() => buildDetailMonthOptions(records), [records]);
|
const monthOptions = useMemo(() => buildDetailMonthOptions(records), [records]);
|
||||||
const deptOptions = useMemo(() => buildDetailOptions(records, 'businessDept'), [records]);
|
const deptOptions = useMemo(() => buildDetailOptions(records, 'businessDept'), [records]);
|
||||||
const salespersonOptions = useMemo(() => buildDetailOptions(records, 'salesperson'), [records]);
|
const salespersonOptions = useMemo(() => buildDetailOptions(records, 'salesperson'), [records]);
|
||||||
|
|
||||||
const fieldMap = useMemo<Record<FieldKey, FilterFieldDef>>(() => ({
|
const fields = useMemo<FilterFieldDef[]>(() => [
|
||||||
statMonth: {
|
{
|
||||||
key: 'statMonth',
|
key: 'statMonth',
|
||||||
label: '统计月份',
|
label: '统计月份',
|
||||||
control: (
|
control: (
|
||||||
@@ -60,7 +63,7 @@ export function FilterPanel({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
businessDept: {
|
{
|
||||||
key: 'businessDept',
|
key: 'businessDept',
|
||||||
label: '业务部门',
|
label: '业务部门',
|
||||||
control: (
|
control: (
|
||||||
@@ -73,7 +76,7 @@ export function FilterPanel({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
salesperson: {
|
{
|
||||||
key: 'salesperson',
|
key: 'salesperson',
|
||||||
label: '业务员',
|
label: '业务员',
|
||||||
control: (
|
control: (
|
||||||
@@ -86,12 +89,12 @@ export function FilterPanel({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
customerName: {
|
{
|
||||||
key: 'customerName',
|
key: 'customerName',
|
||||||
label: '客户名称',
|
label: '客户名称',
|
||||||
control: (
|
control: (
|
||||||
<input
|
<input
|
||||||
className="vm-input"
|
className="vm-input lbd-filter-text-control"
|
||||||
value={filters.customerName}
|
value={filters.customerName}
|
||||||
placeholder="输入客户名称(模糊匹配)"
|
placeholder="输入客户名称(模糊匹配)"
|
||||||
aria-label="客户名称"
|
aria-label="客户名称"
|
||||||
@@ -99,7 +102,7 @@ export function FilterPanel({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
paymentStatus: {
|
{
|
||||||
key: 'paymentStatus',
|
key: 'paymentStatus',
|
||||||
label: '状态',
|
label: '状态',
|
||||||
control: (
|
control: (
|
||||||
@@ -112,7 +115,7 @@ export function FilterPanel({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
plateNos: {
|
{
|
||||||
key: 'plateNos',
|
key: 'plateNos',
|
||||||
label: '车牌号码',
|
label: '车牌号码',
|
||||||
control: (
|
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 { primary, extra } = splitFilterFields(fields, VM_FILTER_PRIMARY_VISIBLE_COUNT);
|
||||||
const extraFields = EXTRA_KEYS.map((key) => fieldMap[key]);
|
const showExpand = shouldShowFilterExpand(fields.length);
|
||||||
const extraActiveCount = filters.plateNos.length > 0 ? 1 : 0;
|
const extraActiveCount = countExtraFiltersActive(filters);
|
||||||
|
const expandPanelId = 'lbd-filter-expand-panel';
|
||||||
|
|
||||||
const renderField = (field: FilterFieldDef) => (
|
const renderField = (field: FilterFieldDef) => (
|
||||||
<label key={field.key} className="vm-filter-field">
|
<label key={field.key} className="vm-filter-field">
|
||||||
@@ -149,31 +153,34 @@ export function FilterPanel({
|
|||||||
|
|
||||||
<div className="ldb-filter-body">
|
<div className="ldb-filter-body">
|
||||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
|
<div className="vm-filter-grid ldb-filter-grid ldb-filter-grid--primary" data-filter-tier="primary">
|
||||||
{primaryFields.map(renderField)}
|
{primary.map(renderField)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showExpand ? (
|
||||||
<div
|
<div
|
||||||
className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}
|
className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}
|
||||||
aria-hidden={!expanded}
|
aria-hidden={!expanded}
|
||||||
id="lbd-filter-expand-panel"
|
id={expandPanelId}
|
||||||
>
|
>
|
||||||
<div className="ldb-filter-expand-inner">
|
<div className="ldb-filter-expand-inner">
|
||||||
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
|
<div className="vm-filter-grid ldb-filter-grid ldb-filter-expand-grid" data-filter-tier="extra">
|
||||||
{expanded ? extraFields.map(renderField) : null}
|
{expanded ? extra.map(renderField) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="vm-filter-actions ldb-filter-actions">
|
<div className="vm-filter-actions ldb-filter-actions">
|
||||||
|
{showExpand ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vm-btn vm-btn-link ldb-filter-toggle"
|
className="vm-btn vm-btn-link ldb-filter-toggle"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-controls="lbd-filter-expand-panel"
|
aria-controls={expandPanelId}
|
||||||
|
data-vm-icon={expanded ? 'chevron-up' : 'filter'}
|
||||||
data-vm-icon={expanded ? 'chevron-up' : 'filter'}>
|
>
|
||||||
{expanded ? '收起' : '更多筛选'}
|
{expanded ? '收起' : '更多筛选'}
|
||||||
{extraActiveCount > 0 && !expanded ? (
|
{extraActiveCount > 0 && !expanded ? (
|
||||||
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
|
<span className="ldb-filter-toggle-badge" aria-label="已设置扩展筛选">
|
||||||
@@ -181,10 +188,12 @@ export function FilterPanel({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
|
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset} data-vm-icon="rotate-ccw">
|
||||||
重置
|
重置
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">查询
|
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch} data-vm-icon="search">
|
||||||
|
查询
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
194
src/prototypes/lease-business-detail/components/MonthlyPlTab.tsx
Normal file
194
src/prototypes/lease-business-detail/components/MonthlyPlTab.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import type { LeaseBusinessDetailRow } from '../types';
|
||||||
|
import { formatMoney } from '../utils/detail';
|
||||||
|
import {
|
||||||
|
buildDetailYearOptions,
|
||||||
|
buildMonthlyPlRows,
|
||||||
|
exportMonthlyPlRows,
|
||||||
|
sumMonthlyPlRows,
|
||||||
|
type MonthlyPlRow,
|
||||||
|
} from '../utils/monthly-pl';
|
||||||
|
|
||||||
|
import type { DetailMainTab } from './DetailMainTabs';
|
||||||
|
import { DetailMainTabs } from './DetailMainTabs';
|
||||||
|
|
||||||
|
interface MonthlyPlTabProps {
|
||||||
|
records: LeaseBusinessDetailRow[];
|
||||||
|
onToast: (message: string) => void;
|
||||||
|
mainTab: DetailMainTab;
|
||||||
|
onTabChange: (tab: DetailMainTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayCell(row: MonthlyPlRow, value: number): string {
|
||||||
|
if (!row.hasData && value === 0) return '';
|
||||||
|
return formatMoney(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutstanding(row: MonthlyPlRow): React.ReactNode {
|
||||||
|
if (!row.hasData && row.outstanding === 0) return '';
|
||||||
|
const text = formatMoney(row.outstanding);
|
||||||
|
if (row.outstanding > 0) {
|
||||||
|
return <span className="lbd-outstanding-warn tabular-nums">{text}</span>;
|
||||||
|
}
|
||||||
|
if (row.outstanding < 0) {
|
||||||
|
return <span className="lbd-outstanding-overpaid tabular-nums">{text}</span>;
|
||||||
|
}
|
||||||
|
return <span className="tabular-nums">{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfit(row: MonthlyPlRow): React.ReactNode {
|
||||||
|
if (!row.hasData && row.profit === 0) return '';
|
||||||
|
const text = formatMoney(row.profit);
|
||||||
|
const cls = row.profit >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg';
|
||||||
|
return <span className={`tabular-nums ${cls}`}>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthlyPlTab({ records, onToast, mainTab, onTabChange }: MonthlyPlTabProps) {
|
||||||
|
const yearOptions = useMemo(() => buildDetailYearOptions(records), [records]);
|
||||||
|
const defaultYear = yearOptions[0] ?? new Date().getFullYear();
|
||||||
|
|
||||||
|
const [draftYear, setDraftYear] = useState(defaultYear);
|
||||||
|
const [appliedYear, setAppliedYear] = useState(defaultYear);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => buildMonthlyPlRows(records, appliedYear),
|
||||||
|
[records, appliedYear],
|
||||||
|
);
|
||||||
|
const sums = useMemo(() => sumMonthlyPlRows(rows), [rows]);
|
||||||
|
const title = `租赁业务${appliedYear}年盈亏月度汇总`;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const hasAny = rows.some((row) => row.hasData);
|
||||||
|
if (!hasAny) {
|
||||||
|
onToast('当前年份无明细数据可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exportMonthlyPlRows(rows, appliedYear);
|
||||||
|
onToast(`已导出 ${appliedYear} 年盈亏月度汇总`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="vm-filter-card lbd-filter-card" data-annotation-id="lbd-monthly-pl-filter">
|
||||||
|
<div className="vm-filter-grid lbd-filter-grid lbd-monthly-pl-filter-grid">
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span className="vm-filter-label">选择年份</span>
|
||||||
|
<select
|
||||||
|
className="vm-select"
|
||||||
|
value={String(draftYear)}
|
||||||
|
onChange={(event) => setDraftYear(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
{(yearOptions.length ? yearOptions : [defaultYear]).map((year) => (
|
||||||
|
<option key={year} value={year}>{year} 年</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="vm-filter-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftYear(defaultYear);
|
||||||
|
setAppliedYear(defaultYear);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-primary"
|
||||||
|
onClick={() => setAppliedYear(draftYear)}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="vm-table-section">
|
||||||
|
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="lbd-monthly-pl-toolbar">
|
||||||
|
<DetailMainTabs mainTab={mainTab} onChange={onTabChange} />
|
||||||
|
<div className="vm-table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||||
|
data-vm-icon="download"
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-table-card">
|
||||||
|
<h2 className="lbd-monthly-pl-title">{title}</h2>
|
||||||
|
<div className="vm-table-wrap lbd-monthly-pl-wrap" data-annotation-id="lbd-monthly-pl-table">
|
||||||
|
<table className="vm-table ldb-table lbd-monthly-pl-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>月份</th>
|
||||||
|
<th className="ldb-col-money">押金</th>
|
||||||
|
<th className="ldb-col-money">应收</th>
|
||||||
|
<th className="ldb-col-money">实收</th>
|
||||||
|
<th className="ldb-col-money">未收</th>
|
||||||
|
<th className="ldb-col-money">自然月收入</th>
|
||||||
|
<th className="ldb-col-money">氢费预充值</th>
|
||||||
|
<th className="ldb-col-money">成本</th>
|
||||||
|
<th className="ldb-col-money">居间费</th>
|
||||||
|
<th className="ldb-col-money">氢费</th>
|
||||||
|
<th className="ldb-col-money">总成本</th>
|
||||||
|
<th className="ldb-col-money">盈亏</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.month}>
|
||||||
|
<td className="tabular-nums">{row.month}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.deposit)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.receivable)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.received)}</td>
|
||||||
|
<td className="ldb-col-money">{renderOutstanding(row)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.naturalMonthIncome)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.hydrogenPrepay)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.baseCost)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.brokerage)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.hydrogenFee)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums">{displayCell(row, row.totalCost)}</td>
|
||||||
|
<td className="ldb-col-money">{renderProfit(row)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="lbd-summary-row">
|
||||||
|
<td className="lbd-summary-cell">总计</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.deposit ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.receivable ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.received ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money lbd-summary-cell">
|
||||||
|
{Number(sums.outstanding) > 0 ? (
|
||||||
|
<span className="lbd-outstanding-warn tabular-nums">{formatMoney(sums.outstanding ?? 0)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">{formatMoney(sums.outstanding ?? 0)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.naturalMonthIncome ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.hydrogenPrepay ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.baseCost ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.brokerage ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.hydrogenFee ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money tabular-nums lbd-summary-cell">{formatMoney(sums.totalCost ?? 0)}</td>
|
||||||
|
<td className="ldb-col-money lbd-summary-cell">
|
||||||
|
<span className={`tabular-nums ${(sums.profit ?? 0) >= 0 ? 'lbd-profit-pos' : 'lbd-profit-neg'}`}>
|
||||||
|
{formatMoney(sums.profit ?? 0)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -268,7 +268,7 @@ export const DETAIL_TABLE_COLUMNS: DetailTableColumn[] = [
|
|||||||
{ key: 'remark', title: '备注', className: 'ldb-col-remark', defaultWidth: 140 },
|
{ key: 'remark', title: '备注', className: 'ldb-col-remark', defaultWidth: 140 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Excel 导入/导出列规格(47 列,含独立年份/月份/业务员) */
|
/** Excel 导入/导出列规格(47 列业务字段;导出时在首列追加「状态」共 48 列) */
|
||||||
export const DETAIL_EXPORT_SPECS: { key: keyof LeaseBusinessDetailRow; title: string }[] = [
|
export const DETAIL_EXPORT_SPECS: { key: keyof LeaseBusinessDetailRow; title: string }[] = [
|
||||||
{ key: 'year', title: '年份' },
|
{ key: 'year', title: '年份' },
|
||||||
{ key: 'month', title: '月份' },
|
{ key: 'month', title: '月份' },
|
||||||
|
|||||||
@@ -94,33 +94,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "lbd-clog-seed-33-5",
|
"id": "lbd-clog-seed-33-5",
|
||||||
"operatedAt": "2026-01-18 10:22:36",
|
|
||||||
"operatorId": "staff-002",
|
|
||||||
"operatorName": "尚建华",
|
|
||||||
"field": "_reconcile",
|
|
||||||
"fieldLabel": "完成对账",
|
|
||||||
"before": "未对账",
|
|
||||||
"after": "已对账(2026-01-18)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lbd-clog-seed-33-6",
|
|
||||||
"operatedAt": "2026-01-18 10:22:36",
|
|
||||||
"operatorId": "staff-002",
|
|
||||||
"operatorName": "尚建华",
|
|
||||||
"field": "archiveStatus",
|
|
||||||
"fieldLabel": "对账状态",
|
|
||||||
"before": "未对账",
|
|
||||||
"after": "已对账"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lbd-clog-seed-33-7",
|
|
||||||
"operatedAt": "2026-01-20 14:11:08",
|
"operatedAt": "2026-01-20 14:11:08",
|
||||||
"operatorId": "supervisor-001",
|
"operatorId": "supervisor-001",
|
||||||
"operatorName": "王主管",
|
"operatorName": "王主管",
|
||||||
"field": "remark",
|
"field": "remark",
|
||||||
"fieldLabel": "备注",
|
"fieldLabel": "备注",
|
||||||
"before": "—",
|
"before": "—",
|
||||||
"after": "主管复核:实收与合同一致,归档确认"
|
"after": "主管复核:实收与合同一致"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lbd-280": [
|
"lbd-280": [
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import '../vehicle-management/style.css';
|
|||||||
import '../lease-business-ledger/styles/index.css';
|
import '../lease-business-ledger/styles/index.css';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { Download, FileUp } from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
type AnnotationDirectoryRouteNode,
|
type AnnotationDirectoryRouteNode,
|
||||||
type AnnotationSourceDocument,
|
type AnnotationSourceDocument,
|
||||||
type AnnotationViewerOptions
|
type AnnotationViewerOptions,
|
||||||
} from '@axhub/annotation';
|
} from '@axhub/annotation';
|
||||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||||
import seedRows from './data/rows.json';
|
import seedRows from './data/rows.json';
|
||||||
@@ -43,21 +42,18 @@ import {
|
|||||||
createChangeLogEntry,
|
createChangeLogEntry,
|
||||||
type DetailChangeLogsByRowId,
|
type DetailChangeLogsByRowId,
|
||||||
} from './utils/change-log';
|
} from './utils/change-log';
|
||||||
import { isDetailSupervisor, resolveDetailOperator } from './utils/role';
|
import { resolveDetailOperator } from './utils/role';
|
||||||
|
import {
|
||||||
|
mergeDetailImportRows,
|
||||||
|
type DetailImportDuplicateStrategy,
|
||||||
|
} from './utils/import-merge';
|
||||||
import annotationSourceDocument from './annotation-source.json';
|
import annotationSourceDocument from './annotation-source.json';
|
||||||
|
|
||||||
const ARCHIVED_DEMO_ROW_IDS = new Set(['lbd-33']);
|
|
||||||
|
|
||||||
export default function LeaseBusinessDetailApp() {
|
export default function LeaseBusinessDetailApp() {
|
||||||
const operator = useMemo(() => resolveDetailOperator(), []);
|
const operator = useMemo(() => resolveDetailOperator(), []);
|
||||||
const isSupervisor = isDetailSupervisor(operator);
|
|
||||||
|
|
||||||
const [records, setRecords] = useState<LeaseBusinessDetailRow[]>(() =>
|
const [records, setRecords] = useState<LeaseBusinessDetailRow[]>(() =>
|
||||||
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations({
|
(seedRows as LeaseBusinessDetailRow[]).map((row) => applyDetailCalculations(row)),
|
||||||
...row,
|
|
||||||
archiveStatus: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? 'archived' : (row.archiveStatus ?? 'active'),
|
|
||||||
reconciledAt: ARCHIVED_DEMO_ROW_IDS.has(row.id) ? '2026-01-18' : (row.reconciledAt ?? ''),
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
const [changeLogs, setChangeLogs] = useState<DetailChangeLogsByRowId>(
|
const [changeLogs, setChangeLogs] = useState<DetailChangeLogsByRowId>(
|
||||||
() => ({ ...(changeLogSeed as DetailChangeLogsByRowId) }),
|
() => ({ ...(changeLogSeed as DetailChangeLogsByRowId) }),
|
||||||
@@ -70,6 +66,9 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importErrors, setImportErrors] = useState<DetailImportErrorRow[]>([]);
|
const [importErrors, setImportErrors] = useState<DetailImportErrorRow[]>([]);
|
||||||
const [importErrorHeaders, setImportErrorHeaders] = useState<string[]>([]);
|
const [importErrorHeaders, setImportErrorHeaders] = useState<string[]>([]);
|
||||||
|
const [importSkipped, setImportSkipped] = useState(0);
|
||||||
|
const [importOverwritten, setImportOverwritten] = useState(0);
|
||||||
|
const [duplicateStrategy, setDuplicateStrategy] = useState<DetailImportDuplicateStrategy>('skip');
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
const [editRow, setEditRow] = useState<LeaseBusinessDetailRow | null>(null);
|
const [editRow, setEditRow] = useState<LeaseBusinessDetailRow | null>(null);
|
||||||
const [deleteRow, setDeleteRow] = useState<LeaseBusinessDetailRow | null>(null);
|
const [deleteRow, setDeleteRow] = useState<LeaseBusinessDetailRow | null>(null);
|
||||||
@@ -94,7 +93,7 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
const updateRow = useCallback((
|
const updateRow = useCallback((
|
||||||
id: string,
|
id: string,
|
||||||
patch: Partial<LeaseBusinessDetailRow>,
|
patch: Partial<LeaseBusinessDetailRow>,
|
||||||
logMeta?: { forceField?: string; fieldLabel?: string },
|
logMeta?: { forceField?: string; fieldLabel?: string; beforeText?: string; afterText?: string },
|
||||||
) => {
|
) => {
|
||||||
setRecords((prev) => {
|
setRecords((prev) => {
|
||||||
const before = prev.find((row) => row.id === id);
|
const before = prev.find((row) => row.id === id);
|
||||||
@@ -109,7 +108,12 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
logMeta.forceField,
|
logMeta.forceField,
|
||||||
before[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
before[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
||||||
after[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
after[logMeta.forceField as keyof LeaseBusinessDetailRow],
|
||||||
{ fieldLabel: logMeta.fieldLabel, force: true },
|
{
|
||||||
|
fieldLabel: logMeta.fieldLabel,
|
||||||
|
beforeText: logMeta.beforeText,
|
||||||
|
afterText: logMeta.afterText,
|
||||||
|
force: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (entry) setChangeLogs((prevLogs) => appendChangeLogs(prevLogs, id, [entry]));
|
if (entry) setChangeLogs((prevLogs) => appendChangeLogs(prevLogs, id, [entry]));
|
||||||
}
|
}
|
||||||
@@ -135,28 +139,39 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
const handleImport = async (file: File) => {
|
const handleImport = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
const result = await parseDetailImportFile(file);
|
const result = await parseDetailImportFile(file);
|
||||||
setImportErrors(result.errors);
|
const merged = mergeDetailImportRows(records, result.success, duplicateStrategy);
|
||||||
setImportErrorHeaders(result.fileHeaders);
|
const allErrors = [
|
||||||
|
...result.errors,
|
||||||
|
...merged.duplicateErrors,
|
||||||
|
...merged.amountErrors,
|
||||||
|
];
|
||||||
|
|
||||||
if (result.success.length === 0 && result.errors.length === 0) {
|
setImportErrors(allErrors);
|
||||||
|
setImportErrorHeaders(result.fileHeaders);
|
||||||
|
setImportSkipped(merged.skipped);
|
||||||
|
setImportOverwritten(merged.overwritten);
|
||||||
|
|
||||||
|
const affectedCount = merged.records.length - records.length + merged.overwritten;
|
||||||
|
|
||||||
|
if (affectedCount === 0 && allErrors.length === 0) {
|
||||||
showToast('未解析到有效数据,请检查模板与必填列');
|
showToast('未解析到有效数据,请检查模板与必填列');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success.length > 0) {
|
if (affectedCount > 0) {
|
||||||
const imported = result.success.map((row) => applyDetailCalculations({
|
const previousIds = new Set(records.map((row) => row.id));
|
||||||
...row,
|
const overwrittenIdSet = new Set(merged.overwrittenIds);
|
||||||
archiveStatus: 'active',
|
setRecords(merged.records);
|
||||||
reconciledAt: '',
|
|
||||||
}));
|
|
||||||
setRecords((prev) => [...imported, ...prev]);
|
|
||||||
setChangeLogs((prev) => {
|
setChangeLogs((prev) => {
|
||||||
let next = { ...prev };
|
let next = { ...prev };
|
||||||
imported.forEach((row) => {
|
merged.records.forEach((row) => {
|
||||||
|
const isNew = !previousIds.has(row.id);
|
||||||
|
const isOverwrite = overwrittenIdSet.has(row.id);
|
||||||
|
if (!isNew && !isOverwrite) return;
|
||||||
const entry = createChangeLogEntry(operator, '_import', null, null, {
|
const entry = createChangeLogEntry(operator, '_import', null, null, {
|
||||||
fieldLabel: '批量导入',
|
fieldLabel: '批量导入',
|
||||||
beforeText: '—',
|
beforeText: '—',
|
||||||
afterText: '新增明细',
|
afterText: isOverwrite ? '覆盖导入' : '新增明细',
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
if (entry) next = appendChangeLogs(next, row.id, [entry]);
|
if (entry) next = appendChangeLogs(next, row.id, [entry]);
|
||||||
@@ -166,13 +181,18 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.errors.length > 0 && result.success.length > 0) {
|
if (allErrors.length > 0 && affectedCount > 0) {
|
||||||
showToast(`已导入 ${result.success.length} 条;${result.errors.length} 条失败,可下载错误日志`);
|
const parts = [`已处理 ${affectedCount} 条`];
|
||||||
} else if (result.errors.length > 0) {
|
if (merged.skipped > 0) parts.push(`跳过 ${merged.skipped} 条重复`);
|
||||||
showToast(`${result.errors.length} 条导入失败,请下载错误日志查看原因`);
|
parts.push(`${allErrors.length} 条失败,可下载错误日志`);
|
||||||
|
showToast(parts.join(';'));
|
||||||
|
} else if (allErrors.length > 0) {
|
||||||
|
showToast(`${allErrors.length} 条导入失败,请下载错误日志查看原因`);
|
||||||
} else {
|
} else {
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
showToast(`已导入 ${result.success.length} 条明细,公式列已自动计算`);
|
const parts = [`已导入 ${affectedCount} 条明细`];
|
||||||
|
if (merged.skipped > 0) parts.push(`跳过 ${merged.skipped} 条重复`);
|
||||||
|
showToast(`${parts.join(',')},公式列已自动计算`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showToast('导入失败,请检查文件格式');
|
showToast('导入失败,请检查文件格式');
|
||||||
@@ -214,16 +234,6 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="vm-page ldb-page lc-page lc-page--list-dense lbd-page">
|
<div className="vm-page ldb-page lc-page lc-page--list-dense lbd-page">
|
||||||
<div className="lbd-role-banner" role="status">
|
|
||||||
<span>
|
|
||||||
当前视角:
|
|
||||||
{isSupervisor ? '业务管理部主管(可修改已锁定记录)' : '业管人员'}
|
|
||||||
</span>
|
|
||||||
{!isSupervisor && (
|
|
||||||
<span className="lbd-role-banner-hint">主管预览:URL 追加 ?role=supervisor</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
records={records}
|
records={records}
|
||||||
filters={pendingFilters}
|
filters={pendingFilters}
|
||||||
@@ -244,6 +254,8 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setImportErrors([]);
|
setImportErrors([]);
|
||||||
setImportErrorHeaders([]);
|
setImportErrorHeaders([]);
|
||||||
|
setImportSkipped(0);
|
||||||
|
setImportOverwritten(0);
|
||||||
setImportOpen(true);
|
setImportOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -268,7 +280,6 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
records={paged}
|
records={paged}
|
||||||
summary={kpi}
|
summary={kpi}
|
||||||
loading={tableLoading}
|
loading={tableLoading}
|
||||||
isSupervisor={isSupervisor}
|
|
||||||
onEdit={setEditRow}
|
onEdit={setEditRow}
|
||||||
onDelete={setDeleteRow}
|
onDelete={setDeleteRow}
|
||||||
onChangeLog={setChangeLogRow}
|
onChangeLog={setChangeLogRow}
|
||||||
@@ -297,7 +308,11 @@ export default function LeaseBusinessDetailApp() {
|
|||||||
onClose={() => setImportOpen(false)}
|
onClose={() => setImportOpen(false)}
|
||||||
onDownloadTemplate={downloadDetailImportTemplate}
|
onDownloadTemplate={downloadDetailImportTemplate}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
|
duplicateStrategy={duplicateStrategy}
|
||||||
|
onDuplicateStrategyChange={setDuplicateStrategy}
|
||||||
failedCount={importErrors.length}
|
failedCount={importErrors.length}
|
||||||
|
skippedCount={importSkipped}
|
||||||
|
overwrittenCount={importOverwritten}
|
||||||
onDownloadErrorLog={
|
onDownloadErrorLog={
|
||||||
importErrors.length > 0
|
importErrors.length > 0
|
||||||
? () => downloadDetailImportErrorLog(importErrors, importErrorHeaders)
|
? () => downloadDetailImportErrorLog(importErrors, importErrorHeaders)
|
||||||
|
|||||||
@@ -9,11 +9,106 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const root = join(__dirname, '..');
|
const root = join(__dirname, '..');
|
||||||
const jsonPath = join(root, 'annotation-source.json');
|
const jsonPath = join(root, 'annotation-source.json');
|
||||||
const prdPath = join(root, '.spec/requirements-prd.md');
|
const prdPath = join(root, '.spec/requirements-prd.md');
|
||||||
|
const fieldChecksPath = join(root, '.spec/field-checks.md');
|
||||||
|
|
||||||
const doc = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
const doc = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
||||||
const prd = readFileSync(prdPath, 'utf8');
|
const prd = readFileSync(prdPath, 'utf8');
|
||||||
|
const fieldChecksMd = readFileSync(fieldChecksPath, 'utf8');
|
||||||
|
|
||||||
|
for (const node of doc.data.nodes) {
|
||||||
|
if (node.annotationText) {
|
||||||
|
doc.markdownMap[node.id] = node.annotationText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mm = doc.markdownMap;
|
const mm = doc.markdownMap;
|
||||||
|
|
||||||
|
const filterSectionMatch = prd.match(/## 2\. 筛选区\n+([\s\S]*?)\n+---/);
|
||||||
|
if (filterSectionMatch) {
|
||||||
|
doc.markdownMap['lbd-filter'] = `## 2. 筛选区\n\n${filterSectionMatch[1].trim()}\n\n---`;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.markdownMap['lbd-doc-field-checks'] = fieldChecksMd;
|
||||||
|
|
||||||
|
doc.markdownMap['lbd-col-customer'] = `## 客户名称 · 系统校验
|
||||||
|
|
||||||
|
导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。
|
||||||
|
|
||||||
|
### 判定顺序
|
||||||
|
|
||||||
|
1. 期望客户与当前名完全一致 → ✅ 与系统登记一致
|
||||||
|
2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致
|
||||||
|
3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**
|
||||||
|
4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过
|
||||||
|
5. 否则 → ⚠️ 请核对提车/退车日期
|
||||||
|
|
||||||
|
完整规则见目录「单元格系统校验」或 \`.spec/field-checks.md\`。
|
||||||
|
|
||||||
|
---`;
|
||||||
|
|
||||||
|
const tableSectionMatch = prd.match(/## 5\. 明细列表(48 列)\n+([\s\S]*?)\n+---\n+\n+## 6\./);
|
||||||
|
if (tableSectionMatch) {
|
||||||
|
doc.markdownMap['lbd-table'] = `## 5. 明细列表(48 列)\n\n${tableSectionMatch[1].trim()}\n\n---`;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.markdownMap['lbd-toolbar'] = `## 4. 列表工具栏
|
||||||
|
|
||||||
|
| 按钮 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |
|
||||||
|
| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |
|
||||||
|
|
||||||
|
### 行内操作
|
||||||
|
|
||||||
|
编辑、删除、变更日志。
|
||||||
|
|
||||||
|
### 导入重复策略
|
||||||
|
|
||||||
|
主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。
|
||||||
|
|
||||||
|
---`;
|
||||||
|
|
||||||
|
doc.markdownMap['lbd-doc-formulas'] = `## 6. 公式列(系统自动计算)
|
||||||
|
|
||||||
|
| 字段 | 计算方式 |
|
||||||
|
|---|---|
|
||||||
|
| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||||
|
| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |
|
||||||
|
| **未收** | 应收合计 − 实收金额 |
|
||||||
|
| **车辆实际成本** | 车辆标准成本 × 平均天数 |
|
||||||
|
| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |
|
||||||
|
| **盈亏** | 月度收入 − 总成本 |
|
||||||
|
|
||||||
|
---`;
|
||||||
|
|
||||||
|
doc.markdownMap['lbd-doc-boundary'] = `## 模块边界
|
||||||
|
|
||||||
|
| 模块 | 定位 |
|
||||||
|
|---|---|
|
||||||
|
| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重与实收校验 |
|
||||||
|
| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |
|
||||||
|
| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |
|
||||||
|
|
||||||
|
### 不做范围
|
||||||
|
|
||||||
|
- 本页不做盈亏月度汇总(见业务部台账)
|
||||||
|
- 服务端持久化、员工数据范围隔离
|
||||||
|
- 收款记录自动回写实收
|
||||||
|
|
||||||
|
---`;
|
||||||
|
|
||||||
|
for (const node of doc.data.nodes) {
|
||||||
|
if (node.id === 'lbd-toolbar') {
|
||||||
|
node.annotationText = doc.markdownMap['lbd-toolbar'];
|
||||||
|
}
|
||||||
|
if (node.id === 'lbd-filter') {
|
||||||
|
node.annotationText = doc.markdownMap['lbd-filter'];
|
||||||
|
}
|
||||||
|
if (node.id === 'lbd-table') {
|
||||||
|
node.annotationText = doc.markdownMap['lbd-table'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
doc.markdownMap['lbd-doc-overview'] = `## 1. 模块定位
|
doc.markdownMap['lbd-doc-overview'] = `## 1. 模块定位
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
@@ -21,7 +116,7 @@ doc.markdownMap['lbd-doc-overview'] = `## 1. 模块定位
|
|||||||
| 模块名称 | 台账数据 — 租赁业务明细 |
|
| 模块名称 | 台账数据 — 租赁业务明细 |
|
||||||
| 目标用户 | 业务二部业管 / 运营人员 |
|
| 目标用户 | 业务二部业管 / 运营人员 |
|
||||||
| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |
|
| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |
|
||||||
| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表(表尾 23 项汇总)+ 分页 |
|
| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表 + 分页 |
|
||||||
| 设计基底 | vm-page + ldb-page + lc-page |
|
| 设计基底 | vm-page + ldb-page + lc-page |
|
||||||
|
|
||||||
对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」。
|
对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」。
|
||||||
@@ -90,6 +185,7 @@ doc.directory.nodes = [
|
|||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
children: [
|
children: [
|
||||||
nodeDoc('lbd-col-status', '状态'),
|
nodeDoc('lbd-col-status', '状态'),
|
||||||
|
nodeDoc('lbd-col-customer', '客户名称'),
|
||||||
nodeDoc('lbd-col-receivable-total', '应收合计'),
|
nodeDoc('lbd-col-receivable-total', '应收合计'),
|
||||||
nodeDoc('lbd-col-outstanding', '未收'),
|
nodeDoc('lbd-col-outstanding', '未收'),
|
||||||
nodeDoc('lbd-col-vehicle-actual-cost', '车辆实际成本'),
|
nodeDoc('lbd-col-vehicle-actual-cost', '车辆实际成本'),
|
||||||
@@ -116,6 +212,13 @@ doc.directory.nodes = [
|
|||||||
title: '模块边界',
|
title: '模块边界',
|
||||||
markdown: mm['lbd-doc-boundary'],
|
markdown: mm['lbd-doc-boundary'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'markdown',
|
||||||
|
id: 'lbd-doc-field-checks',
|
||||||
|
title: '单元格系统校验',
|
||||||
|
markdown: mm['lbd-doc-field-checks'],
|
||||||
|
markdownPath: 'src/prototypes/lease-business-detail/.spec/field-checks.md',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'markdown',
|
type: 'markdown',
|
||||||
id: 'lbd-doc-source',
|
id: 'lbd-doc-source',
|
||||||
@@ -130,7 +233,10 @@ doc.directory.nodes = [
|
|||||||
| 筛选 / KPI 汇总 | \`utils/detail.ts\` |
|
| 筛选 / KPI 汇总 | \`utils/detail.ts\` |
|
||||||
| 收款状态 | \`utils/payment-status.ts\` |
|
| 收款状态 | \`utils/payment-status.ts\` |
|
||||||
| 公式计算 | \`utils/calc-detail.ts\` |
|
| 公式计算 | \`utils/calc-detail.ts\` |
|
||||||
| 导入导出 | \`utils/batch-io.ts\` |
|
| 导入导出 | \`utils/batch-io.ts\`、\`utils/import-merge.ts\` |
|
||||||
|
| 单元格校验 | \`utils/field-checks.ts\`、\`utils/system-ref.ts\`、\`components/FieldCheckIcon.tsx\` |
|
||||||
|
| 保存校验 | \`utils/validate-detail.ts\` |
|
||||||
|
| 校验说明 | \`.spec/field-checks.md\` |
|
||||||
| 变更日志 | \`utils/change-log.ts\` |
|
| 变更日志 | \`utils/change-log.ts\` |
|
||||||
| 类型 | \`types.ts\` |
|
| 类型 | \`types.ts\` |
|
||||||
| 组件 | \`FilterPanel.tsx\`、\`DetailKpiRow.tsx\`、\`DetailTable.tsx\`、\`DetailImportModal.tsx\`、\`DetailEditModal.tsx\`、\`DetailChangeLogModal.tsx\` |
|
| 组件 | \`FilterPanel.tsx\`、\`DetailKpiRow.tsx\`、\`DetailTable.tsx\`、\`DetailImportModal.tsx\`、\`DetailEditModal.tsx\`、\`DetailChangeLogModal.tsx\` |
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 筛选区文本输入与 FilterPicker 控件等高 */
|
||||||
|
.lbd-filter-text-control {
|
||||||
|
min-height: var(--vm-control-height);
|
||||||
|
height: var(--vm-control-height);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* —— KPI 汇总卡片(7 项关键指标) —— */
|
/* —— KPI 汇总卡片(7 项关键指标) —— */
|
||||||
.lbd-kpi-row {
|
.lbd-kpi-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -484,14 +493,6 @@
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lbd-row-archived td {
|
|
||||||
background: color-mix(in srgb, var(--ln-muted) 6%, var(--ln-surface-card));
|
|
||||||
}
|
|
||||||
|
|
||||||
.lbd-row-archived:hover td {
|
|
||||||
background: color-mix(in srgb, var(--ln-muted) 10%, var(--ln-surface-card));
|
|
||||||
}
|
|
||||||
|
|
||||||
.lbd-changelog-meta {
|
.lbd-changelog-meta {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
@@ -678,3 +679,114 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lbd-edit-error {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--ln-radius-control);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--ln-danger, #cf1322) 35%, var(--ln-hairline));
|
||||||
|
background: color-mix(in srgb, var(--ln-danger, #cf1322) 8%, var(--ln-surface-card));
|
||||||
|
color: var(--ln-danger, #cf1322);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-outstanding-overpaid {
|
||||||
|
color: #d48806;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-main-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--ln-radius-control);
|
||||||
|
border: 1px solid var(--ln-hairline);
|
||||||
|
background: var(--ln-surface-card);
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-main-tabs--toolbar {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-main-tab {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ln-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: calc(var(--ln-radius-control) - 2px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-main-tab:hover {
|
||||||
|
background: color-mix(in srgb, var(--ln-primary) 8%, transparent);
|
||||||
|
color: var(--ln-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-main-tab.is-active {
|
||||||
|
background: color-mix(in srgb, var(--ln-primary) 12%, var(--ln-surface-card));
|
||||||
|
color: var(--ln-primary);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-monthly-pl-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-monthly-pl-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-monthly-pl-table th,
|
||||||
|
.lbd-monthly-pl-table td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-monthly-pl-filter-grid {
|
||||||
|
grid-template-columns: minmax(180px, 240px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-import-dup-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-import-dup-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 4px 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--ln-hairline);
|
||||||
|
border-radius: var(--ln-radius-control);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-import-dup-option:has(input:checked) {
|
||||||
|
border-color: color-mix(in srgb, var(--ln-primary) 40%, var(--ln-hairline));
|
||||||
|
background: color-mix(in srgb, var(--ln-primary) 6%, var(--ln-surface-card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-import-dup-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ln-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbd-import-dup-hint {
|
||||||
|
grid-column: 2;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export type DetailArchiveStatus = 'active' | 'archived';
|
|
||||||
|
|
||||||
export interface LeaseBusinessDetailRow {
|
export interface LeaseBusinessDetailRow {
|
||||||
id: string;
|
id: string;
|
||||||
year: number;
|
year: number;
|
||||||
@@ -49,10 +47,6 @@ export interface LeaseBusinessDetailRow {
|
|||||||
assetOwner: string;
|
assetOwner: string;
|
||||||
signingCompany: string;
|
signingCompany: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
/** 未对账可编辑;已对账后仅业务管理部主管可改 */
|
|
||||||
archiveStatus?: DetailArchiveStatus;
|
|
||||||
/** 完成对账日期 YYYY-MM-DD */
|
|
||||||
reconciledAt?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaseDetailFilters {
|
export interface LeaseDetailFilters {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { LeaseBusinessDetailRow } from '../types';
|
|||||||
import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableColumns';
|
import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableColumns';
|
||||||
import { applyDetailCalculations } from './calc-detail';
|
import { applyDetailCalculations } from './calc-detail';
|
||||||
import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref';
|
import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref';
|
||||||
|
import { resolveDetailPaymentStatus } from './payment-status';
|
||||||
|
import { validateDetailRowAmounts } from './validate-detail';
|
||||||
|
|
||||||
/** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */
|
/** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */
|
||||||
export const DETAIL_IMPORT_TEMPLATE_HEADERS = [
|
export const DETAIL_IMPORT_TEMPLATE_HEADERS = [
|
||||||
@@ -326,7 +328,14 @@ export function downloadDetailImportErrorLog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
|
export function exportDetailRows(rows: LeaseBusinessDetailRow[]): void {
|
||||||
const body = [DETAIL_EXPORT_HEADERS, ...rows.map(rowToExportCells)];
|
const headers = ['状态', ...DETAIL_EXPORT_HEADERS];
|
||||||
|
const body = [
|
||||||
|
headers,
|
||||||
|
...rows.map((row) => [
|
||||||
|
resolveDetailPaymentStatus(row.receivableTotal, row.receivedAmount),
|
||||||
|
...rowToExportCells(row),
|
||||||
|
]),
|
||||||
|
];
|
||||||
const sheet = XLSX.utils.aoa_to_sheet(body);
|
const sheet = XLSX.utils.aoa_to_sheet(body);
|
||||||
const book = XLSX.utils.book_new();
|
const book = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
|
XLSX.utils.book_append_sheet(book, sheet, '租赁业务明细');
|
||||||
@@ -363,7 +372,14 @@ export async function parseDetailImportFile(file: File): Promise<DetailImportPar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
success.push(applyDetailCalculations(draft));
|
const calculated = applyDetailCalculations(draft);
|
||||||
|
const amountReason = validateDetailRowAmounts(calculated);
|
||||||
|
if (amountReason) {
|
||||||
|
errors.push({ reason: amountReason, rawCells });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
success.push(calculated);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success, errors, fileHeaders };
|
return { success, errors, fileHeaders };
|
||||||
|
|||||||
@@ -248,7 +248,5 @@ export function applyDetailCalculations(
|
|||||||
totalCost,
|
totalCost,
|
||||||
outstanding,
|
outstanding,
|
||||||
profitLoss,
|
profitLoss,
|
||||||
archiveStatus: row.archiveStatus ?? 'active',
|
|
||||||
reconciledAt: row.reconciledAt ?? '',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ export interface DetailChangeLogEntry {
|
|||||||
export type DetailChangeLogsByRowId = Record<string, DetailChangeLogEntry[]>;
|
export type DetailChangeLogsByRowId = Record<string, DetailChangeLogEntry[]>;
|
||||||
|
|
||||||
const EXTRA_FIELD_LABELS: Record<string, string> = {
|
const EXTRA_FIELD_LABELS: Record<string, string> = {
|
||||||
archiveStatus: '对账状态',
|
|
||||||
reconciledAt: '对账日期',
|
|
||||||
_delete: '删除记录',
|
_delete: '删除记录',
|
||||||
_import: '批量导入',
|
_import: '批量导入',
|
||||||
_reconcile: '完成对账',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = Object.fromEntries(
|
const FIELD_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
@@ -40,11 +37,6 @@ const MONEY_FIELDS = new Set<string>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export function formatLogValue(field: string, value: unknown): string {
|
export function formatLogValue(field: string, value: unknown): string {
|
||||||
if (field === 'archiveStatus') {
|
|
||||||
if (value === 'archived') return '已对账';
|
|
||||||
if (value === 'active') return '未对账';
|
|
||||||
return displayText(value);
|
|
||||||
}
|
|
||||||
if (field === 'year' || field === 'month') {
|
if (field === 'year' || field === 'month') {
|
||||||
return value === null || value === undefined || value === '' ? '—' : String(value);
|
return value === null || value === undefined || value === '' ? '—' : String(value);
|
||||||
}
|
}
|
||||||
@@ -62,8 +54,6 @@ export function valuesEqualForLog(field: string, before: unknown, after: unknown
|
|||||||
|
|
||||||
const LOGGED_ROW_FIELDS: (keyof LeaseBusinessDetailRow)[] = [
|
const LOGGED_ROW_FIELDS: (keyof LeaseBusinessDetailRow)[] = [
|
||||||
...DETAIL_EXPORT_SPECS.map((spec) => spec.key),
|
...DETAIL_EXPORT_SPECS.map((spec) => spec.key),
|
||||||
'archiveStatus',
|
|
||||||
'reconciledAt',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createChangeLogEntry(
|
export function createChangeLogEntry(
|
||||||
@@ -116,11 +106,3 @@ export function collectRowChangeLogs(
|
|||||||
});
|
});
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canEditDetailRow(
|
|
||||||
row: Pick<LeaseBusinessDetailRow, 'archiveStatus'>,
|
|
||||||
isSupervisor: boolean,
|
|
||||||
): boolean {
|
|
||||||
if (row.archiveStatus === 'archived') return isSupervisor;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ export function displayDeposit(value: number | string): string {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function displayOutstanding(value: number): { text: string; warn: boolean } {
|
export function displayOutstanding(value: number): { text: string; warn: boolean; overpaid: boolean } {
|
||||||
if (value === 0) return { text: '0.00', warn: false };
|
if (value === 0) return { text: '0.00', warn: false, overpaid: false };
|
||||||
return { text: formatMoney(value), warn: value > 0 };
|
if (value < 0) {
|
||||||
|
return { text: formatMoney(value), warn: true, overpaid: true };
|
||||||
|
}
|
||||||
|
return { text: formatMoney(value), warn: value > 0, overpaid: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyDetailFilters(
|
export function applyDetailFilters(
|
||||||
|
|||||||
103
src/prototypes/lease-business-detail/utils/import-merge.ts
Normal file
103
src/prototypes/lease-business-detail/utils/import-merge.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { LeaseBusinessDetailRow } from '../types';
|
||||||
|
import type { DetailImportErrorRow } from './batch-io';
|
||||||
|
import { detailRowBusinessKey } from './row-key';
|
||||||
|
import { validateDetailRowAmounts } from './validate-detail';
|
||||||
|
|
||||||
|
export type DetailImportDuplicateStrategy = 'skip' | 'overwrite' | 'error';
|
||||||
|
|
||||||
|
export interface DetailImportMergeResult {
|
||||||
|
records: LeaseBusinessDetailRow[];
|
||||||
|
skipped: number;
|
||||||
|
overwritten: number;
|
||||||
|
overwrittenIds: string[];
|
||||||
|
duplicateErrors: DetailImportErrorRow[];
|
||||||
|
amountErrors: DetailImportErrorRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyRawCells(): (string | number)[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDetailImportRows(
|
||||||
|
existing: LeaseBusinessDetailRow[],
|
||||||
|
imported: LeaseBusinessDetailRow[],
|
||||||
|
strategy: DetailImportDuplicateStrategy,
|
||||||
|
): DetailImportMergeResult {
|
||||||
|
const keyToIndex = new Map<string, number>();
|
||||||
|
existing.forEach((row, index) => {
|
||||||
|
keyToIndex.set(detailRowBusinessKey(row), index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = [...existing];
|
||||||
|
const toPrepend: LeaseBusinessDetailRow[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
let overwritten = 0;
|
||||||
|
const overwrittenIds: string[] = [];
|
||||||
|
const duplicateErrors: DetailImportErrorRow[] = [];
|
||||||
|
const amountErrors: DetailImportErrorRow[] = [];
|
||||||
|
|
||||||
|
imported.forEach((row) => {
|
||||||
|
const amountReason = validateDetailRowAmounts(row);
|
||||||
|
if (amountReason) {
|
||||||
|
amountErrors.push({ reason: amountReason, rawCells: emptyRawCells() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = detailRowBusinessKey(row);
|
||||||
|
let existingIndex = keyToIndex.get(key);
|
||||||
|
|
||||||
|
if (existingIndex === undefined) {
|
||||||
|
const prependIndex = toPrepend.length;
|
||||||
|
toPrepend.push(row);
|
||||||
|
keyToIndex.set(key, next.length + prependIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIndex >= next.length) {
|
||||||
|
const prependIndex = existingIndex - next.length;
|
||||||
|
const current = toPrepend[prependIndex];
|
||||||
|
if (strategy === 'skip') {
|
||||||
|
skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (strategy === 'error') {
|
||||||
|
duplicateErrors.push({
|
||||||
|
reason: `重复记录:${row.year}年${row.month}月 · ${row.plateNo} 与本批导入重复`,
|
||||||
|
rawCells: emptyRawCells(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toPrepend[prependIndex] = { ...row, id: current.id };
|
||||||
|
overwritten += 1;
|
||||||
|
overwrittenIds.push(current.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strategy === 'skip') {
|
||||||
|
skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strategy === 'error') {
|
||||||
|
duplicateErrors.push({
|
||||||
|
reason: `重复记录:${row.year}年${row.month}月 · ${row.plateNo} 已存在`,
|
||||||
|
rawCells: emptyRawCells(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preservedId = next[existingIndex].id;
|
||||||
|
next[existingIndex] = { ...row, id: preservedId };
|
||||||
|
overwritten += 1;
|
||||||
|
overwrittenIds.push(preservedId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
records: [...toPrepend, ...next],
|
||||||
|
skipped,
|
||||||
|
overwritten,
|
||||||
|
overwrittenIds,
|
||||||
|
duplicateErrors,
|
||||||
|
amountErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
146
src/prototypes/lease-business-detail/utils/monthly-pl.ts
Normal file
146
src/prototypes/lease-business-detail/utils/monthly-pl.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import type { LeaseBusinessDetailRow } from '../types';
|
||||||
|
import { formatMoney } from './detail';
|
||||||
|
|
||||||
|
export interface MonthlyPlRow {
|
||||||
|
month: number;
|
||||||
|
deposit: number;
|
||||||
|
receivable: number;
|
||||||
|
received: number;
|
||||||
|
outstanding: number;
|
||||||
|
naturalMonthIncome: number;
|
||||||
|
/** 明细表无此字段,汇总占位为 0 */
|
||||||
|
hydrogenPrepay: number;
|
||||||
|
baseCost: number;
|
||||||
|
brokerage: number;
|
||||||
|
hydrogenFee: number;
|
||||||
|
totalCost: number;
|
||||||
|
profit: number;
|
||||||
|
hasData: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MONTHLY_PL_SUM_KEYS: (keyof MonthlyPlRow)[] = [
|
||||||
|
'deposit',
|
||||||
|
'receivable',
|
||||||
|
'received',
|
||||||
|
'outstanding',
|
||||||
|
'naturalMonthIncome',
|
||||||
|
'hydrogenPrepay',
|
||||||
|
'baseCost',
|
||||||
|
'brokerage',
|
||||||
|
'hydrogenFee',
|
||||||
|
'totalCost',
|
||||||
|
'profit',
|
||||||
|
];
|
||||||
|
|
||||||
|
function emptyMonthlyRow(month: number): MonthlyPlRow {
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
deposit: 0,
|
||||||
|
receivable: 0,
|
||||||
|
received: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
naturalMonthIncome: 0,
|
||||||
|
hydrogenPrepay: 0,
|
||||||
|
baseCost: 0,
|
||||||
|
brokerage: 0,
|
||||||
|
hydrogenFee: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
profit: 0,
|
||||||
|
hasData: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按年份聚合明细行为 1–12 月盈亏汇总 */
|
||||||
|
export function buildMonthlyPlRows(
|
||||||
|
records: LeaseBusinessDetailRow[],
|
||||||
|
year: number,
|
||||||
|
): MonthlyPlRow[] {
|
||||||
|
const buckets = new Map<number, MonthlyPlRow>();
|
||||||
|
for (let month = 1; month <= 12; month += 1) {
|
||||||
|
buckets.set(month, emptyMonthlyRow(month));
|
||||||
|
}
|
||||||
|
|
||||||
|
records.forEach((row) => {
|
||||||
|
if (row.year !== year) return;
|
||||||
|
const bucket = buckets.get(row.month);
|
||||||
|
if (!bucket) return;
|
||||||
|
|
||||||
|
bucket.hasData = true;
|
||||||
|
bucket.deposit += typeof row.deposit === 'number' ? row.deposit : 0;
|
||||||
|
bucket.receivable += row.receivableTotal;
|
||||||
|
bucket.received += row.receivedAmount;
|
||||||
|
bucket.outstanding += row.outstanding;
|
||||||
|
bucket.naturalMonthIncome += row.monthlyIncome;
|
||||||
|
bucket.baseCost += row.vehicleActualCost;
|
||||||
|
bucket.brokerage += row.brokerageFee;
|
||||||
|
bucket.hydrogenFee += row.hydrogenFee;
|
||||||
|
bucket.totalCost += row.totalCost;
|
||||||
|
bucket.profit += row.profitLoss;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(buckets.values()).sort((a, b) => a.month - b.month);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumMonthlyPlRows(rows: MonthlyPlRow[]): Record<string, number> {
|
||||||
|
const sums: Record<string, number> = {};
|
||||||
|
MONTHLY_PL_SUM_KEYS.forEach((key) => {
|
||||||
|
sums[key] = rows.reduce((acc, row) => acc + (row[key] as number), 0);
|
||||||
|
});
|
||||||
|
return sums;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPlMoney(value: number, hasData: boolean): string {
|
||||||
|
if (!hasData && value === 0) return '';
|
||||||
|
return formatMoney(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportMonthlyPlRows(rows: MonthlyPlRow[], year: number): void {
|
||||||
|
const headers = [
|
||||||
|
'月份',
|
||||||
|
'押金',
|
||||||
|
'应收',
|
||||||
|
'实收',
|
||||||
|
'未收',
|
||||||
|
'自然月收入',
|
||||||
|
'氢费预充值',
|
||||||
|
'成本',
|
||||||
|
'居间费',
|
||||||
|
'氢费',
|
||||||
|
'总成本',
|
||||||
|
'盈亏',
|
||||||
|
];
|
||||||
|
const body = rows.map((row) => [
|
||||||
|
String(row.month),
|
||||||
|
displayPlMoney(row.deposit, row.hasData),
|
||||||
|
displayPlMoney(row.receivable, row.hasData),
|
||||||
|
displayPlMoney(row.received, row.hasData),
|
||||||
|
displayPlMoney(row.outstanding, row.hasData),
|
||||||
|
displayPlMoney(row.naturalMonthIncome, row.hasData),
|
||||||
|
displayPlMoney(row.hydrogenPrepay, row.hasData),
|
||||||
|
displayPlMoney(row.baseCost, row.hasData),
|
||||||
|
displayPlMoney(row.brokerage, row.hasData),
|
||||||
|
displayPlMoney(row.hydrogenFee, row.hasData),
|
||||||
|
displayPlMoney(row.totalCost, row.hasData),
|
||||||
|
displayPlMoney(row.profit, row.hasData),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sums = sumMonthlyPlRows(rows);
|
||||||
|
const totalRow = [
|
||||||
|
'总计',
|
||||||
|
...MONTHLY_PL_SUM_KEYS.map((key) => formatMoney(sums[key] ?? 0)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const sheet = XLSX.utils.aoa_to_sheet([headers, ...body, totalRow]);
|
||||||
|
const book = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(book, sheet, '盈亏月度汇总');
|
||||||
|
XLSX.writeFile(book, `租赁业务盈亏月度汇总_${year}_${Date.now()}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDetailYearOptions(records: LeaseBusinessDetailRow[]): number[] {
|
||||||
|
const years = new Set<number>();
|
||||||
|
records.forEach((row) => {
|
||||||
|
if (row.year) years.add(row.year);
|
||||||
|
});
|
||||||
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
|
}
|
||||||
8
src/prototypes/lease-business-detail/utils/row-key.ts
Normal file
8
src/prototypes/lease-business-detail/utils/row-key.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LeaseBusinessDetailRow } from '../types';
|
||||||
|
|
||||||
|
/** 业务主键:年份 + 月份 + 车牌号码 */
|
||||||
|
export function detailRowBusinessKey(
|
||||||
|
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo'>,
|
||||||
|
): string {
|
||||||
|
return `${row.year}-${String(row.month).padStart(2, '0')}-${row.plateNo.trim()}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { LeaseBusinessDetailRow } from '../types';
|
||||||
|
import { applyDetailCalculations } from './calc-detail';
|
||||||
|
|
||||||
|
const MONEY_EPS = 0.005;
|
||||||
|
|
||||||
|
/** 实收不得超过应收合计(允许分位误差) */
|
||||||
|
export function validateReceivedAgainstReceivable(
|
||||||
|
receivableTotal: number,
|
||||||
|
receivedAmount: number,
|
||||||
|
): string | null {
|
||||||
|
if (receivedAmount > receivableTotal + MONEY_EPS) {
|
||||||
|
return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)})`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDetailRowAmounts(row: LeaseBusinessDetailRow): string | null {
|
||||||
|
return validateReceivedAgainstReceivable(row.receivableTotal, row.receivedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑保存前:合并 patch 并重算后校验 */
|
||||||
|
export function validateDetailPatch(
|
||||||
|
before: LeaseBusinessDetailRow,
|
||||||
|
patch: Partial<LeaseBusinessDetailRow>,
|
||||||
|
): string | null {
|
||||||
|
const next = applyDetailCalculations({ ...before, ...patch });
|
||||||
|
return validateDetailRowAmounts(next);
|
||||||
|
}
|
||||||
@@ -283,8 +283,8 @@ export const ENERGY_MODULES: LineModule[] = [
|
|||||||
],
|
],
|
||||||
closure:
|
closure:
|
||||||
'签约站氢费由站点主动上报或 PLC 自动采集,预约订单在线闭环,减少能源部手工补录、提升数据时效性。',
|
'签约站氢费由站点主动上报或 PLC 自动采集,预约订单在线闭环,减少能源部手工补录、提升数据时效性。',
|
||||||
prototypeHref: '/prototypes/oneos-web-h2-station',
|
prototypeHref: '/prototypes/oneos-h5-h2-order',
|
||||||
prototypeLabel: '打开加氢订单',
|
prototypeLabel: '打开加氢订单(H5)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vehicle-h2-fee',
|
id: 'vehicle-h2-fee',
|
||||||
@@ -805,7 +805,7 @@ export const SELF_OPERATED_MODULES: LineModule[] = [
|
|||||||
],
|
],
|
||||||
closure: '项目收入、司机与车辆成本在同一台账内可核对,盈亏结果可对接财务实收。',
|
closure: '项目收入、司机与车辆成本在同一台账内可核对,盈亏结果可对接财务实收。',
|
||||||
prototypeHref: '/prototypes/self-operated-business-ledger',
|
prototypeHref: '/prototypes/self-operated-business-ledger',
|
||||||
prototypeLabel: '打开自营业务台账',
|
prototypeLabel: '打开物流业务明细',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ vm-page ldb-page
|
|||||||
## 筛选区
|
## 筛选区
|
||||||
|
|
||||||
- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区(与全局 `vm-filter-expand` / `src/common/vm-filter-panel.ts` 规则一致)
|
- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区(与全局 `vm-filter-expand` / `src/common/vm-filter-panel.ts` 规则一致)
|
||||||
|
- 筛选项顺序按业务优先级排列后,**必须**经 `splitFilterFields()` 拆分;禁止手写 `PRIMARY_KEYS` 超过 4 项导致首行溢出
|
||||||
|
- **禁止末行仅 1 项**:展开区项数为 4n+1 时,从首行借调 1 项到展开区(见 `vm-filter-panel.ts` 内 `rebalanceFilterSplit`)
|
||||||
- 日期区间使用 `DateRangeFilterField`(见 `vm-shared/DESIGN.md`),展示分隔符为「**至**」
|
- 日期区间使用 `DateRangeFilterField`(见 `vm-shared/DESIGN.md`),展示分隔符为「**至**」
|
||||||
- 重置 / 查询按钮加 `ldb-toolbar-btn`
|
- 重置 / 查询按钮加 `ldb-toolbar-btn`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 加氢订单(H5)Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Implement task-by-task. Checkbox tracking. Do **not** git commit unless user asks.
|
||||||
|
|
||||||
|
**Goal:** 交付手机端「加氢订单」原型,站端可查看本站记录、OCR 新增,并对账单联动已对账状态。
|
||||||
|
|
||||||
|
**Architecture:** React 单页原型 + 小羚羚 token 的 390 手机壳;数据读写 `h2VehicleLedgerBridge` 共享 Store;列表 / 分步新增 / 只读详情三视图本地状态切换。
|
||||||
|
|
||||||
|
**Tech Stack:** React + TypeScript、本地 CSS、`@axhub/annotation`、现有 Bridge Store
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Bridge 写入 API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/common/h2VehicleLedgerBridge.js`
|
||||||
|
|
||||||
|
- [x] 增加 `upsertRow(row)`、`removeRow(id)`,挂到 `H2VehicleLedgerBridge` 与 Store
|
||||||
|
- [x] 新增行默认 `reconcileStatus: pending`;通知 subscribe
|
||||||
|
|
||||||
|
### Task 2: 原型骨架与类型
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/prototypes/oneos-h5-h2-order/index.tsx`
|
||||||
|
- Create: `src/prototypes/oneos-h5-h2-order/index.html`(若扫描需要)
|
||||||
|
- Create: `src/prototypes/oneos-h5-h2-order/types.ts`
|
||||||
|
- Create: `src/prototypes/oneos-h5-h2-order/styles/index.css`
|
||||||
|
- Create: `src/prototypes/oneos-h5-h2-order/utils/orders.ts`
|
||||||
|
- Create: `src/prototypes/oneos-h5-h2-order/utils/ocr-mock.ts`
|
||||||
|
- Create: `src/prototypes/oneos-h5-h2-order/utils/format.ts`
|
||||||
|
|
||||||
|
### Task 3: 列表 / 新增向导 / 详情组件
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `components/PhoneShell.tsx`
|
||||||
|
- Create: `components/OrderList.tsx`
|
||||||
|
- Create: `components/OrderCard.tsx`
|
||||||
|
- Create: `components/CreateWizard.tsx`
|
||||||
|
- Create: `components/OrderDetail.tsx`
|
||||||
|
- Create: `components/StationSwitcher.tsx`
|
||||||
|
|
||||||
|
### Task 4: 文档、菜单、标注、导航同步
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.spec/requirements-prd.md`、`.spec/reconcile-linkage.md`
|
||||||
|
- Create: `annotation-source.json`
|
||||||
|
- Modify: `.axhub/make/sidebar-tree.json`(加氢站管理下「加氢订单」)
|
||||||
|
- Run: `npm run project-nav:sync`、`npm run nav:sync -- --prototype oneos-h5-h2-order --note "..."`
|
||||||
|
|
||||||
|
### Task 5: 验收
|
||||||
|
|
||||||
|
- [ ] `/prototypes/oneos-h5-h2-order` 列表仅本站、FAB 新增 OCR 模拟、已对账只读
|
||||||
|
- [ ] 站点信息对账后本页状态同步(同会话)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 加氢订单(H5)— 已确认需求与设计决策
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 日期 | 2026-07-14 |
|
||||||
|
| 原型 ID | `oneos-h5-h2-order` |
|
||||||
|
| 菜单位置 | OneOS → 加氢站管理 → 加氢订单 |
|
||||||
|
| 文档性质 | 决策快照(对齐当时确认,不随实现逐行同步) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 已确认产品需求
|
||||||
|
|
||||||
|
| 问题 | 用户选择 |
|
||||||
|
|---|---|
|
||||||
|
| 列表数据范围 | **A. 仅本站**(当前演示站点绑定的加氢站) |
|
||||||
|
| 登录态 | **A. 已登录态**;顶栏展示站名,演示可切换示例站 |
|
||||||
|
| 已对账记录 | **A. 不可改**(只读);未对账可编辑 / 删除 |
|
||||||
|
| OCR 演示 | **A. 拍照或选图 → 约 1s 模拟识别 → 可校正**(已改为单表单,非三步) |
|
||||||
|
|
||||||
|
### 1.1 目标与范围
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 目标用户 | 加氢站操作人员(采购建站、绑供应商、开账号后使用) |
|
||||||
|
| 核心任务 | 手机浏览器查看 / 上报本站加氢订单,供能源部对账 |
|
||||||
|
| 访问形态 | 手机浏览器 H5(桌面预览用 390 宽手机框) |
|
||||||
|
| 与 Web 关系 | Web 站端/运营仍用「加氢记录」;本页为站端手机入口 |
|
||||||
|
|
||||||
|
**功能清单**
|
||||||
|
|
||||||
|
1. 本站订单列表(KPI:订单数 / 加氢量 / 加氢金额)
|
||||||
|
2. 卡片字段:加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间
|
||||||
|
3. 新增:分步 OCR(车牌 → 加氢机面板)→ 核对提交;加氢时间默认当前;站名只读
|
||||||
|
4. 未对账可编辑 / 删除;已对账只读
|
||||||
|
5. 与「站点信息 → 对账单」共享氢费 Store:对账后本页状态变为「已对账」,对账时间为 `YYYY-MM-DD HH:mm`
|
||||||
|
|
||||||
|
**不做**:真登录鉴权、真 OCR 模型、PLC、原生 App、审批流、后端联调
|
||||||
|
|
||||||
|
### 1.2 验收重点
|
||||||
|
|
||||||
|
1. 手机宽度下列表 + 分步新增顺畅(含两次 OCR 模拟)
|
||||||
|
2. 仅展示当前演示站点数据;切换站点列表随之变化
|
||||||
|
3. 已对账不可编辑 / 删除
|
||||||
|
4. 在站点信息完成对账后,本页对应记录状态与对账时间同步(同会话共享 Store)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已确认设计方案
|
||||||
|
|
||||||
|
| 问题 | 用户选择 |
|
||||||
|
|---|---|
|
||||||
|
| 设计基底 | **A. 小羚羚** `make-project-20260629/.../xll-miniapp/DESIGN.md` |
|
||||||
|
| 布局方案 | 列表 + FAB 新增;新增为单表单页(含 OCR);返回时非空二次确认 |
|
||||||
|
|
||||||
|
### 2.1 设计决策
|
||||||
|
|
||||||
|
| 决策 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 主色 | `#7AB929` / `#6AA322`(小羚羚绿);与 OneOS Web `#32a06e` 区分,视为「站端 H5」 |
|
||||||
|
| 预览壳 | 居中 390×844 手机框;真机浏览器可全宽 |
|
||||||
|
| 首屏 | 列表为主 + 悬浮「+」 |
|
||||||
|
| 卡片优先级 | 车牌、状态、时间、量与金额;单价 / 对账时间次级 |
|
||||||
|
| 无底 Tab | 本模块单入口,不套小羚羚全局 Tab |
|
||||||
|
| 演示切换 | 顶栏站点下拉,仅演示,不代表正式鉴权 |
|
||||||
|
|
||||||
|
### 2.2 信息架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
加氢订单(H5)
|
||||||
|
├── 列表
|
||||||
|
│ ├── 顶栏:标题 + 当前站(演示切换)
|
||||||
|
│ ├── 摘要条:未对账 n · 已对账 m
|
||||||
|
│ ├── 卡片列表(时间倒序)
|
||||||
|
│ └── FAB 新增
|
||||||
|
├── 新增 / 编辑(未对账)— 单表单
|
||||||
|
│ ├── 加氢时间(默认当前)+ 站名只读
|
||||||
|
│ ├── 拍车牌 OCR → 可校正
|
||||||
|
│ ├── 拍面板 OCR → 单价 / 量 / 总额可校正
|
||||||
|
│ └── 提交;返回时若有未提交内容二次确认
|
||||||
|
└── 详情(已对账,只读)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据与对账联动(实现约定)
|
||||||
|
|
||||||
|
| 项 | 约定 |
|
||||||
|
|---|---|
|
||||||
|
| 数据源 | `src/common/h2VehicleLedgerBridge.js` 共享 Store(与站点信息、车辆氢费明细同源种子) |
|
||||||
|
| 过滤 | `stationName` / `stationId` === 当前演示站 |
|
||||||
|
| 列表单价 / 总额 | 站端口径:`costUnitPrice`、`costTotal`(加氢机成本侧);`hydrogenKg` |
|
||||||
|
| 对账状态 | `reconcileStatus === 'reconciled'` → 已对账;否则未对账 |
|
||||||
|
| 对账时间 | 优先 `reconcileDate`,格式展示 `YYYY-MM-DD HH:mm` |
|
||||||
|
| 写入 | 扩展 Bridge:`upsertRow` / `removeRow`(或等价),保证站点对账单可拉到站端新增的未对账记录 |
|
||||||
|
| 新增默认 | `reconcileStatus: pending`;站名 / stationId 取当前站;客户等字段用演示占位以满足 Store 结构 |
|
||||||
|
| 原型边界 | 本地种子 + 内存 Store,**未接真实 API** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实施边界(确认后执行)
|
||||||
|
|
||||||
|
**做**
|
||||||
|
|
||||||
|
- 新建原型 `oneos-h5-h2-order`(列表 / 分步新增 / 只读详情)
|
||||||
|
- 挂菜单、同步导航注册表
|
||||||
|
- Bridge 写入能力与对账订阅展示
|
||||||
|
- `.spec/requirements-prd.md` + 对账联动业务说明 + annotation
|
||||||
|
|
||||||
|
**不做**
|
||||||
|
|
||||||
|
- 改造桌面「加氢记录」为主流程(保持并列)
|
||||||
|
- 真相机 OCR SDK、账号体系
|
||||||
51
src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md
Normal file
51
src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 加氢订单(H5)· 对账联动规则
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 代码路径 | `src/common/h2VehicleLedgerBridge.js`;对账单 UI:`oneos-web-h2-station/pages/03-站点信息.jsx`;PC 加氢记录:`oneos-web-h2-station/pages/02-加氢记录.jsx` |
|
||||||
|
| 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) |
|
||||||
|
| 双端一致 | H5「加氢订单」与 Web「加氢记录」读写同一 Store、同一站端口径字段;Web 另有导出 / 预约加氢 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 判定优先级
|
||||||
|
|
||||||
|
| 优先级 | 条件 | 站端 H5 展示 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `reconcileStatus === 'reconciled'` 或已写 `reconcileDate`(对账单提交回写) | **已对账**;对账时间取 `reconcileDate` / `reconciledAt`,格式 `YYYY-MM-DD HH:mm` |
|
||||||
|
| 2 | 否则 | **未对账**;对账时间显示「—」 |
|
||||||
|
|
||||||
|
## 2. 前置条件
|
||||||
|
|
||||||
|
| 场景 | 是否校验 / 联动 |
|
||||||
|
|---|---|
|
||||||
|
| 未打开站点信息、未提交对账单 | 种子里已对账记录仍显示已对账;H5 新增默认为未对账 |
|
||||||
|
| 记录已绑定 `statementRecordId` | 不再进入下一张对账单 |
|
||||||
|
| 切换演示站点 | 仅过滤 `stationName`,不对其他站回写 |
|
||||||
|
|
||||||
|
## 3. 数据流
|
||||||
|
|
||||||
|
```text
|
||||||
|
站端 H5 加氢订单 / Web 加氢记录 新增/编辑
|
||||||
|
→ Bridge.upsertRow(reconcileStatus=pending,成本侧单价/量/总额)
|
||||||
|
→ 共享 Store 通知订阅方(双端列表同步)
|
||||||
|
|
||||||
|
站点信息 · 生成对账单
|
||||||
|
→ 筛选:本站 ∧ 无 statementRecordId ∧ (pending|reconciled) ∧ 账期内
|
||||||
|
→ 提交:写 statementRecordId、reconcileStatus=reconciled、reconcileDate
|
||||||
|
→ applyPatches → H5 / Web 同步「已对账」+ 对账时间
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 用户可见结果
|
||||||
|
|
||||||
|
| 结果 | 文案 / 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 未对账 | 标签「未对账」;可编辑、删除 |
|
||||||
|
| 已对账 | 标签「已对账」;详情只读,提示「已纳入站点对账单,不可修改」 |
|
||||||
|
| 提交对账单成功后 | 同步出现对账时间(操作完成时间) |
|
||||||
|
|
||||||
|
## 5. 边界
|
||||||
|
|
||||||
|
- 站端口径单价/总额映射 `costUnitPrice` / `costTotal`(加氢机成本侧)
|
||||||
|
- OCR 为模拟识别,不解析真实图片数字
|
||||||
|
- 跨页联动依赖同浏览器会话内未刷新导致的 Store 重建场景有限:刷新后以种子 + `H2_STATION_STATEMENT_LEDGER_UPDATES` 队列重建
|
||||||
57
src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md
Normal file
57
src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 加氢订单(H5)— 产品需求文档
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 文档版本 | v1.0 |
|
||||||
|
| 模块名称 | 加氢站管理 → 加氢订单 |
|
||||||
|
| 所属系统 | ONE-OS(加氢站手机浏览器) |
|
||||||
|
| 交互原型 | `/prototypes/oneos-h5-h2-order` |
|
||||||
|
| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |
|
||||||
|
| 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) |
|
||||||
|
| 决策快照 | [2026-07-14-requirements-design.md](./2026-07-14-requirements-design.md) |
|
||||||
|
| 对账规则全文 | [reconcile-linkage.md](./reconcile-linkage.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(数据结构一致;PC 另有导出与预约加氢)。
|
||||||
|
|
||||||
|
### 1.1 目标用户
|
||||||
|
|
||||||
|
加氢站操作人员(户外 / 站场手机使用)。
|
||||||
|
|
||||||
|
### 1.2 功能清单
|
||||||
|
|
||||||
|
| # | 功能 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 本站列表 | 仅当前站;KPI:订单数 / 加氢量 / 加氢金额;字段见下表 |
|
||||||
|
| 2 | OCR 新增 | 单表单:拍车牌 / 拍面板 OCR 可校正后提交;返回时非空二次确认 |
|
||||||
|
| 3 | 未对账编辑/删除 | 已对账只读 |
|
||||||
|
| 4 | 对账单联动 | 共享氢费 Store;对账后状态与时间回写 |
|
||||||
|
|
||||||
|
### 1.3 列表字段
|
||||||
|
|
||||||
|
加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间(`YYYY-MM-DD HH:mm`)。
|
||||||
|
|
||||||
|
### 1.4 不做
|
||||||
|
|
||||||
|
真登录、真 OCR SDK、PLC、原生 App、后端联调。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 验收项
|
||||||
|
|
||||||
|
1. 菜单位于 OneOS → 加氢站管理 → 加氢订单
|
||||||
|
2. 默认已登录;可切换演示站点;列表仅本站
|
||||||
|
3. FAB 进入单表单新增;OCR 可校正;时间默认当前;站名只读;有内容返回二次确认
|
||||||
|
4. 已对账不可编辑/删除;未对账可
|
||||||
|
5. 站点信息提交对账单后,本页对应记录显示「已对账」+ 对账时间(同会话 Store)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 复杂逻辑摘要
|
||||||
|
|
||||||
|
| 主题 | 摘要 | 全文 |
|
||||||
|
|---|---|---|
|
||||||
|
| 对账联动 | 站端上报写入共享 Store(与 Web 加氢记录同源);对账单纳入 pending/已对账未结算记录;提交后标记已对账并写对账时间 | [reconcile-linkage.md](./reconcile-linkage.md) |
|
||||||
93
src/prototypes/oneos-h5-h2-order/annotation-source.json
Normal file
93
src/prototypes/oneos-h5-h2-order/annotation-source.json
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"documentVersion": 1,
|
||||||
|
"format": "axhub-annotation-source",
|
||||||
|
"data": {
|
||||||
|
"version": 1,
|
||||||
|
"prototypeName": "oneos-h5-h2-order",
|
||||||
|
"pageId": "h5-h2-order",
|
||||||
|
"updatedAt": 1784010000000,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "h5-station",
|
||||||
|
"index": 1,
|
||||||
|
"title": "当前加氢站(演示)",
|
||||||
|
"pageId": "h5-h2-order",
|
||||||
|
"locator": {
|
||||||
|
"selectors": ["#h5-station-switch", "[data-ax=\"h5-h2-order-root\"]"],
|
||||||
|
"fingerprint": "station|h5-h2-order",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"annotationText": "已登录态演示:可切换示例站点;列表仅展示本站加氢记录。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#7AB929",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784010000000,
|
||||||
|
"updatedAt": 1784010000000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h5-fab",
|
||||||
|
"index": 2,
|
||||||
|
"title": "新增加氢记录",
|
||||||
|
"pageId": "h5-h2-order",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".h5-fab"],
|
||||||
|
"fingerprint": "fab|h5-h2-order",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"annotationText": "单表单新增:加氢时间默认当前;拍车牌/面板 OCR 可校正;有内容点返回会二次确认防丢失。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#7AB929",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784010000000,
|
||||||
|
"updatedAt": 1784010000000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h5-card",
|
||||||
|
"index": 3,
|
||||||
|
"title": "订单卡片",
|
||||||
|
"pageId": "h5-h2-order",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".h5-card"],
|
||||||
|
"fingerprint": "card|h5-h2-order",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"annotationText": "展示时间、车牌、单价、量、总额、对账状态/时间;已对账只读,未对账可编辑删除。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#FF7D00",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784010000000,
|
||||||
|
"updatedAt": 1784010000000,
|
||||||
|
"controls": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"directory": [
|
||||||
|
{
|
||||||
|
"id": "h5-prd-root",
|
||||||
|
"title": "产品需求",
|
||||||
|
"type": "folder",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "h5-prd-main",
|
||||||
|
"title": "加氢订单(H5)PRD",
|
||||||
|
"type": "markdown",
|
||||||
|
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h5-prd-reconcile",
|
||||||
|
"title": "对账联动规则",
|
||||||
|
"type": "markdown",
|
||||||
|
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h5-prd-decision",
|
||||||
|
"title": "需求与设计决策",
|
||||||
|
"type": "markdown",
|
||||||
|
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/2026-07-14-requirements-design.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx
Normal file
311
src/prototypes/oneos-h5-h2-order/components/CreateWizard.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import type { CreateDraft } from '../types';
|
||||||
|
import { delay, mockPanelOcr, mockPlateOcr, readFileAsDataUrl } from '../utils/ocr-mock';
|
||||||
|
import {
|
||||||
|
IconCamera,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronLeft,
|
||||||
|
IconClock,
|
||||||
|
IconFuel,
|
||||||
|
IconSpinner,
|
||||||
|
} from './Icons';
|
||||||
|
import { PhoneShell } from './PhoneShell';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
draft: CreateDraft;
|
||||||
|
initialDraft: CreateDraft;
|
||||||
|
stationName: string;
|
||||||
|
onChangeDraft: (patch: Partial<CreateDraft>) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
toast?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function draftFingerprint(draft: CreateDraft): string {
|
||||||
|
return [
|
||||||
|
draft.hydrogenTime.trim(),
|
||||||
|
draft.plateNo.trim(),
|
||||||
|
draft.unitPrice.trim(),
|
||||||
|
draft.hydrogenKg.trim(),
|
||||||
|
draft.totalAmount.trim(),
|
||||||
|
draft.platePreviewUrl ? '1' : '0',
|
||||||
|
draft.panelPreviewUrl ? '1' : '0',
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDraftDirty(
|
||||||
|
draft: CreateDraft,
|
||||||
|
initialDraft: CreateDraft,
|
||||||
|
mode: 'create' | 'edit',
|
||||||
|
): boolean {
|
||||||
|
if (mode === 'edit') {
|
||||||
|
return draftFingerprint(draft) !== draftFingerprint(initialDraft);
|
||||||
|
}
|
||||||
|
return Boolean(
|
||||||
|
draft.plateNo.trim() ||
|
||||||
|
draft.unitPrice.trim() ||
|
||||||
|
draft.hydrogenKg.trim() ||
|
||||||
|
draft.totalAmount.trim() ||
|
||||||
|
draft.platePreviewUrl ||
|
||||||
|
draft.panelPreviewUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateWizard({
|
||||||
|
mode,
|
||||||
|
draft,
|
||||||
|
initialDraft,
|
||||||
|
stationName,
|
||||||
|
onChangeDraft,
|
||||||
|
onBack,
|
||||||
|
onSubmit,
|
||||||
|
toast,
|
||||||
|
}: Props) {
|
||||||
|
const plateInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const panelInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handlePlateFile(file: File | null) {
|
||||||
|
if (!file) return;
|
||||||
|
setRecognizing('plate');
|
||||||
|
try {
|
||||||
|
const url = await readFileAsDataUrl(file);
|
||||||
|
await delay(900);
|
||||||
|
const ocr = mockPlateOcr();
|
||||||
|
onChangeDraft({ platePreviewUrl: url, plateNo: ocr.plateNo });
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(10);
|
||||||
|
} finally {
|
||||||
|
setRecognizing(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePanelFile(file: File | null) {
|
||||||
|
if (!file) return;
|
||||||
|
setRecognizing('panel');
|
||||||
|
try {
|
||||||
|
const url = await readFileAsDataUrl(file);
|
||||||
|
await delay(900);
|
||||||
|
const ocr = mockPanelOcr();
|
||||||
|
onChangeDraft({
|
||||||
|
panelPreviewUrl: url,
|
||||||
|
unitPrice: String(ocr.unitPrice),
|
||||||
|
hydrogenKg: String(ocr.hydrogenKg),
|
||||||
|
totalAmount: String(ocr.totalAmount),
|
||||||
|
});
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(10);
|
||||||
|
} finally {
|
||||||
|
setRecognizing(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBack() {
|
||||||
|
if (isDraftDirty(draft, initialDraft, mode)) {
|
||||||
|
const ok = window.confirm('当前内容尚未提交,返回将丢失已填写信息。确认返回?');
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitClick() {
|
||||||
|
if (submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
onSubmit();
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => setSubmitting(false), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
Boolean(draft.plateNo.trim()) &&
|
||||||
|
Boolean(draft.unitPrice) &&
|
||||||
|
Boolean(draft.hydrogenKg) &&
|
||||||
|
Boolean(draft.totalAmount) &&
|
||||||
|
Boolean(draft.hydrogenTime.trim()) &&
|
||||||
|
!recognizing &&
|
||||||
|
!submitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhoneShell
|
||||||
|
title={mode === 'edit' ? '编辑加氢记录' : '新增加氢记录'}
|
||||||
|
left={
|
||||||
|
<button type="button" className="h5-nav-btn" onClick={requestBack} aria-label="返回">
|
||||||
|
<IconChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
bodyClassName="h5-body--wizard"
|
||||||
|
footer={
|
||||||
|
<div className="h5-action-bar">
|
||||||
|
<button type="button" className="h5-btn" onClick={requestBack} disabled={submitting}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h5-btn h5-btn--primary"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => void handleSubmitClick()}
|
||||||
|
>
|
||||||
|
{submitting ? <IconSpinner size={18} /> : <IconCheck size={18} />}
|
||||||
|
{submitting ? '提交中…' : '提交'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section className="h5-panel">
|
||||||
|
<div className="h5-panel-head">
|
||||||
|
<div className="h5-panel-badge" aria-hidden>
|
||||||
|
<IconClock size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>基础信息</h3>
|
||||||
|
<p>加氢时间默认当前,站点由登录账号带入。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h5-field">
|
||||||
|
<label htmlFor="h5-time">加氢时间</label>
|
||||||
|
<input
|
||||||
|
id="h5-time"
|
||||||
|
value={draft.hydrogenTime}
|
||||||
|
onChange={(e) => onChangeDraft({ hydrogenTime: e.target.value })}
|
||||||
|
placeholder="YYYY-MM-DD HH:mm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h5-field">
|
||||||
|
<label htmlFor="h5-station">加氢站名称</label>
|
||||||
|
<input id="h5-station" value={stationName} disabled />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="h5-panel">
|
||||||
|
<div className="h5-panel-head">
|
||||||
|
<div className="h5-panel-badge" aria-hidden>
|
||||||
|
<IconCamera size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>车牌识别</h3>
|
||||||
|
<p>拍照或相册选择,OCR 结果可校正。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`h5-shot h5-shot--compact${draft.platePreviewUrl ? ' h5-shot--filled' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => plateInputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') plateInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{draft.platePreviewUrl ? <img src={draft.platePreviewUrl} alt="车牌照片预览" /> : null}
|
||||||
|
<div className="h5-shot-content">
|
||||||
|
{recognizing === 'plate' ? <IconSpinner size={18} /> : <IconCamera size={18} />}
|
||||||
|
<span>
|
||||||
|
{recognizing === 'plate'
|
||||||
|
? '识别中…'
|
||||||
|
: draft.platePreviewUrl
|
||||||
|
? '重新拍摄 / 选图'
|
||||||
|
: '拍摄车牌'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={plateInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={(e) => {
|
||||||
|
void handlePlateFile(e.target.files?.[0] || null);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h5-field" style={{ marginTop: 12 }}>
|
||||||
|
<label htmlFor="h5-plate">车牌号(可校正)</label>
|
||||||
|
<input
|
||||||
|
id="h5-plate"
|
||||||
|
value={draft.plateNo}
|
||||||
|
onChange={(e) => onChangeDraft({ plateNo: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="如 浙A88H201"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="h5-panel">
|
||||||
|
<div className="h5-panel-head">
|
||||||
|
<div className="h5-panel-badge" aria-hidden>
|
||||||
|
<IconFuel size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>加氢机面板识别</h3>
|
||||||
|
<p>识别单价、加氢量与总额,结果可校正。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`h5-shot h5-shot--compact${draft.panelPreviewUrl ? ' h5-shot--filled' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => panelInputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') panelInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{draft.panelPreviewUrl ? <img src={draft.panelPreviewUrl} alt="加氢机面板预览" /> : null}
|
||||||
|
<div className="h5-shot-content">
|
||||||
|
{recognizing === 'panel' ? <IconSpinner size={18} /> : <IconCamera size={18} />}
|
||||||
|
<span>
|
||||||
|
{recognizing === 'panel'
|
||||||
|
? '识别中…'
|
||||||
|
: draft.panelPreviewUrl
|
||||||
|
? '重新拍摄 / 选图'
|
||||||
|
: '拍摄面板'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={panelInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={(e) => {
|
||||||
|
void handlePanelFile(e.target.files?.[0] || null);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h5-field" style={{ marginTop: 12 }}>
|
||||||
|
<label htmlFor="h5-price">氢气单价(元/kg)</label>
|
||||||
|
<input
|
||||||
|
id="h5-price"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft.unitPrice}
|
||||||
|
onChange={(e) => onChangeDraft({ unitPrice: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h5-field">
|
||||||
|
<label htmlFor="h5-kg">加氢量(kg)</label>
|
||||||
|
<input
|
||||||
|
id="h5-kg"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft.hydrogenKg}
|
||||||
|
onChange={(e) => onChangeDraft({ hydrogenKg: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h5-field">
|
||||||
|
<label htmlFor="h5-total">加氢总额(元)</label>
|
||||||
|
<input
|
||||||
|
id="h5-total"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft.totalAmount}
|
||||||
|
onChange={(e) => onChangeDraft({ totalAmount: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{toast ? (
|
||||||
|
<div className="h5-toast" role="status" aria-live="polite">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</PhoneShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/prototypes/oneos-h5-h2-order/components/Icons.tsx
Normal file
145
src/prototypes/oneos-h5-h2-order/components/Icons.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Svg({
|
||||||
|
size = 20,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: IconProps & { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChevronLeft(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPlus(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCamera(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconFuel(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M3 22h12M5 22V10l7-4v16M19 22V8l-4-2v16" />
|
||||||
|
<circle cx="8.5" cy="17.5" r="1.5" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconList(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconDroplet(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWallet(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M20 7H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
||||||
|
<path d="M16 14h.01" />
|
||||||
|
<path d="M22 10V7a2 2 0 0 0-2-2H8" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconMapPin(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCheck(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M20 6L9 17l-5-5" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconClock(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconInbox(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg size={props.size || 40} className={props.className}>
|
||||||
|
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||||
|
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSpinner({ size = 16, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`h5-spinner ${className || ''}`.trim()}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 0 0-9-9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx
Normal file
48
src/prototypes/oneos-h5-h2-order/components/OrderCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { H5OrderRecord } from '../types';
|
||||||
|
import { formatDateTimeMinute, formatKg, formatMoney, formatPlateDisplay } from '../utils/format';
|
||||||
|
import { IconCheck, IconClock } from './Icons';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
order: H5OrderRecord;
|
||||||
|
onClick: () => void;
|
||||||
|
index?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrderCard({ order, onClick, index = 0 }: Props) {
|
||||||
|
const done = order.reconcileStatus === 'reconciled';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`h5-card${done ? ' h5-card--done' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
|
||||||
|
>
|
||||||
|
<div className="h5-card-top">
|
||||||
|
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
|
||||||
|
<span className={`h5-tag ${done ? 'h5-tag--done' : 'h5-tag--pending'}`}>
|
||||||
|
{done ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||||
|
{done ? '已对账' : '未对账'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h5-card-meta">
|
||||||
|
<IconClock size={13} />
|
||||||
|
{formatDateTimeMinute(order.hydrogenTime)}
|
||||||
|
</div>
|
||||||
|
<div className="h5-card-metrics">
|
||||||
|
<div>
|
||||||
|
<strong>{formatKg(order.hydrogenKg)}</strong>
|
||||||
|
<span>加氢量 kg</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>¥{formatMoney(order.totalAmount)}</strong>
|
||||||
|
<span>加氢总额</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h5-card-foot">
|
||||||
|
<span>单价 {formatMoney(order.unitPrice)} 元/kg</span>
|
||||||
|
<span>{done ? `对账 ${formatDateTimeMinute(order.reconcileTime)}` : order.stationName}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx
Normal file
88
src/prototypes/oneos-h5-h2-order/components/OrderDetail.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { H5OrderRecord } from '../types';
|
||||||
|
import { formatDateTimeMinute, formatKg, formatMoney, formatPlateDisplay } from '../utils/format';
|
||||||
|
import { IconCheck, IconChevronLeft, IconClock } from './Icons';
|
||||||
|
import { PhoneShell } from './PhoneShell';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
order: H5OrderRecord;
|
||||||
|
onBack: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
|
||||||
|
const done = order.reconcileStatus === 'reconciled';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhoneShell
|
||||||
|
title="加氢记录详情"
|
||||||
|
left={
|
||||||
|
<button type="button" className="h5-nav-btn" onClick={onBack} aria-label="返回">
|
||||||
|
<IconChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
bodyClassName={done ? undefined : 'h5-body--wizard'}
|
||||||
|
footer={
|
||||||
|
done ? null : (
|
||||||
|
<div className="h5-action-bar">
|
||||||
|
<button type="button" className="h5-btn h5-btn--danger" onClick={onDelete}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<button type="button" className="h5-btn h5-btn--primary" onClick={onEdit}>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section className="h5-panel">
|
||||||
|
<div className="h5-detail-hero">
|
||||||
|
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
|
||||||
|
<span className={`h5-tag ${done ? 'h5-tag--done' : 'h5-tag--pending'}`}>
|
||||||
|
{done ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||||
|
{done ? '已对账' : '未对账'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>加氢时间</span>
|
||||||
|
<strong>{formatDateTimeMinute(order.hydrogenTime)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>加氢站名称</span>
|
||||||
|
<strong>{order.stationName}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>车牌号</span>
|
||||||
|
<strong>{formatPlateDisplay(order.plateNo)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>氢气单价</span>
|
||||||
|
<strong>{formatMoney(order.unitPrice)} 元/kg</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>加氢量</span>
|
||||||
|
<strong>{formatKg(order.hydrogenKg)} kg</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>加氢总额</span>
|
||||||
|
<strong>¥{formatMoney(order.totalAmount)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>对账状态</span>
|
||||||
|
<strong>{done ? '已对账' : '未对账'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h5-detail-row">
|
||||||
|
<span>对账时间</span>
|
||||||
|
<strong>{done ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{done ? (
|
||||||
|
<p className="h5-lock-note">
|
||||||
|
<IconCheck size={16} />
|
||||||
|
该记录已纳入站点对账单,不可修改
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</PhoneShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/prototypes/oneos-h5-h2-order/components/PhoneShell.tsx
Normal file
31
src/prototypes/oneos-h5-h2-order/components/PhoneShell.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
left?: React.ReactNode;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
bodyClassName?: string;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PhoneShell({ title, left, right, children, bodyClassName, footer }: Props) {
|
||||||
|
const now = new Date();
|
||||||
|
const timeLabel = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h5-phone">
|
||||||
|
<div className="h5-status-bar" aria-hidden>
|
||||||
|
<span>{timeLabel}</span>
|
||||||
|
<span>5G · 86%</span>
|
||||||
|
</div>
|
||||||
|
<header className="h5-nav">
|
||||||
|
<div className="h5-nav-side">{left || null}</div>
|
||||||
|
<h1 className="h5-nav-title">{title}</h1>
|
||||||
|
<div className="h5-nav-side h5-nav-side--end">{right || null}</div>
|
||||||
|
</header>
|
||||||
|
<main className={`h5-body${bodyClassName ? ` ${bodyClassName}` : ''}`}>{children}</main>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
src/prototypes/oneos-h5-h2-order/index.tsx
Normal file
308
src/prototypes/oneos-h5-h2-order/index.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* @name 加氢订单
|
||||||
|
* 加氢站手机浏览器 H5:本站加氢记录列表、OCR 新增、与站点对账单联动
|
||||||
|
*/
|
||||||
|
import '../../common/h2VehicleLedgerBridge.js';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
type AnnotationSourceDocument,
|
||||||
|
type AnnotationViewerOptions,
|
||||||
|
} from '@axhub/annotation';
|
||||||
|
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||||
|
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||||
|
import annotationSourceDocument from './annotation-source.json';
|
||||||
|
import { CreateWizard } from './components/CreateWizard';
|
||||||
|
import {
|
||||||
|
IconChevronLeft,
|
||||||
|
IconDroplet,
|
||||||
|
IconInbox,
|
||||||
|
IconList,
|
||||||
|
IconMapPin,
|
||||||
|
IconPlus,
|
||||||
|
IconWallet,
|
||||||
|
} from './components/Icons';
|
||||||
|
import { OrderCard } from './components/OrderCard';
|
||||||
|
import { OrderDetail } from './components/OrderDetail';
|
||||||
|
import { PhoneShell } from './components/PhoneShell';
|
||||||
|
import type { AppView, CreateDraft, StationOption } from './types';
|
||||||
|
import { formatDateTimeMinute, formatKg, formatMoney, nowDateTimeMinute } from './utils/format';
|
||||||
|
import {
|
||||||
|
listOrdersForStation,
|
||||||
|
listStations,
|
||||||
|
removeStationOrder,
|
||||||
|
subscribeOrders,
|
||||||
|
summarizeOrders,
|
||||||
|
upsertStationOrder,
|
||||||
|
} from './utils/orders';
|
||||||
|
|
||||||
|
function emptyDraft(partial?: Partial<CreateDraft>): CreateDraft {
|
||||||
|
return {
|
||||||
|
hydrogenTime: nowDateTimeMinute(),
|
||||||
|
plateNo: '',
|
||||||
|
unitPrice: '',
|
||||||
|
hydrogenKg: '',
|
||||||
|
totalAmount: '',
|
||||||
|
platePreviewUrl: '',
|
||||||
|
panelPreviewUrl: '',
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OneosH5H2Order() {
|
||||||
|
useEffect(() => {
|
||||||
|
clearHostPrototypeRouteInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stations = useMemo(() => listStations(), []);
|
||||||
|
const [station, setStation] = useState<StationOption>(
|
||||||
|
() => stations[0] || { stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)' },
|
||||||
|
);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
const [view, setView] = useState<AppView>({ name: 'list' });
|
||||||
|
const [toast, setToast] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeOrders(() => setTick((n) => n + 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const orders = useMemo(
|
||||||
|
() => listOrdersForStation(station.stationName),
|
||||||
|
[station.stationName, tick],
|
||||||
|
);
|
||||||
|
const summary = useMemo(() => summarizeOrders(orders), [orders]);
|
||||||
|
|
||||||
|
function showToast(msg: string) {
|
||||||
|
setToast(msg);
|
||||||
|
window.setTimeout(() => setToast(''), 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
const draft = emptyDraft();
|
||||||
|
setView({
|
||||||
|
name: 'create',
|
||||||
|
mode: 'create',
|
||||||
|
draft,
|
||||||
|
initialDraft: { ...draft },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(id: string) {
|
||||||
|
const order = orders.find((item) => item.id === id);
|
||||||
|
if (!order || order.reconcileStatus === 'reconciled') return;
|
||||||
|
const draft = emptyDraft({
|
||||||
|
id: order.id,
|
||||||
|
hydrogenTime:
|
||||||
|
formatDateTimeMinute(order.hydrogenTime) === '—'
|
||||||
|
? nowDateTimeMinute()
|
||||||
|
: order.hydrogenTime.slice(0, 16),
|
||||||
|
plateNo: order.plateNo.replace(/F$/u, ''),
|
||||||
|
unitPrice: String(order.unitPrice),
|
||||||
|
hydrogenKg: String(order.hydrogenKg),
|
||||||
|
totalAmount: String(order.totalAmount),
|
||||||
|
});
|
||||||
|
setView({
|
||||||
|
name: 'create',
|
||||||
|
mode: 'edit',
|
||||||
|
draft,
|
||||||
|
initialDraft: { ...draft },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (view.name !== 'create') return;
|
||||||
|
const { draft, mode } = view;
|
||||||
|
const unitPrice = Number(draft.unitPrice);
|
||||||
|
const hydrogenKg = Number(draft.hydrogenKg);
|
||||||
|
const totalAmount = Number(draft.totalAmount);
|
||||||
|
if (!draft.plateNo.trim() || !Number.isFinite(unitPrice) || !Number.isFinite(hydrogenKg) || !Number.isFinite(totalAmount)) {
|
||||||
|
showToast('请完善加氢信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = upsertStationOrder({
|
||||||
|
id: draft.id,
|
||||||
|
stationId: station.stationId,
|
||||||
|
stationName: station.stationName,
|
||||||
|
hydrogenTime: draft.hydrogenTime,
|
||||||
|
plateNo: draft.plateNo.trim(),
|
||||||
|
unitPrice,
|
||||||
|
hydrogenKg,
|
||||||
|
totalAmount,
|
||||||
|
});
|
||||||
|
if (!saved) {
|
||||||
|
showToast('保存失败,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTick((n) => n + 1);
|
||||||
|
setView({ name: 'list' });
|
||||||
|
showToast(mode === 'edit' ? '已更新加氢记录' : '已新增加氢记录');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
const order = orders.find((item) => item.id === id);
|
||||||
|
if (!order || order.reconcileStatus === 'reconciled') return;
|
||||||
|
if (!window.confirm(`确认删除车牌 ${order.plateNo.replace(/F$/u, '')} 的未对账记录?`)) return;
|
||||||
|
removeStationOrder(id);
|
||||||
|
setTick((n) => n + 1);
|
||||||
|
setView({ name: 'list' });
|
||||||
|
showToast('已删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||||
|
() => ({
|
||||||
|
showToolbar: true,
|
||||||
|
showThemeToggle: true,
|
||||||
|
showColorFilter: true,
|
||||||
|
emptyWhenNoData: false,
|
||||||
|
toolbarEdge: 'right',
|
||||||
|
currentPageId: 'h5-h2-order',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
let content: React.ReactNode = null;
|
||||||
|
|
||||||
|
if (view.name === 'create') {
|
||||||
|
content = (
|
||||||
|
<CreateWizard
|
||||||
|
mode={view.mode}
|
||||||
|
draft={view.draft}
|
||||||
|
initialDraft={view.initialDraft}
|
||||||
|
stationName={station.stationName}
|
||||||
|
onChangeDraft={(patch) =>
|
||||||
|
setView((prev) =>
|
||||||
|
prev.name === 'create' ? { ...prev, draft: { ...prev.draft, ...patch } } : prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onBack={() => setView({ name: 'list' })}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
toast={toast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (view.name === 'detail') {
|
||||||
|
const order = orders.find((item) => item.id === view.id);
|
||||||
|
content = order ? (
|
||||||
|
<OrderDetail
|
||||||
|
order={order}
|
||||||
|
onBack={() => setView({ name: 'list' })}
|
||||||
|
onEdit={() => openEdit(order.id)}
|
||||||
|
onDelete={() => handleDelete(order.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PhoneShell
|
||||||
|
title="加氢记录详情"
|
||||||
|
left={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h5-nav-btn"
|
||||||
|
onClick={() => setView({ name: 'list' })}
|
||||||
|
aria-label="返回"
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h5-empty">
|
||||||
|
<div className="h5-empty-icon" aria-hidden>
|
||||||
|
<IconInbox size={36} />
|
||||||
|
</div>
|
||||||
|
<strong>记录不存在</strong>
|
||||||
|
可能已切换站点,请返回列表查看
|
||||||
|
</div>
|
||||||
|
</PhoneShell>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<PhoneShell title="加氢订单">
|
||||||
|
<div className="h5-hero">
|
||||||
|
<div className="h5-hero-label">
|
||||||
|
<IconMapPin size={14} />
|
||||||
|
当前加氢站(演示可切换)
|
||||||
|
</div>
|
||||||
|
<label htmlFor="h5-station-switch" className="sr-only">
|
||||||
|
当前加氢站
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="h5-station-switch"
|
||||||
|
className="h5-station-select"
|
||||||
|
value={station.stationId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = stations.find((s) => s.stationId === e.target.value);
|
||||||
|
if (next) setStation(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stations.map((s) => (
|
||||||
|
<option key={s.stationId} value={s.stationId}>
|
||||||
|
{s.stationName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h5-summary" aria-label="本站汇总">
|
||||||
|
<div className="h5-summary-chip">
|
||||||
|
<div className="h5-summary-chip-icon" aria-hidden>
|
||||||
|
<IconList size={15} />
|
||||||
|
</div>
|
||||||
|
<strong>{summary.count}</strong>
|
||||||
|
<span>订单数</span>
|
||||||
|
</div>
|
||||||
|
<div className="h5-summary-chip">
|
||||||
|
<div className="h5-summary-chip-icon" aria-hidden>
|
||||||
|
<IconDroplet size={15} />
|
||||||
|
</div>
|
||||||
|
<strong>{formatKg(summary.totalKg)}</strong>
|
||||||
|
<span>加氢量 kg</span>
|
||||||
|
</div>
|
||||||
|
<div className="h5-summary-chip">
|
||||||
|
<div className="h5-summary-chip-icon" aria-hidden>
|
||||||
|
<IconWallet size={15} />
|
||||||
|
</div>
|
||||||
|
<strong>{formatMoney(summary.totalAmount)}</strong>
|
||||||
|
<span>加氢金额</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className="h5-empty">
|
||||||
|
<div className="h5-empty-icon" aria-hidden>
|
||||||
|
<IconInbox size={36} />
|
||||||
|
</div>
|
||||||
|
<strong>本站暂无加氢记录</strong>
|
||||||
|
点击下方「新增」开始上报第一笔订单
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
orders.map((order, index) => (
|
||||||
|
<OrderCard
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
index={index}
|
||||||
|
onClick={() => setView({ name: 'detail', id: order.id })}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="h5-fab" onClick={openCreate} aria-label="新增加氢记录">
|
||||||
|
<IconPlus size={22} />
|
||||||
|
<span className="h5-fab-label">新增</span>
|
||||||
|
</button>
|
||||||
|
{toast ? (
|
||||||
|
<div className="h5-toast" role="status" aria-live="polite">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</PhoneShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h5-h2-order-page" data-ax="h5-h2-order-root">
|
||||||
|
{content}
|
||||||
|
<PrototypeAnnotationHost
|
||||||
|
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||||
|
options={annotationOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
876
src/prototypes/oneos-h5-h2-order/styles/index.css
Normal file
876
src/prototypes/oneos-h5-h2-order/styles/index.css
Normal file
@@ -0,0 +1,876 @@
|
|||||||
|
.h5-h2-order-page {
|
||||||
|
--xll-green: #7ab929;
|
||||||
|
--xll-green-deep: #6aa322;
|
||||||
|
--xll-green-soft: rgba(122, 185, 41, 0.14);
|
||||||
|
--xll-green-glow: rgba(122, 185, 41, 0.35);
|
||||||
|
--color-text: #1d2129;
|
||||||
|
--color-text-sec: #4e5969;
|
||||||
|
--color-muted: #86909c;
|
||||||
|
--color-line: #e5e6eb;
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-page: #f2f3f5;
|
||||||
|
--color-danger: #f53f3f;
|
||||||
|
--color-warn: #ff7d00;
|
||||||
|
--color-success: #00b42a;
|
||||||
|
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--shadow-card: 0 4px 16px -6px rgba(15, 23, 42, 0.08);
|
||||||
|
--shadow-float: 0 12px 28px -10px rgba(15, 23, 42, 0.22);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 20% 0%, rgba(122, 185, 41, 0.12), transparent 55%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 90% 10%, rgba(0, 180, 42, 0.06), transparent 50%),
|
||||||
|
linear-gradient(165deg, #e8ecf0 0%, #f2f3f5 45%, #dde3ea 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', STHeiti, sans-serif;
|
||||||
|
color: var(--color-text);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-phone {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 390px;
|
||||||
|
height: min(844px, calc(100vh - 48px));
|
||||||
|
background: var(--color-page);
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(208, 211, 217, 0.9);
|
||||||
|
box-shadow: var(--shadow-float), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-status-bar {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: linear-gradient(180deg, #fff 0%, #fafbfc 100%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--color-line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 8px;
|
||||||
|
transition: background 160ms var(--ease-out), transform 160ms var(--ease-out), color 160ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav-btn:hover {
|
||||||
|
background: var(--xll-green-soft);
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
background: var(--xll-green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--xll-green);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav-side {
|
||||||
|
min-width: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-nav-side--end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 12px 14px calc(88px + env(safe-area-inset-bottom, 0px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-body--wizard {
|
||||||
|
padding-bottom: calc(100px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 站点头 —— */
|
||||||
|
.h5-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(122, 185, 41, 0.18) 0%, rgba(122, 185, 41, 0.06) 48%, #fff 100%);
|
||||||
|
border: 1px solid rgba(122, 185, 41, 0.22);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-hero::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -20px;
|
||||||
|
top: -24px;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(122, 185, 41, 0.28), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-hero-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-station-select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(122, 185, 41, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-station-select:hover,
|
||||||
|
.h5-station-select:focus {
|
||||||
|
border-color: var(--xll-green);
|
||||||
|
box-shadow: 0 0 0 3px var(--xll-green-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— KPI —— */
|
||||||
|
.h5-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-summary-chip {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 8px 10px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: transform 180ms var(--ease-out), box-shadow 180ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-summary-chip:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-summary-chip-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
background: var(--xll-green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-summary-chip:nth-child(2) .h5-summary-chip-icon {
|
||||||
|
color: #0e8a7b;
|
||||||
|
background: rgba(14, 138, 123, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-summary-chip:nth-child(3) .h5-summary-chip-icon {
|
||||||
|
color: #c2410c;
|
||||||
|
background: rgba(255, 125, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-summary-chip strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-summary-chip span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 卡片 —— */
|
||||||
|
.h5-card {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
transition:
|
||||||
|
transform 180ms var(--ease-out),
|
||||||
|
box-shadow 180ms var(--ease-out),
|
||||||
|
border-color 180ms var(--ease-out);
|
||||||
|
animation: h5CardIn 280ms var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.h5-card:nth-child(2) { animation-delay: 40ms; }
|
||||||
|
.h5-card:nth-child(3) { animation-delay: 80ms; }
|
||||||
|
.h5-card:nth-child(4) { animation-delay: 120ms; }
|
||||||
|
.h5-card:nth-child(5) { animation-delay: 160ms; }
|
||||||
|
|
||||||
|
@keyframes h5CardIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: linear-gradient(180deg, var(--xll-green), #9fd14a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card--done::before {
|
||||||
|
background: linear-gradient(180deg, #c9cdd4, #a9aeb8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card:hover {
|
||||||
|
border-color: rgba(122, 185, 41, 0.35);
|
||||||
|
box-shadow: 0 8px 22px -10px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card:focus-visible {
|
||||||
|
outline: 2px solid var(--xll-green);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-plate {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-tag--pending {
|
||||||
|
background: rgba(255, 125, 0, 0.12);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-tag--done {
|
||||||
|
background: rgba(0, 180, 42, 0.12);
|
||||||
|
color: #0f9d3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-sec);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-metrics strong {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-metrics span {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-metrics > div:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-metrics > div:last-child strong {
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-card-foot {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 空态 —— */
|
||||||
|
.h5-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 56px 20px 40px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-empty-icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
background: var(--xll-green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-empty strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— FAB —— */
|
||||||
|
.h5-fab {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
|
||||||
|
min-width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(145deg, #8bc73a 0%, var(--xll-green-deep) 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 24px var(--xll-green-glow), 0 2px 0 rgba(255, 255, 255, 0.25) inset;
|
||||||
|
z-index: 5;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: transform 180ms var(--ease-out), box-shadow 180ms var(--ease-out), filter 180ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fab:hover {
|
||||||
|
filter: brightness(1.03);
|
||||||
|
box-shadow: 0 14px 28px var(--xll-green-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fab:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fab:focus-visible {
|
||||||
|
outline: 2px solid var(--xll-green-deep);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fab-label {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 表单面板 —— */
|
||||||
|
.h5-panel {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
animation: h5CardIn 260ms var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-panel:nth-of-type(1) { animation-delay: 0ms; }
|
||||||
|
.h5-panel:nth-of-type(2) { animation-delay: 50ms; }
|
||||||
|
.h5-panel:nth-of-type(3) { animation-delay: 100ms; }
|
||||||
|
|
||||||
|
.h5-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-panel-badge {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--xll-green-soft);
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-panel h3 {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-panel p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-sec);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot {
|
||||||
|
border: 1.5px dashed rgba(122, 185, 41, 0.45);
|
||||||
|
border-radius: 14px;
|
||||||
|
min-height: 120px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(122, 185, 41, 0.06) 0%, #f7f8fa 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: border-color 160ms var(--ease-out), transform 160ms var(--ease-out), background 160ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot:hover {
|
||||||
|
border-color: var(--xll-green);
|
||||||
|
background: rgba(122, 185, 41, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot:focus-visible {
|
||||||
|
outline: 2px solid var(--xll-green);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot--filled {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(122, 185, 41, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot--compact {
|
||||||
|
min-height: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot--filled .h5-shot-content {
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-shot input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-sec);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
background: var(--color-page);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text);
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out), background 160ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field input:disabled {
|
||||||
|
color: var(--color-muted);
|
||||||
|
background: #eceef1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field input:hover:not(:disabled) {
|
||||||
|
border-color: #c9cdd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--xll-green);
|
||||||
|
box-shadow: 0 0 0 3px var(--xll-green-soft);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-action-bar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 6;
|
||||||
|
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: transform 160ms var(--ease-out), background 160ms var(--ease-out), box-shadow 160ms var(--ease-out), filter 160ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn:hover:not(:disabled) {
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn--primary {
|
||||||
|
background: linear-gradient(145deg, #8bc73a 0%, var(--xll-green-deep) 100%);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 6px 16px var(--xll-green-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn--primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.03);
|
||||||
|
background: linear-gradient(145deg, #8bc73a 0%, var(--xll-green-deep) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn--danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: rgba(245, 63, 63, 0.35);
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--xll-green);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-toast {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 100px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(29, 33, 41, 0.94);
|
||||||
|
color: #fff;
|
||||||
|
padding: 11px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 20;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.25);
|
||||||
|
animation: h5ToastIn 220ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes h5ToastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 0;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-row span {
|
||||||
|
color: var(--color-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-row strong {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-detail-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--xll-green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-spinner {
|
||||||
|
animation: h5Spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes h5Spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-lock-note {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: var(--color-text-sec);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.h5-fab,
|
||||||
|
.h5-card,
|
||||||
|
.h5-btn,
|
||||||
|
.h5-nav-btn,
|
||||||
|
.h5-shot,
|
||||||
|
.h5-summary-chip,
|
||||||
|
.h5-panel,
|
||||||
|
.h5-toast,
|
||||||
|
.h5-spinner {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
.h5-h2-order-page {
|
||||||
|
padding: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--color-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-phone {
|
||||||
|
max-width: none;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fab-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fab {
|
||||||
|
width: 56px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/prototypes/oneos-h5-h2-order/types.ts
Normal file
40
src/prototypes/oneos-h5-h2-order/types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type ReconcileStatus = 'pending' | 'reconciled';
|
||||||
|
|
||||||
|
export type StationOption = {
|
||||||
|
stationId: string;
|
||||||
|
stationName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type H5OrderRecord = {
|
||||||
|
id: string;
|
||||||
|
stationId: string;
|
||||||
|
stationName: string;
|
||||||
|
hydrogenTime: string;
|
||||||
|
plateNo: string;
|
||||||
|
unitPrice: number;
|
||||||
|
hydrogenKg: number;
|
||||||
|
totalAmount: number;
|
||||||
|
reconcileStatus: ReconcileStatus;
|
||||||
|
reconcileTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateDraft = {
|
||||||
|
id?: string;
|
||||||
|
hydrogenTime: string;
|
||||||
|
plateNo: string;
|
||||||
|
unitPrice: string;
|
||||||
|
hydrogenKg: string;
|
||||||
|
totalAmount: string;
|
||||||
|
platePreviewUrl: string;
|
||||||
|
panelPreviewUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppView =
|
||||||
|
| { name: 'list' }
|
||||||
|
| {
|
||||||
|
name: 'create';
|
||||||
|
draft: CreateDraft;
|
||||||
|
initialDraft: CreateDraft;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
}
|
||||||
|
| { name: 'detail'; id: string };
|
||||||
32
src/prototypes/oneos-h5-h2-order/utils/format.ts
Normal file
32
src/prototypes/oneos-h5-h2-order/utils/format.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示用:YYYY-MM-DD HH:mm */
|
||||||
|
export function formatDateTimeMinute(value: string | null | undefined): string {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return '—';
|
||||||
|
const normalized = raw.replace('T', ' ');
|
||||||
|
if (normalized.length >= 16) return normalized.slice(0, 16);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowDateTimeMinute(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPlateDisplay(plateNo: string): string {
|
||||||
|
const p = String(plateNo || '').replace(/F$/u, '');
|
||||||
|
return p || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(n: number): string {
|
||||||
|
if (!Number.isFinite(n)) return '—';
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatKg(n: number): string {
|
||||||
|
if (!Number.isFinite(n)) return '—';
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
39
src/prototypes/oneos-h5-h2-order/utils/ocr-mock.ts
Normal file
39
src/prototypes/oneos-h5-h2-order/utils/ocr-mock.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const PLATE_POOL = ['浙A88H201', '浙A12345', '浙B23456', '沪ADB9161', '苏E33333'];
|
||||||
|
|
||||||
|
function pick<T>(list: T[]): T {
|
||||||
|
return list[Math.floor(Math.random() * list.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(n: number): number {
|
||||||
|
return Math.round(n * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockPlateOcr(): { plateNo: string } {
|
||||||
|
return { plateNo: pick(PLATE_POOL) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockPanelOcr(): { unitPrice: number; hydrogenKg: number; totalAmount: number } {
|
||||||
|
const unitPrice = pick([42.5, 43, 44, 41, 45]);
|
||||||
|
const hydrogenKg = round1(8 + Math.random() * 12);
|
||||||
|
const totalAmount = round2(unitPrice * hydrogenKg);
|
||||||
|
return { unitPrice, hydrogenKg, totalAmount };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
134
src/prototypes/oneos-h5-h2-order/utils/orders.ts
Normal file
134
src/prototypes/oneos-h5-h2-order/utils/orders.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import type { H5OrderRecord, StationOption } from '../types';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
H2VehicleLedgerBridge?: {
|
||||||
|
getRows: () => Array<Record<string, unknown>>;
|
||||||
|
getStationList: () => Array<{ value: string; label: string }>;
|
||||||
|
upsertRow: (row: Record<string, unknown>) => Record<string, unknown> | null;
|
||||||
|
removeRow: (id: string) => boolean;
|
||||||
|
subscribe: (fn: (rows: Array<Record<string, unknown>>) => void) => () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONCILED = 'reconciled';
|
||||||
|
|
||||||
|
function asNumber(value: unknown, fallback = 0): number {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return value == null ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRowToOrder(row: Record<string, unknown>): H5OrderRecord {
|
||||||
|
const reconcileTime = asString(row.reconcileDate || row.reconciledAt);
|
||||||
|
const reconcileStatus =
|
||||||
|
asString(row.reconcileStatus) === RECONCILED || Boolean(reconcileTime)
|
||||||
|
? 'reconciled'
|
||||||
|
: 'pending';
|
||||||
|
const unitPrice = asNumber(row.costUnitPrice, asNumber(row.customerUnitPrice));
|
||||||
|
const hydrogenKg = asNumber(row.hydrogenKg);
|
||||||
|
const totalAmount = asNumber(row.costTotal, asNumber(row.costAmount, asNumber(row.customerAmount)));
|
||||||
|
return {
|
||||||
|
id: asString(row.id || row.key),
|
||||||
|
stationId: asString(row.stationId),
|
||||||
|
stationName: asString(row.stationName),
|
||||||
|
hydrogenTime: asString(row.hydrogenTime),
|
||||||
|
plateNo: asString(row.plateNo),
|
||||||
|
unitPrice,
|
||||||
|
hydrogenKg,
|
||||||
|
totalAmount,
|
||||||
|
reconcileStatus,
|
||||||
|
reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStations(): StationOption[] {
|
||||||
|
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||||
|
if (!bridge) {
|
||||||
|
return [
|
||||||
|
{ stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)' },
|
||||||
|
{ stationId: 'HZ-H2-002', stationName: '杭州临平加氢站' },
|
||||||
|
{ stationId: 'SH-H2-003', stationName: '上海宝山加氢站' },
|
||||||
|
{ stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return bridge.getStationList().map((item) => ({
|
||||||
|
stationId: item.value,
|
||||||
|
stationName: item.label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOrdersForStation(stationName: string): H5OrderRecord[] {
|
||||||
|
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||||
|
const rows = bridge?.getRows() || [];
|
||||||
|
return rows
|
||||||
|
.map(mapRowToOrder)
|
||||||
|
.filter((r) => r.stationName === stationName)
|
||||||
|
.sort((a, b) => String(b.hydrogenTime).localeCompare(String(a.hydrogenTime)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeOrders(onChange: () => void): () => void {
|
||||||
|
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||||
|
if (!bridge?.subscribe) return () => undefined;
|
||||||
|
return bridge.subscribe(() => onChange());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertStationOrder(input: {
|
||||||
|
id?: string;
|
||||||
|
stationId: string;
|
||||||
|
stationName: string;
|
||||||
|
hydrogenTime: string;
|
||||||
|
plateNo: string;
|
||||||
|
unitPrice: number;
|
||||||
|
hydrogenKg: number;
|
||||||
|
totalAmount: number;
|
||||||
|
}): H5OrderRecord | null {
|
||||||
|
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||||
|
if (!bridge?.upsertRow) return null;
|
||||||
|
const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`;
|
||||||
|
const saved = bridge.upsertRow({
|
||||||
|
id: input.id,
|
||||||
|
key: input.id,
|
||||||
|
stationId: input.stationId,
|
||||||
|
stationName: input.stationName,
|
||||||
|
hydrogenTime: input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime,
|
||||||
|
plateNo: plate,
|
||||||
|
customerName: `${input.stationName}·站端上报`,
|
||||||
|
hydrogenKg: input.hydrogenKg,
|
||||||
|
costUnitPrice: input.unitPrice,
|
||||||
|
costTotal: input.totalAmount,
|
||||||
|
customerUnitPrice: input.unitPrice,
|
||||||
|
customerAmount: input.totalAmount,
|
||||||
|
settlementStatus: 'customer',
|
||||||
|
mileageKm: 0,
|
||||||
|
creatorName: '站端账号',
|
||||||
|
reconcileStatus: 'pending',
|
||||||
|
});
|
||||||
|
return saved ? mapRowToOrder(saved) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStationOrder(id: string): boolean {
|
||||||
|
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||||
|
if (!bridge?.removeRow) return false;
|
||||||
|
return bridge.removeRow(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeOrders(orders: H5OrderRecord[]): {
|
||||||
|
count: number;
|
||||||
|
totalKg: number;
|
||||||
|
totalAmount: number;
|
||||||
|
} {
|
||||||
|
return orders.reduce(
|
||||||
|
(acc, row) => {
|
||||||
|
acc.count += 1;
|
||||||
|
acc.totalKg += row.hydrogenKg || 0;
|
||||||
|
acc.totalAmount += row.totalAmount || 0;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ count: 0, totalKg: 0, totalAmount: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@ const CLOUD_PUBLISH_PATH_ALIASES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function cloudPrototypeSegment(prototypeId: string): string {
|
function cloudPrototypeSegment(prototypeId: string): string {
|
||||||
return CLOUD_PUBLISH_PATH_ALIASES[prototypeId] || `prototypes/${prototypeId}`;
|
// 与 axhub.config.json cloudPublishing.s3 及 Make 发布路径一致:/{id}/index.html
|
||||||
|
return CLOUD_PUBLISH_PATH_ALIASES[prototypeId] || prototypeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 对象存储静态发布站点(无 Make 本地 dev server 路由) */
|
/** 对象存储静态发布站点(无 Make 本地 dev server 路由) */
|
||||||
@@ -40,7 +41,7 @@ export function isPublishedCloudHost(): boolean {
|
|||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
const { hostname, pathname } = window.location;
|
const { hostname, pathname } = window.location;
|
||||||
if (PUBLISHED_CLOUD_HOSTS.has(hostname)) return true;
|
if (PUBLISHED_CLOUD_HOSTS.has(hostname)) return true;
|
||||||
// export-html 发布页:/{prototype}/index.html 或 /prototypes/{prototype}/index.html
|
// export-html 发布页:/{prototype-id}/index.html(与 Make 对象存储发布一致)
|
||||||
return /\/index\.html$/u.test(pathname);
|
return /\/index\.html$/u.test(pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ type SidebarNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GROUP_ICONS: Record<string, LucideIcon> = {
|
const GROUP_ICONS: Record<string, LucideIcon> = {
|
||||||
|
车辆资产: Car,
|
||||||
运维管理: Wrench,
|
运维管理: Wrench,
|
||||||
业务管理: Briefcase,
|
业务管理: Briefcase,
|
||||||
合同配置: FileText,
|
合同配置: FileText,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
{
|
{
|
||||||
"id": "folder-1782874599304-roj8fd",
|
"id": "folder-1782874599304-roj8fd",
|
||||||
"kind": "folder",
|
"kind": "folder",
|
||||||
"title": "运维管理",
|
"title": "车辆资产",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "item:prototypes:vehicle-management",
|
"id": "item:prototypes:vehicle-management",
|
||||||
@@ -78,6 +78,18 @@
|
|||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "站点信息",
|
"title": "站点信息",
|
||||||
"itemKey": "prototypes/oneos-web-h2-station-site"
|
"itemKey": "prototypes/oneos-web-h2-station-site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:oneos-h5-h2-order",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "加氢订单",
|
||||||
|
"itemKey": "prototypes/oneos-h5-h2-order"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:oneos-web-h2-station",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "加氢记录",
|
||||||
|
"itemKey": "prototypes/oneos-web-h2-station"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -89,8 +101,20 @@
|
|||||||
{
|
{
|
||||||
"id": "item:prototypes:business-dept-ledger",
|
"id": "item:prototypes:business-dept-ledger",
|
||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "业务部台账",
|
"title": "项目盈亏情况",
|
||||||
"itemKey": "prototypes/business-dept-ledger"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -153,14 +177,27 @@
|
|||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "还车应结款",
|
"title": "还车应结款",
|
||||||
"itemKey": "prototypes/vehicle-return-settlement"
|
"itemKey": "prototypes/vehicle-return-settlement"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "item:prototypes:vehicle-return-settlement-v2",
|
|
||||||
"kind": "item",
|
|
||||||
"title": "还车应结款-新",
|
|
||||||
"itemKey": "prototypes/vehicle-return-settlement-v2"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "folder-1783875880945-4r6tq1",
|
||||||
|
"kind": "folder",
|
||||||
|
"title": "任务工单",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:task-work-order",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "任务工单",
|
||||||
|
"itemKey": "prototypes/task-work-order"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:lease-business-line-overview",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"itemKey": "prototypes/lease-business-line-overview"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"updatedAt": "2026-07-12T14:43:43.194Z",
|
"updatedAt": "2026-07-14T21:21:56.182Z",
|
||||||
"title": "小羚羚",
|
"title": "小羚羚",
|
||||||
"sectionId": "folder-prototypes-xll-miniapp",
|
"sectionId": "folder-prototypes-xll-miniapp",
|
||||||
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",
|
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @name 数据分析
|
* @name 数据分析
|
||||||
* 自 ONE-OS web端 原稿复刻(8 个页面;业务部台账已迁至 OneOS → 数据分析)
|
* 自 ONE-OS web端 原稿复刻(8 个页面;业务部台账、客户回款情况已迁至 OneOS → 数据分析)
|
||||||
*/
|
*/
|
||||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -119,4 +119,5 @@
|
|||||||
| 入口 | 路径 |
|
| 入口 | 路径 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |
|
| 独立原型(本页) | OneOS → 加氢站管理 → 站点信息 |
|
||||||
| 合包 | ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录 |
|
| 加氢订单 | OneOS → 加氢站管理 → 加氢订单(H5) |
|
||||||
|
| 加氢记录 | OneOS → 加氢站管理 → 加氢记录(Web) |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| 所属系统 | ONE-OS Web 端 |
|
| 所属系统 | ONE-OS Web 端 |
|
||||||
| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |
|
| 目标读者 | 前端 / 后端 / 测试 / 业务产品对接人 |
|
||||||
| 交互原型 | `/prototypes/oneos-web-h2-station-site` |
|
| 交互原型 | `/prototypes/oneos-web-h2-station-site` |
|
||||||
| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢站合包](/prototypes/oneos-web-h2-station) |
|
| 关联原型 | [车辆氢费明细](/prototypes/vehicle-h2-fee-ledger)、[加氢记录](/prototypes/oneos-web-h2-station)、[加氢订单 H5](/prototypes/oneos-h5-h2-order) |
|
||||||
| 文档状态 | 已对齐原型 |
|
| 文档状态 | 已对齐原型 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
- 对账单审批流、ERP 自动过账
|
- 对账单审批流、ERP 自动过账
|
||||||
- 发票 OCR 识别与验真
|
- 发票 OCR 识别与验真
|
||||||
- 加氢订单、加氢记录(仍在合包 `oneos-web-h2-station`)
|
- 加氢订单(独立 H5:`oneos-h5-h2-order`)、加氢记录(独立 Web:`oneos-web-h2-station`)本页不内嵌
|
||||||
- 后端接口联调(原型使用 Mock + 共享 Store)
|
- 后端接口联调(原型使用 Mock + 共享 Store)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
| 包含 | 不包含 |
|
| 包含 | 不包含 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单、加氢记录(仍在合包) |
|
| 站点列表、KPI、新建/编辑/查看/删除 | 加氢订单(H5 独立)、加氢记录(Web 独立) |
|
||||||
| 营业状态、价格配置、余额下钻 | 后端接口联调 |
|
| 营业状态、价格配置、余额下钻 | 后端接口联调 |
|
||||||
| 两阶段对账单、充值单、批量导入 | |
|
| 两阶段对账单、充值单、批量导入 | |
|
||||||
|
|
||||||
@@ -31,4 +31,5 @@
|
|||||||
## 双入口说明
|
## 双入口说明
|
||||||
|
|
||||||
- **本原型**:OneOS → 加氢站管理 → 站点信息
|
- **本原型**:OneOS → 加氢站管理 → 站点信息
|
||||||
- **合包**:ONE-OS Web 端 → 加氢站管理 → 加氢订单 / 加氢记录
|
- **加氢订单**:OneOS → 加氢站管理 → 加氢订单(`oneos-h5-h2-order`)
|
||||||
|
- **加氢记录**:OneOS → 加氢站管理 → 加氢记录(`oneos-web-h2-station`)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 加氢站数量统计 — 产品需求文档(PRD)
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 文档版本 | v1.0 |
|
||||||
|
| 确认日期 | 2026-07-13 |
|
||||||
|
| 模块名称 | 加氢站管理 → 加氢站数量统计 |
|
||||||
|
| 所属系统 | ONE-OS Web 端 |
|
||||||
|
| 交互原型 | `/prototypes/oneos-web-h2-station-stats` |
|
||||||
|
| 数据来源 | 加氢站管理 → 站点信息台账 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
业务管理中心每周通过邮件手工汇总全国加氢站数量(总数、签约/普通、本周新增明细)。本模块将该流程系统化,支持按周筛选、同比/环比、图表与 PDF 报告导出。
|
||||||
|
|
||||||
|
## 2. 已确认决策
|
||||||
|
|
||||||
|
| 决策 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 菜单位置 | 加氢站管理 → 加氢站数量统计 |
|
||||||
|
| 明细范围 | 两个区块:汇总明细(全部)+ 新增明细(周期内) |
|
||||||
|
| 停止营业 | 计入总数 |
|
||||||
|
| 设计基底 | 与站点信息一致的 vm 布局 |
|
||||||
|
|
||||||
|
## 3. 页面结构
|
||||||
|
|
||||||
|
1. 筛选区(统计周、省/市、签约状态、导出 PDF)
|
||||||
|
2. KPI 四卡(总数、签约、普通、本周新增 + 同比/环比)
|
||||||
|
3. 文字摘要条
|
||||||
|
4. 图表四宫格(占比、近 8 周趋势、省份 TOP10、本周新增构成)
|
||||||
|
5. 新增明细表
|
||||||
|
6. 汇总明细表(可导出 CSV)
|
||||||
|
|
||||||
|
## 4. 统计口径
|
||||||
|
|
||||||
|
- **截止点**:所选周周日 23:59:59 的台账快照
|
||||||
|
- **本周新增**:`createTime` 落在该周周一至周日
|
||||||
|
- **同比**:与去年同期同周对比
|
||||||
|
- **环比**:与上一周对比
|
||||||
|
|
||||||
|
## 5. 验收重点
|
||||||
|
|
||||||
|
- [ ] 默认当前周,起止日期为周一至周日
|
||||||
|
- [ ] 签约 + 普通 = 总数;停止营业计入
|
||||||
|
- [ ] 切换周后 KPI / 图表 / 双表联动
|
||||||
|
- [ ] 无新增时显示「本周暂无新增站点」
|
||||||
|
- [ ] PDF 导出含 KPI、摘要、新增/汇总明细
|
||||||
122
src/prototypes/oneos-web-h2-station-stats/annotation-source.json
Normal file
122
src/prototypes/oneos-web-h2-station-stats/annotation-source.json
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"documentVersion": 1,
|
||||||
|
"format": "axhub-annotation-source",
|
||||||
|
"data": {
|
||||||
|
"version": 2,
|
||||||
|
"prototypeName": "oneos-web-h2-station-stats",
|
||||||
|
"pageId": "h2-station-stats",
|
||||||
|
"updatedAt": 1784000000000,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "h2s-filter",
|
||||||
|
"index": 1,
|
||||||
|
"title": "筛选条件",
|
||||||
|
"pageId": "h2-station-stats",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".h2s-filter-card", "[data-annotation-id=\"h2s-filter\"]"],
|
||||||
|
"fingerprint": "filter|h2-station-stats",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "按周筛选、地区与签约状态;切换周后自动刷新;支持导出 PDF。",
|
||||||
|
"annotationText": "统计周期(按周)、省份、城市、签约状态;重置/查询;导出 PDF 分析报告。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2563eb",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784000000000,
|
||||||
|
"updatedAt": 1784000000000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-kpi",
|
||||||
|
"index": 2,
|
||||||
|
"title": "KPI 汇总",
|
||||||
|
"pageId": "h2-station-stats",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".h2s-kpi-row", "[data-annotation-id=\"h2s-kpi\"]"],
|
||||||
|
"fingerprint": "kpi|h2-station-stats",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "全国总数、签约、普通、本周新增;含同比/环比。",
|
||||||
|
"annotationText": "四张 KPI 卡:截止周日的台账快照 + 本周新增;卡片 ? 说明口径。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2563eb",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784000000000,
|
||||||
|
"updatedAt": 1784000000000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-charts",
|
||||||
|
"index": 3,
|
||||||
|
"title": "统计图表",
|
||||||
|
"pageId": "h2-station-stats",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".h2s-charts", "[data-annotation-id=\"h2s-charts\"]"],
|
||||||
|
"fingerprint": "charts|h2-station-stats",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "签约占比、近8周趋势、省份TOP10、本周新增构成。",
|
||||||
|
"annotationText": "四宫格图表区,随筛选联动。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#059669",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784000000000,
|
||||||
|
"updatedAt": 1784000000000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-table-new",
|
||||||
|
"index": 4,
|
||||||
|
"title": "新增明细",
|
||||||
|
"pageId": "h2-station-stats",
|
||||||
|
"locator": {
|
||||||
|
"selectors": ["[data-annotation-id=\"h2s-table-new\"]"],
|
||||||
|
"fingerprint": "table-new|h2-station-stats",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "所选周期内新建站点;空态「本周暂无新增站点」。",
|
||||||
|
"annotationText": "序号、省份、城市、站点名称、签约状态、建档时间。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#d97706",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784000000000,
|
||||||
|
"updatedAt": 1784000000000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-table-all",
|
||||||
|
"index": 5,
|
||||||
|
"title": "汇总明细",
|
||||||
|
"pageId": "h2-station-stats",
|
||||||
|
"locator": {
|
||||||
|
"selectors": ["[data-annotation-id=\"h2s-table-all\"]"],
|
||||||
|
"fingerprint": "table-all|h2-station-stats",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "截止所选周日的全部站点台账;支持 CSV 导出。",
|
||||||
|
"annotationText": "含营业状态列;导出 CSV。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#d97706",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784000000000,
|
||||||
|
"updatedAt": 1784000000000,
|
||||||
|
"controls": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"directory": [
|
||||||
|
{
|
||||||
|
"id": "h2s-prd-root",
|
||||||
|
"title": "产品需求",
|
||||||
|
"type": "folder",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "h2s-prd-main",
|
||||||
|
"title": "加氢站数量统计 PRD",
|
||||||
|
"type": "markdown",
|
||||||
|
"markdownPath": "src/prototypes/oneos-web-h2-station-stats/.spec/requirements-prd.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { FileDown, RotateCcw, Search } from 'lucide-react';
|
||||||
|
import type { H2StationRecord, StatsFilters } from '../types';
|
||||||
|
import { buildRegionOptions } from '../utils/stats';
|
||||||
|
import { buildRecentWeekOptions, type WeekRange } from '../utils/week';
|
||||||
|
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||||
|
|
||||||
|
const SIGNED_OPTIONS = ['全部', '签约站点', '普通站点'] as const;
|
||||||
|
|
||||||
|
const SIGNED_LABEL_TO_VALUE: Record<(typeof SIGNED_OPTIONS)[number], StatsFilters['signed']> = {
|
||||||
|
全部: '',
|
||||||
|
签约站点: 'yes',
|
||||||
|
普通站点: 'no',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIGNED_VALUE_TO_LABEL: Record<StatsFilters['signed'], (typeof SIGNED_OPTIONS)[number]> = {
|
||||||
|
'': '全部',
|
||||||
|
yes: '签约站点',
|
||||||
|
no: '普通站点',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
records: H2StationRecord[];
|
||||||
|
filters: StatsFilters;
|
||||||
|
onChange: (patch: Partial<StatsFilters>) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
onExportPdf: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterPanel({
|
||||||
|
records,
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
onSearch,
|
||||||
|
onExportPdf,
|
||||||
|
}: FilterPanelProps) {
|
||||||
|
const weekOptions = useMemo(() => buildRecentWeekOptions(20), []);
|
||||||
|
const { provinces, citiesByProvince } = useMemo(() => buildRegionOptions(records), [records]);
|
||||||
|
|
||||||
|
const weekOptionLabels = weekOptions.map((w: WeekRange) => w.label);
|
||||||
|
const weekValue = weekOptions.find((w) => w.year === filters.year && w.week === filters.week)?.label
|
||||||
|
?? weekOptionLabels[0]
|
||||||
|
?? '';
|
||||||
|
|
||||||
|
const activeChips = [
|
||||||
|
filters.province ? `省份:${filters.province}` : null,
|
||||||
|
filters.city ? `城市:${filters.city}` : null,
|
||||||
|
filters.signed === 'yes' ? '签约站点' : filters.signed === 'no' ? '普通站点' : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="vm-filter-card h2s-filter-card" data-annotation-id="h2s-filter" aria-label="筛选条件">
|
||||||
|
<div className="vm-filter-head">
|
||||||
|
<div className="h2s-filter-head-main">
|
||||||
|
<h2 className="vm-filter-title">筛选条件</h2>
|
||||||
|
<p className="h2s-filter-hint">切换统计周后自动刷新;地区与签约状态需点击查询</p>
|
||||||
|
</div>
|
||||||
|
<div className="h2s-filter-actions-top">
|
||||||
|
<button type="button" className="vm-btn vm-btn--primary h2s-export-btn" onClick={onExportPdf}>
|
||||||
|
<FileDown size={15} aria-hidden />
|
||||||
|
导出 PDF 分析报告
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-filter-grid h2s-filter-grid">
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>统计周期(按周)</span>
|
||||||
|
<FilterPickerField
|
||||||
|
value={weekValue}
|
||||||
|
options={weekOptionLabels}
|
||||||
|
placeholder="选择统计周"
|
||||||
|
ariaLabel="统计周期"
|
||||||
|
onChange={(label) => {
|
||||||
|
const matched = weekOptions.find((w) => w.label === label);
|
||||||
|
if (!matched) return;
|
||||||
|
onChange({ year: matched.year, week: matched.week });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>省份</span>
|
||||||
|
<FilterPickerField
|
||||||
|
value={filters.province}
|
||||||
|
options={provinces}
|
||||||
|
placeholder="全部省份"
|
||||||
|
ariaLabel="省份"
|
||||||
|
onChange={(province) => onChange({ province, city: '' })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>城市</span>
|
||||||
|
<FilterPickerField
|
||||||
|
value={filters.city}
|
||||||
|
options={filters.province ? citiesByProvince[filters.province] ?? [] : []}
|
||||||
|
placeholder={filters.province ? '全部城市' : '请先选择省份'}
|
||||||
|
ariaLabel="城市"
|
||||||
|
onChange={(city) => onChange({ city })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>签约状态</span>
|
||||||
|
<FilterPickerField
|
||||||
|
value={SIGNED_VALUE_TO_LABEL[filters.signed]}
|
||||||
|
options={[...SIGNED_OPTIONS]}
|
||||||
|
placeholder="全部"
|
||||||
|
ariaLabel="签约状态"
|
||||||
|
onChange={(label) => {
|
||||||
|
if (!label) {
|
||||||
|
onChange({ signed: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signed = SIGNED_LABEL_TO_VALUE[label as (typeof SIGNED_OPTIONS)[number]] ?? '';
|
||||||
|
onChange({ signed });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeChips.length > 0 ? (
|
||||||
|
<div className="h2s-active-filters" aria-label="已选筛选">
|
||||||
|
{activeChips.map((chip) => (
|
||||||
|
<span key={chip} className="h2s-filter-chip">{chip}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="vm-filter-foot">
|
||||||
|
<button type="button" className="vm-btn vm-btn--ghost" onClick={onReset}>
|
||||||
|
<RotateCcw size={15} aria-hidden />
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
<button type="button" className="vm-btn vm-btn--primary" onClick={onSearch}>
|
||||||
|
<Search size={15} aria-hidden />
|
||||||
|
查询
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Download, Inbox } from 'lucide-react';
|
||||||
|
import type { H2StationRecord } from '../types';
|
||||||
|
import { formatDateTime } from '../utils/week';
|
||||||
|
|
||||||
|
interface StationDetailTableProps {
|
||||||
|
title: string;
|
||||||
|
rows: H2StationRecord[];
|
||||||
|
mode: 'all' | 'new';
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
onExportCsv?: () => void;
|
||||||
|
annotationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: H2StationRecord['businessStatus']): string {
|
||||||
|
if (status === '营业中') return 'h2s-status h2s-status--open';
|
||||||
|
if (status === '暂停营业') return 'h2s-status h2s-status--pause';
|
||||||
|
return 'h2s-status h2s-status--stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StationDetailTable({
|
||||||
|
title,
|
||||||
|
rows,
|
||||||
|
mode,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onExportCsv,
|
||||||
|
annotationId,
|
||||||
|
}: StationDetailTableProps) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
const paged = rows.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="vm-table-section h2s-table-section" data-annotation-id={annotationId}>
|
||||||
|
<div className="vm-table-toolbar h2s-table-toolbar">
|
||||||
|
<div className="h2s-table-head">
|
||||||
|
<h3 className="h2s-table-title">{title}</h3>
|
||||||
|
<p className="h2s-table-subtitle">
|
||||||
|
共 <span className="tabular-nums">{rows.length}</span> 座
|
||||||
|
{mode === 'new' ? ' · 周期内新建' : ' · 截止周日台账'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onExportCsv ? (
|
||||||
|
<button type="button" className="vm-btn vm-btn--ghost h2s-table-export" onClick={onExportCsv}>
|
||||||
|
<Download size={15} aria-hidden />
|
||||||
|
导出 CSV
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-table-card h2s-table-card">
|
||||||
|
{rows.length === 0 && mode === 'new' ? (
|
||||||
|
<div className="h2s-empty" role="status">
|
||||||
|
<span className="h2s-empty-icon" aria-hidden>
|
||||||
|
<Inbox size={28} strokeWidth={1.75} />
|
||||||
|
</span>
|
||||||
|
<p className="h2s-empty-title">本周暂无新增站点</p>
|
||||||
|
<p className="h2s-empty-desc">与邮件周报「本周暂无新增」口径一致,可切换其他统计周查看历史新增。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h2s-table-wrap">
|
||||||
|
<table className="h2s-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">序号</th>
|
||||||
|
<th scope="col">所在省份</th>
|
||||||
|
<th scope="col">所在城市</th>
|
||||||
|
<th scope="col">站点名称</th>
|
||||||
|
<th scope="col">签约状态</th>
|
||||||
|
{mode === 'all' ? <th scope="col">营业状态</th> : null}
|
||||||
|
<th scope="col">建档时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((row, index) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td className="tabular-nums h2s-cell-muted">{start + index + 1}</td>
|
||||||
|
<td>{row.province}</td>
|
||||||
|
<td>{row.city}</td>
|
||||||
|
<td className="h2s-cell-name">
|
||||||
|
<span className="h2s-station-name">{row.name}</span>
|
||||||
|
{row.isSigned ? <span className="h2s-tag h2s-tag--signed">签约</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={row.isSigned ? 'h2s-tag h2s-tag--signed-soft' : 'h2s-tag h2s-tag--ordinary-soft'}>
|
||||||
|
{row.isSigned ? '签约站点' : '普通站点'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{mode === 'all' ? (
|
||||||
|
<td><span className={statusClass(row.businessStatus)}>{row.businessStatus}</span></td>
|
||||||
|
) : null}
|
||||||
|
<td className="tabular-nums h2s-cell-time">{formatDateTime(row.createTime)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 ? (
|
||||||
|
<div className="h2s-pagination">
|
||||||
|
<label className="h2s-pagination-size">
|
||||||
|
每页
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
aria-label="每页条数"
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{[10, 20, 50].map((n) => (
|
||||||
|
<option key={n} value={n}>{n}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
条
|
||||||
|
</label>
|
||||||
|
<div className="h2s-pagination-btns">
|
||||||
|
<button type="button" className="h2s-page-btn" disabled={safePage <= 1} onClick={() => onPageChange(safePage - 1)}>上一页</button>
|
||||||
|
<span className="h2s-page-indicator tabular-nums">{safePage} / {totalPages}</span>
|
||||||
|
<button type="button" className="h2s-page-btn" disabled={safePage >= totalPages} onClick={() => onPageChange(safePage + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import type { ChartData, WeekTrendPoint } from '../types';
|
||||||
|
|
||||||
|
interface StatsChartsProps {
|
||||||
|
chart: ChartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
title: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartTooltip({ tip }: { tip: TooltipState | null }) {
|
||||||
|
if (!tip) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h2s-chart-tooltip"
|
||||||
|
style={{ left: tip.x, top: tip.y }}
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<p className="h2s-chart-tooltip-title">{tip.title}</p>
|
||||||
|
{tip.lines.map((line) => (
|
||||||
|
<p key={line} className="h2s-chart-tooltip-line">{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(part: number, total: number): string {
|
||||||
|
if (total <= 0) return '0.0%';
|
||||||
|
return `${((part / total) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DonutChart({ signed, ordinary }: { signed: number; ordinary: number }) {
|
||||||
|
const total = signed + ordinary;
|
||||||
|
const signedPct = total > 0 ? signed / total : 0;
|
||||||
|
const radius = 58;
|
||||||
|
const stroke = 16;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const signedLen = circumference * signedPct;
|
||||||
|
const ordinaryLen = circumference - signedLen;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ key: 'signed', label: '签约站点', value: signed, color: '#10b981' },
|
||||||
|
{ key: 'ordinary', label: '普通站点', value: ordinary, color: '#64748b' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h2s-chart h2s-chart--donut">
|
||||||
|
<div className="h2s-donut-visual">
|
||||||
|
<svg viewBox="0 0 160 160" preserveAspectRatio="xMidYMid meet" role="img" aria-label={`签约 ${signed} 座占 ${pct(signed, total)},普通 ${ordinary} 座占 ${pct(ordinary, total)}`}>
|
||||||
|
<circle cx="80" cy="80" r={radius} fill="none" stroke="#e2e8f0" strokeWidth={stroke} />
|
||||||
|
<circle
|
||||||
|
cx="80"
|
||||||
|
cy="80"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${signedLen} ${circumference}`}
|
||||||
|
transform="rotate(-90 80 80)"
|
||||||
|
className="h2s-donut-segment"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="80"
|
||||||
|
cy="80"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="#64748b"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${ordinaryLen} ${circumference}`}
|
||||||
|
strokeDashoffset={-signedLen}
|
||||||
|
transform="rotate(-90 80 80)"
|
||||||
|
className="h2s-donut-segment h2s-donut-segment--delay"
|
||||||
|
/>
|
||||||
|
<text x="80" y="74" textAnchor="middle" className="h2s-donut-total">{total}</text>
|
||||||
|
<text x="80" y="92" textAnchor="middle" className="h2s-donut-sub">站点总计</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<ul className="h2s-donut-legend">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.key} className="h2s-donut-legend-item">
|
||||||
|
<span className="h2s-donut-legend-dot" style={{ background: item.color }} aria-hidden />
|
||||||
|
<span className="h2s-donut-legend-label">{item.label}</span>
|
||||||
|
<span className="h2s-donut-legend-val tabular-nums">{item.value}</span>
|
||||||
|
<span className="h2s-donut-legend-pct tabular-nums">{pct(item.value, total)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendChart({ chart }: { chart: ChartData }) {
|
||||||
|
const [tip, setTip] = useState<TooltipState | null>(null);
|
||||||
|
const max = Math.max(1, ...chart.weekTrend.map((p) => p.totalNew));
|
||||||
|
const width = 640;
|
||||||
|
const height = 200;
|
||||||
|
const pad = { l: 36, r: 20, t: 18, b: 34 };
|
||||||
|
const innerW = width - pad.l - pad.r;
|
||||||
|
const innerH = height - pad.t - pad.b;
|
||||||
|
const count = chart.weekTrend.length;
|
||||||
|
const barGroupW = innerW / count;
|
||||||
|
const barW = Math.min(28, barGroupW * 0.42);
|
||||||
|
|
||||||
|
const showTip = (event: React.MouseEvent, point: WeekTrendPoint) => {
|
||||||
|
const host = (event.currentTarget as HTMLElement).closest('.h2s-chart--trend');
|
||||||
|
if (!host) return;
|
||||||
|
const hostRect = host.getBoundingClientRect();
|
||||||
|
const targetRect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setTip({
|
||||||
|
x: targetRect.left - hostRect.left + targetRect.width / 2,
|
||||||
|
y: targetRect.top - hostRect.top - 8,
|
||||||
|
title: `${point.year} 年第 ${point.week} 周`,
|
||||||
|
lines: [
|
||||||
|
`合计新增 ${point.totalNew} 座`,
|
||||||
|
`签约 ${point.signedNew} 座 · 普通 ${point.ordinaryNew} 座`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const linePoints = chart.weekTrend.map((p, i) => {
|
||||||
|
const cx = pad.l + barGroupW * i + barGroupW / 2;
|
||||||
|
const cy = pad.t + innerH - (p.totalNew / max) * innerH;
|
||||||
|
return { cx, cy, ...p };
|
||||||
|
});
|
||||||
|
const linePath = linePoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.cx} ${p.cy}`).join(' ');
|
||||||
|
const areaPath = `${linePath} L ${linePoints[linePoints.length - 1]?.cx ?? pad.l} ${pad.t + innerH} L ${linePoints[0]?.cx ?? pad.l} ${pad.t + innerH} Z`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h2s-chart h2s-chart--trend">
|
||||||
|
<ChartTooltip tip={tip} />
|
||||||
|
<div className="h2s-chart-svg-wrap">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
className="h2s-chart-svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="近 8 周新增趋势"
|
||||||
|
onMouseLeave={() => setTip(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="h2s-trend-area" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.22" />
|
||||||
|
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||||
|
const y = pad.t + innerH * (1 - ratio);
|
||||||
|
return (
|
||||||
|
<g key={ratio}>
|
||||||
|
<line x1={pad.l} y1={y} x2={width - pad.r} y2={y} className="h2s-grid-line" />
|
||||||
|
<text x={8} y={y + 4} className="h2s-axis-label">{Math.round(max * ratio)}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<path d={areaPath} fill="url(#h2s-trend-area)" />
|
||||||
|
<path d={linePath} fill="none" className="h2s-trend-line" strokeWidth="2.5" />
|
||||||
|
{chart.weekTrend.map((p, i) => {
|
||||||
|
const cx = pad.l + barGroupW * i + barGroupW / 2;
|
||||||
|
const signedH = (p.signedNew / max) * innerH;
|
||||||
|
const ordinaryH = (p.ordinaryNew / max) * innerH;
|
||||||
|
const baseY = pad.t + innerH;
|
||||||
|
const stackTop = baseY - signedH - ordinaryH;
|
||||||
|
return (
|
||||||
|
<g key={`${p.year}-${p.week}`}>
|
||||||
|
{ordinaryH > 0 ? (
|
||||||
|
<rect
|
||||||
|
x={cx - barW / 2}
|
||||||
|
y={stackTop}
|
||||||
|
width={barW}
|
||||||
|
height={ordinaryH}
|
||||||
|
rx={3}
|
||||||
|
className="h2s-bar h2s-bar--ordinary"
|
||||||
|
onMouseEnter={(e) => showTip(e, p)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{signedH > 0 ? (
|
||||||
|
<rect
|
||||||
|
x={cx - barW / 2}
|
||||||
|
y={baseY - signedH}
|
||||||
|
width={barW}
|
||||||
|
height={signedH}
|
||||||
|
rx={3}
|
||||||
|
className="h2s-bar h2s-bar--signed"
|
||||||
|
onMouseEnter={(e) => showTip(e, p)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{p.totalNew > 0 ? (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={pad.t + innerH - (p.totalNew / max) * innerH}
|
||||||
|
r={4.5}
|
||||||
|
className="h2s-trend-dot"
|
||||||
|
onMouseEnter={(e) => showTip(e, p)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<text x={cx} y={height - 12} textAnchor="middle" className="h2s-axis-label">{p.label}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="h2s-chart-legend">
|
||||||
|
<span><i className="h2s-dot h2s-dot--signed" aria-hidden />签约新增</span>
|
||||||
|
<span><i className="h2s-dot h2s-dot--ordinary" aria-hidden />普通新增</span>
|
||||||
|
<span><i className="h2s-dot h2s-dot--line" aria-hidden />合计趋势线</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvinceBarChart({ items }: { items: ChartData['provinceTop10'] }) {
|
||||||
|
const max = Math.max(1, ...items.map((i) => i.count));
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h2s-chart-empty-wrap">
|
||||||
|
<p className="h2s-chart-empty">暂无省份分布数据</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="h2s-chart h2s-chart--province" role="list" aria-label="各省站点分布 TOP10">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.province} className="h2s-province-row" role="listitem">
|
||||||
|
<span className={['h2s-province-rank', index < 3 ? 'h2s-province-rank--top' : ''].filter(Boolean).join(' ')}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="h2s-province-name" title={item.province}>
|
||||||
|
{item.province.replace(/省|市|自治区/g, '')}
|
||||||
|
</span>
|
||||||
|
<div className="h2s-province-track" aria-hidden>
|
||||||
|
<div
|
||||||
|
className="h2s-province-bar"
|
||||||
|
style={{ width: `${(item.count / max) * 100}%`, transitionDelay: `${index * 30}ms` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="h2s-province-val tabular-nums">{item.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewWeekChart({ signed, ordinary }: { signed: number; ordinary: number }) {
|
||||||
|
const total = signed + ordinary;
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h2s-newweek-empty">
|
||||||
|
<div className="h2s-newweek-empty-icon" aria-hidden />
|
||||||
|
<p className="h2s-newweek-empty-title">本周暂无新增</p>
|
||||||
|
<p className="h2s-newweek-empty-desc">切换其他统计周可查看历史新增构成</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedPct = (signed / total) * 100;
|
||||||
|
return (
|
||||||
|
<div className="h2s-chart h2s-chart--newweek">
|
||||||
|
<div className="h2s-newweek-stats">
|
||||||
|
<div className="h2s-newweek-stat h2s-newweek-stat--signed">
|
||||||
|
<span className="h2s-newweek-stat-label">签约新增</span>
|
||||||
|
<span className="h2s-newweek-stat-val tabular-nums">{signed}</span>
|
||||||
|
<span className="h2s-newweek-stat-pct tabular-nums">{signedPct.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h2s-newweek-stat h2s-newweek-stat--ordinary">
|
||||||
|
<span className="h2s-newweek-stat-label">普通新增</span>
|
||||||
|
<span className="h2s-newweek-stat-val tabular-nums">{ordinary}</span>
|
||||||
|
<span className="h2s-newweek-stat-pct tabular-nums">{(100 - signedPct).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h2s-newweek-bar" role="img" aria-label={`本周新增签约 ${signed} 座,普通 ${ordinary} 座`}>
|
||||||
|
<div className="h2s-newweek-bar-signed" style={{ width: `${signedPct}%` }}>
|
||||||
|
{signedPct >= 18 ? <span className="tabular-nums">{signed}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="h2s-newweek-bar-ordinary" style={{ width: `${100 - signedPct}%` }}>
|
||||||
|
{(100 - signedPct) >= 18 ? <span className="tabular-nums">{ordinary}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="h2s-newweek-caption">
|
||||||
|
本周新增合计 <strong className="tabular-nums">{total}</strong> 座
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCharts({ chart }: StatsChartsProps) {
|
||||||
|
const panels = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'donut',
|
||||||
|
title: '签约 vs 普通占比',
|
||||||
|
subtitle: '截止所选周日的站点结构',
|
||||||
|
layout: 'half' as const,
|
||||||
|
node: <DonutChart signed={chart.signedCount} ordinary={chart.ordinaryCount} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'newweek',
|
||||||
|
title: '本周新增构成',
|
||||||
|
subtitle: '签约与普通新增对比',
|
||||||
|
layout: 'half' as const,
|
||||||
|
node: <NewWeekChart signed={chart.newSignedThisWeek} ordinary={chart.newOrdinaryThisWeek} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'trend',
|
||||||
|
title: '近 8 周新增趋势',
|
||||||
|
subtitle: '堆叠柱 + 合计折线',
|
||||||
|
layout: 'full' as const,
|
||||||
|
node: <TrendChart chart={chart} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'province',
|
||||||
|
title: '各省站点分布 TOP10',
|
||||||
|
subtitle: '按省份汇总站点数',
|
||||||
|
layout: 'full' as const,
|
||||||
|
node: <ProvinceBarChart items={chart.provinceTop10} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[chart],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="h2s-analytics" data-annotation-id="h2s-charts" aria-label="统计图表">
|
||||||
|
<header className="h2s-section-head">
|
||||||
|
<h2 className="h2s-section-title">数据分析</h2>
|
||||||
|
<p className="h2s-section-desc">图表随筛选条件联动更新;悬停可查看明细数值</p>
|
||||||
|
</header>
|
||||||
|
<div className="h2s-charts">
|
||||||
|
{panels.map((panel) => (
|
||||||
|
<article
|
||||||
|
key={panel.key}
|
||||||
|
className={[
|
||||||
|
'h2s-chart-card',
|
||||||
|
panel.layout === 'full' ? 'h2s-chart-card--full' : 'h2s-chart-card--half',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<header className="h2s-chart-head">
|
||||||
|
<h3 className="h2s-chart-title">{panel.title}</h3>
|
||||||
|
<p className="h2s-chart-subtitle">{panel.subtitle}</p>
|
||||||
|
</header>
|
||||||
|
<div className="h2s-chart-body">
|
||||||
|
{panel.node}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Building2, CircleHelp, Handshake, MapPin, Minus, Sparkles, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
import type { StatsKpiSummary } from '../types';
|
||||||
|
import { compareTone, formatComparePercent } from '../utils/stats';
|
||||||
|
|
||||||
|
const CARDS = [
|
||||||
|
{
|
||||||
|
key: 'total' as const,
|
||||||
|
title: '全国加氢站总数',
|
||||||
|
desc: '截止所选周周日 23:59:59,台账中全部站点(含停止营业)',
|
||||||
|
icon: Building2,
|
||||||
|
tone: 'total',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'signed' as const,
|
||||||
|
title: '签约站点',
|
||||||
|
desc: '截止所选周周日,已签约(isSigned = true)的站点数',
|
||||||
|
icon: Handshake,
|
||||||
|
tone: 'signed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ordinary' as const,
|
||||||
|
title: '普通站点',
|
||||||
|
desc: '截止所选周周日,未签约(isSigned = false)的站点数',
|
||||||
|
icon: MapPin,
|
||||||
|
tone: 'ordinary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'newThisWeek' as const,
|
||||||
|
title: '本周新增',
|
||||||
|
desc: '建档时间在所选周周一至周日内的站点数',
|
||||||
|
icon: Sparkles,
|
||||||
|
tone: 'new',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface StatsKpiRowProps {
|
||||||
|
summary: StatsKpiSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompareBadge({ label, value }: { label: string; value: number | null }) {
|
||||||
|
const tone = compareTone(value);
|
||||||
|
const Icon = tone === 'up' ? TrendingUp : tone === 'down' ? TrendingDown : Minus;
|
||||||
|
return (
|
||||||
|
<span className={['h2s-kpi-badge', `h2s-kpi-badge--${tone}`].join(' ')}>
|
||||||
|
<Icon size={11} aria-hidden strokeWidth={2.5} />
|
||||||
|
<span className="h2s-kpi-badge-label">{label}</span>
|
||||||
|
<span className="h2s-kpi-badge-val tabular-nums">{formatComparePercent(value)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsKpiRow({ summary }: StatsKpiRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="h2s-kpi-row" data-annotation-id="h2s-kpi" aria-label="加氢站数量统计指标">
|
||||||
|
{CARDS.map((card) => {
|
||||||
|
const metric = summary[card.key];
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<article key={card.key} className={['h2s-kpi-card', `h2s-kpi-card--${card.tone}`].join(' ')}>
|
||||||
|
<div className="h2s-kpi-card-accent" aria-hidden />
|
||||||
|
<div className="h2s-kpi-card-body">
|
||||||
|
<div className="h2s-kpi-card-head">
|
||||||
|
<span className="h2s-kpi-icon" aria-hidden>
|
||||||
|
<Icon size={16} strokeWidth={2.25} />
|
||||||
|
</span>
|
||||||
|
<span className="h2s-kpi-label">{card.title}</span>
|
||||||
|
<span className="h2s-kpi-tip" tabIndex={0} aria-label={`${card.title}说明`}>
|
||||||
|
<CircleHelp size={13} aria-hidden />
|
||||||
|
<span className="h2s-kpi-tooltip" role="tooltip">{card.desc}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="h2s-kpi-val tabular-nums">{metric.value}</p>
|
||||||
|
<div className="h2s-kpi-badges">
|
||||||
|
<CompareBadge label="环比" value={metric.mom} />
|
||||||
|
<CompareBadge label="同比" value={metric.yoy} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CalendarRange, TrendingUp } from 'lucide-react';
|
||||||
|
import type { ChartData, StatsKpiSummary } from '../types';
|
||||||
|
|
||||||
|
interface SummaryInsightProps {
|
||||||
|
weekLabel: string;
|
||||||
|
kpi: StatsKpiSummary;
|
||||||
|
chart: ChartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ value, label }: { value: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className="h2s-insight-metric">
|
||||||
|
<strong className="tabular-nums">{value}</strong>
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryInsight({ weekLabel, kpi, chart }: SummaryInsightProps) {
|
||||||
|
const hasNew = kpi.newThisWeek.value > 0;
|
||||||
|
const signedPct = kpi.total.value > 0
|
||||||
|
? ((kpi.signed.value / kpi.total.value) * 100).toFixed(1)
|
||||||
|
: '0.0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="h2s-insight" aria-label="周期统计摘要">
|
||||||
|
<div className="h2s-insight-icon" aria-hidden>
|
||||||
|
{hasNew ? <TrendingUp size={18} strokeWidth={2.25} /> : <CalendarRange size={18} strokeWidth={2.25} />}
|
||||||
|
</div>
|
||||||
|
<div className="h2s-insight-body">
|
||||||
|
<p className="h2s-insight-period">{weekLabel}</p>
|
||||||
|
{hasNew ? (
|
||||||
|
<p className="h2s-insight-text">
|
||||||
|
本周新增
|
||||||
|
{' '}
|
||||||
|
<Metric value={kpi.newThisWeek.value} label="座" />
|
||||||
|
(签约 <Metric value={chart.newSignedThisWeek} label="座" />,普通 <Metric value={chart.newOrdinaryThisWeek} label="座" />)。
|
||||||
|
截止本周日,全国台账共 <Metric value={kpi.total.value} label="座" />
|
||||||
|
,签约占比 <strong className="tabular-nums">{signedPct}%</strong>。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="h2s-insight-text">
|
||||||
|
<span className="h2s-insight-muted">本周暂无新增站点。</span>
|
||||||
|
{' '}
|
||||||
|
截止本周日,全国台账共 <Metric value={kpi.total.value} label="座" />
|
||||||
|
(签约 <Metric value={kpi.signed.value} label="座" />,普通 <Metric value={kpi.ordinary.value} label="座" />)。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
938
src/prototypes/oneos-web-h2-station-stats/data/stations.json
Normal file
938
src/prototypes/oneos-web-h2-station-stats/data/stations.json
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "h2s-1",
|
||||||
|
"name": "成都加氢站1号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-01-01 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-2",
|
||||||
|
"name": "成都加氢站2号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-02-03 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-3",
|
||||||
|
"name": "成都加氢站3号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-03-05 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-4",
|
||||||
|
"name": "德阳加氢站1号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "德阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-02-02 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-5",
|
||||||
|
"name": "德阳加氢站2号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "德阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-03-04 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-6",
|
||||||
|
"name": "德阳加氢站3号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "德阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-04-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-7",
|
||||||
|
"name": "绵阳加氢站1号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "绵阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-03-03 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-8",
|
||||||
|
"name": "绵阳加氢站2号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "绵阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-04-05 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-9",
|
||||||
|
"name": "绵阳加氢站3号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "绵阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-05-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-10",
|
||||||
|
"name": "乐山加氢站1号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "乐山市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2024-04-04 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-11",
|
||||||
|
"name": "乐山加氢站2号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "乐山市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-05-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-12",
|
||||||
|
"name": "乐山加氢站3号",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "乐山市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-06-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-13",
|
||||||
|
"name": "杭州加氢站1号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-02-02 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-14",
|
||||||
|
"name": "杭州加氢站2号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-03-04 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-15",
|
||||||
|
"name": "杭州加氢站3号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-04-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-16",
|
||||||
|
"name": "嘉兴加氢站1号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "嘉兴市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-03-03 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-17",
|
||||||
|
"name": "嘉兴加氢站2号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "嘉兴市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-04-05 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-18",
|
||||||
|
"name": "嘉兴加氢站3号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "嘉兴市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-05-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-19",
|
||||||
|
"name": "宁波加氢站1号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "宁波市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2024-04-04 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-20",
|
||||||
|
"name": "宁波加氢站2号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "宁波市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-05-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-21",
|
||||||
|
"name": "宁波加氢站3号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "宁波市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-06-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-22",
|
||||||
|
"name": "温州加氢站1号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "温州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2024-05-05 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-23",
|
||||||
|
"name": "温州加氢站2号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "温州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-06-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-24",
|
||||||
|
"name": "温州加氢站3号",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "温州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-25",
|
||||||
|
"name": "上海加氢站1号",
|
||||||
|
"province": "上海市",
|
||||||
|
"city": "上海市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-03-03 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-26",
|
||||||
|
"name": "上海加氢站2号",
|
||||||
|
"province": "上海市",
|
||||||
|
"city": "上海市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-04-05 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-27",
|
||||||
|
"name": "上海加氢站3号",
|
||||||
|
"province": "上海市",
|
||||||
|
"city": "上海市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-05-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-28",
|
||||||
|
"name": "南京加氢站1号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "南京市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2024-04-04 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-29",
|
||||||
|
"name": "南京加氢站2号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "南京市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-05-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-30",
|
||||||
|
"name": "南京加氢站3号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "南京市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-06-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-31",
|
||||||
|
"name": "苏州加氢站1号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "苏州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2024-05-05 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-32",
|
||||||
|
"name": "苏州加氢站2号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "苏州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-06-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-33",
|
||||||
|
"name": "苏州加氢站3号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "苏州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-34",
|
||||||
|
"name": "无锡加氢站1号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "无锡市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-06-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-35",
|
||||||
|
"name": "无锡加氢站2号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "无锡市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-36",
|
||||||
|
"name": "无锡加氢站3号",
|
||||||
|
"province": "江苏省",
|
||||||
|
"city": "无锡市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-08-10 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-37",
|
||||||
|
"name": "广州加氢站1号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2024-05-05 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-38",
|
||||||
|
"name": "广州加氢站2号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-06-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-39",
|
||||||
|
"name": "广州加氢站3号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-40",
|
||||||
|
"name": "深圳加氢站1号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "深圳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-06-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-41",
|
||||||
|
"name": "深圳加氢站2号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "深圳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-42",
|
||||||
|
"name": "深圳加氢站3号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "深圳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-08-10 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-43",
|
||||||
|
"name": "佛山加氢站1号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "佛山市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-07-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-44",
|
||||||
|
"name": "佛山加氢站2号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "佛山市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-08-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-45",
|
||||||
|
"name": "佛山加氢站3号",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "佛山市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-09-11 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-46",
|
||||||
|
"name": "济南加氢站1号",
|
||||||
|
"province": "山东省",
|
||||||
|
"city": "济南市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-06-06 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-47",
|
||||||
|
"name": "济南加氢站2号",
|
||||||
|
"province": "山东省",
|
||||||
|
"city": "济南市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-48",
|
||||||
|
"name": "济南加氢站3号",
|
||||||
|
"province": "山东省",
|
||||||
|
"city": "济南市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-08-10 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-49",
|
||||||
|
"name": "青岛加氢站1号",
|
||||||
|
"province": "山东省",
|
||||||
|
"city": "青岛市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-07-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-50",
|
||||||
|
"name": "青岛加氢站2号",
|
||||||
|
"province": "山东省",
|
||||||
|
"city": "青岛市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-08-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-51",
|
||||||
|
"name": "青岛加氢站3号",
|
||||||
|
"province": "山东省",
|
||||||
|
"city": "青岛市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-09-11 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-52",
|
||||||
|
"name": "郑州加氢站1号",
|
||||||
|
"province": "河南省",
|
||||||
|
"city": "郑州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-07-07 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-53",
|
||||||
|
"name": "郑州加氢站2号",
|
||||||
|
"province": "河南省",
|
||||||
|
"city": "郑州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-08-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-54",
|
||||||
|
"name": "郑州加氢站3号",
|
||||||
|
"province": "河南省",
|
||||||
|
"city": "郑州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-09-11 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-55",
|
||||||
|
"name": "洛阳加氢站1号",
|
||||||
|
"province": "河南省",
|
||||||
|
"city": "洛阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-08-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-56",
|
||||||
|
"name": "洛阳加氢站2号",
|
||||||
|
"province": "河南省",
|
||||||
|
"city": "洛阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-09-10 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-57",
|
||||||
|
"name": "洛阳加氢站3号",
|
||||||
|
"province": "河南省",
|
||||||
|
"city": "洛阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-10-12 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-58",
|
||||||
|
"name": "石家庄加氢站1号",
|
||||||
|
"province": "河北省",
|
||||||
|
"city": "石家庄市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-08-08 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-59",
|
||||||
|
"name": "石家庄加氢站2号",
|
||||||
|
"province": "河北省",
|
||||||
|
"city": "石家庄市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2025-09-10 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-60",
|
||||||
|
"name": "石家庄加氢站3号",
|
||||||
|
"province": "河北省",
|
||||||
|
"city": "石家庄市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-10-12 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-61",
|
||||||
|
"name": "保定加氢站1号",
|
||||||
|
"province": "河北省",
|
||||||
|
"city": "保定市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2024-09-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-62",
|
||||||
|
"name": "保定加氢站2号",
|
||||||
|
"province": "河北省",
|
||||||
|
"city": "保定市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-10-11 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-63",
|
||||||
|
"name": "保定加氢站3号",
|
||||||
|
"province": "河北省",
|
||||||
|
"city": "保定市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-11-13 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-64",
|
||||||
|
"name": "武汉加氢站1号",
|
||||||
|
"province": "湖北省",
|
||||||
|
"city": "武汉市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "暂停营业",
|
||||||
|
"createTime": "2024-09-09 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-65",
|
||||||
|
"name": "武汉加氢站2号",
|
||||||
|
"province": "湖北省",
|
||||||
|
"city": "武汉市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2025-10-11 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-66",
|
||||||
|
"name": "武汉加氢站3号",
|
||||||
|
"province": "湖北省",
|
||||||
|
"city": "武汉市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-11-13 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-67",
|
||||||
|
"name": "宜昌加氢站1号",
|
||||||
|
"province": "湖北省",
|
||||||
|
"city": "宜昌市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2024-10-10 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-68",
|
||||||
|
"name": "宜昌加氢站2号",
|
||||||
|
"province": "湖北省",
|
||||||
|
"city": "宜昌市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-11-12 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-69",
|
||||||
|
"name": "宜昌加氢站3号",
|
||||||
|
"province": "湖北省",
|
||||||
|
"city": "宜昌市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-01-14 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-70",
|
||||||
|
"name": "西安加氢站1号",
|
||||||
|
"province": "陕西省",
|
||||||
|
"city": "西安市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "停止营业",
|
||||||
|
"createTime": "2024-10-10 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-71",
|
||||||
|
"name": "西安加氢站2号",
|
||||||
|
"province": "陕西省",
|
||||||
|
"city": "西安市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-11-12 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-72",
|
||||||
|
"name": "西安加氢站3号",
|
||||||
|
"province": "陕西省",
|
||||||
|
"city": "西安市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-01-14 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-73",
|
||||||
|
"name": "咸阳加氢站1号",
|
||||||
|
"province": "陕西省",
|
||||||
|
"city": "咸阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2024-11-11 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-74",
|
||||||
|
"name": "咸阳加氢站2号",
|
||||||
|
"province": "陕西省",
|
||||||
|
"city": "咸阳市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-01-13 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-75",
|
||||||
|
"name": "咸阳加氢站3号",
|
||||||
|
"province": "陕西省",
|
||||||
|
"city": "咸阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-02-15 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-76",
|
||||||
|
"name": "成都市新都区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-08 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-77",
|
||||||
|
"name": "成都市龙泉驿区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-09 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-78",
|
||||||
|
"name": "成都市双流区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-10 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-79",
|
||||||
|
"name": "成都市郫都区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-11 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-80",
|
||||||
|
"name": "成都市温江区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-12 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-81",
|
||||||
|
"name": "成都市青白江区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-08 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-82",
|
||||||
|
"name": "成都市天府新区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-09 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-83",
|
||||||
|
"name": "成都市简阳市加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-10 09:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-84",
|
||||||
|
"name": "德阳市旌阳区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "德阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-09 14:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-85",
|
||||||
|
"name": "德阳市罗江区加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "德阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-10 14:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-86",
|
||||||
|
"name": "德阳市广汉市加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "德阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-11 14:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-87",
|
||||||
|
"name": "德阳市什邡市加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "德阳市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-12 14:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-88",
|
||||||
|
"name": "趋势演示站-W27-1",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-06-27 11:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-89",
|
||||||
|
"name": "趋势演示站-W26-2",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-06-24 11:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-90",
|
||||||
|
"name": "趋势演示站-W25-3",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-06-21 11:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-91",
|
||||||
|
"name": "趋势演示站-W24-4",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-06-18 11:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-92",
|
||||||
|
"name": "趋势演示站-W23-5",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-06-15 11:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-93",
|
||||||
|
"name": "趋势演示站-W22-6",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-06-12 11:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-94",
|
||||||
|
"name": "趋势演示站-W21-7",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-06-09 11:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-95",
|
||||||
|
"name": "同比基准站-1",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-10 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-96",
|
||||||
|
"name": "同比基准站-2",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-11 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-97",
|
||||||
|
"name": "同比基准站-3",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-12 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-98",
|
||||||
|
"name": "同比基准站-4",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-13 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-99",
|
||||||
|
"name": "同比基准站-5",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-14 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-100",
|
||||||
|
"name": "同比基准站-6",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-15 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-101",
|
||||||
|
"name": "同比基准站-7",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-16 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-102",
|
||||||
|
"name": "同比基准站-8",
|
||||||
|
"province": "广东省",
|
||||||
|
"city": "广州市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2025-07-17 08:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-cw-1",
|
||||||
|
"name": "成都市温江区示范加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": true,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-13 10:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2s-cw-2",
|
||||||
|
"name": "成都市青白江区示范加氢站",
|
||||||
|
"province": "四川省",
|
||||||
|
"city": "成都市",
|
||||||
|
"isSigned": false,
|
||||||
|
"businessStatus": "营业中",
|
||||||
|
"createTime": "2026-07-13 11:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
198
src/prototypes/oneos-web-h2-station-stats/index.tsx
Normal file
198
src/prototypes/oneos-web-h2-station-stats/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* @name 加氢站数量统计
|
||||||
|
* 加氢站管理 — 站点数量周报统计(签约/普通、同比环比、图表、PDF 导出)
|
||||||
|
*/
|
||||||
|
import '../vehicle-management/style.css';
|
||||||
|
import '../lease-contract-management/styles/lease-contract.css';
|
||||||
|
import '../oneos-web-h2-station/styles/h2-station-vm-filter.css';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
type AnnotationSourceDocument,
|
||||||
|
type AnnotationViewerOptions,
|
||||||
|
} from '@axhub/annotation';
|
||||||
|
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||||
|
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||||
|
import seedStations from './data/stations.json';
|
||||||
|
import { FilterPanel } from './components/FilterPanel';
|
||||||
|
import { StatsKpiRow } from './components/StatsKpiRow';
|
||||||
|
import { StatsCharts } from './components/StatsCharts';
|
||||||
|
import { SummaryInsight } from './components/SummaryInsight';
|
||||||
|
import { StationDetailTable } from './components/StationDetailTable';
|
||||||
|
import type { H2StationRecord, StatsFilters } from './types';
|
||||||
|
import {
|
||||||
|
computeChartData,
|
||||||
|
computeKpiSummary,
|
||||||
|
stationsAtWeekEnd,
|
||||||
|
stationsNewInWeek,
|
||||||
|
} from './utils/stats';
|
||||||
|
import { exportStatsPdfReport, exportStationsCsv } from './utils/export';
|
||||||
|
import { getCurrentWeekRange, getWeekRange } from './utils/week';
|
||||||
|
import annotationSourceDocument from './annotation-source.json';
|
||||||
|
|
||||||
|
function buildDefaultFilters(): StatsFilters {
|
||||||
|
const current = getCurrentWeekRange();
|
||||||
|
return {
|
||||||
|
year: current.year,
|
||||||
|
week: current.week,
|
||||||
|
province: '',
|
||||||
|
city: '',
|
||||||
|
signed: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OneosWebH2StationStats() {
|
||||||
|
useEffect(() => {
|
||||||
|
clearHostPrototypeRouteInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [records] = useState<H2StationRecord[]>(seedStations as H2StationRecord[]);
|
||||||
|
const [pendingFilters, setPendingFilters] = useState<StatsFilters>(buildDefaultFilters);
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<StatsFilters>(buildDefaultFilters);
|
||||||
|
const [allPage, setAllPage] = useState(1);
|
||||||
|
const [newPage, setNewPage] = useState(1);
|
||||||
|
const [allPageSize, setAllPageSize] = useState(10);
|
||||||
|
const [newPageSize, setNewPageSize] = useState(10);
|
||||||
|
const [toast, setToast] = useState('');
|
||||||
|
const [contentKey, setContentKey] = useState(0);
|
||||||
|
|
||||||
|
const showToast = useCallback((msg: string) => {
|
||||||
|
setToast(msg);
|
||||||
|
window.setTimeout(() => setToast(''), 2800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const kpi = useMemo(() => computeKpiSummary(records, appliedFilters), [records, appliedFilters]);
|
||||||
|
const chart = useMemo(() => computeChartData(records, appliedFilters), [records, appliedFilters]);
|
||||||
|
const allRows = useMemo(
|
||||||
|
() => stationsAtWeekEnd(records, appliedFilters.year, appliedFilters.week, appliedFilters),
|
||||||
|
[records, appliedFilters],
|
||||||
|
);
|
||||||
|
const newRows = useMemo(
|
||||||
|
() => stationsNewInWeek(records, appliedFilters.year, appliedFilters.week, appliedFilters),
|
||||||
|
[records, appliedFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekLabel = useMemo(
|
||||||
|
() => getWeekRange(appliedFilters.year, appliedFilters.week).label,
|
||||||
|
[appliedFilters.year, appliedFilters.week],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyFilters = useCallback((next: StatsFilters) => {
|
||||||
|
setAppliedFilters(next);
|
||||||
|
setAllPage(1);
|
||||||
|
setNewPage(1);
|
||||||
|
setContentKey((k) => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
applyFilters(pendingFilters);
|
||||||
|
}, [applyFilters, pendingFilters]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
const defaults = buildDefaultFilters();
|
||||||
|
setPendingFilters(defaults);
|
||||||
|
applyFilters(defaults);
|
||||||
|
}, [applyFilters]);
|
||||||
|
|
||||||
|
const handleExportPdf = useCallback(() => {
|
||||||
|
exportStatsPdfReport({
|
||||||
|
filters: appliedFilters,
|
||||||
|
kpi,
|
||||||
|
chart,
|
||||||
|
allRows,
|
||||||
|
newRows,
|
||||||
|
});
|
||||||
|
showToast('已打开 PDF 报告预览,可在打印对话框中保存为 PDF');
|
||||||
|
}, [allRows, appliedFilters, chart, kpi, newRows, showToast]);
|
||||||
|
|
||||||
|
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||||
|
() => ({
|
||||||
|
showToolbar: true,
|
||||||
|
showThemeToggle: true,
|
||||||
|
showColorFilter: true,
|
||||||
|
emptyWhenNoData: false,
|
||||||
|
toolbarEdge: 'right',
|
||||||
|
currentPageId: 'h2-station-stats',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-page lc-page h2s-page">
|
||||||
|
<header className="vm-page-head h2s-page-head">
|
||||||
|
<div>
|
||||||
|
<p className="vm-breadcrumb">加氢站管理 / 加氢站数量统计</p>
|
||||||
|
<div className="h2s-title-row">
|
||||||
|
<h1 className="vm-page-title">加氢站数量统计</h1>
|
||||||
|
<span className="h2s-period-badge">{weekLabel}</span>
|
||||||
|
</div>
|
||||||
|
<p className="h2s-page-desc">替代邮件人工周报,按周统计签约/普通站点数量、新增明细与同比环比。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FilterPanel
|
||||||
|
records={records}
|
||||||
|
filters={pendingFilters}
|
||||||
|
onChange={(patch) => {
|
||||||
|
setPendingFilters((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
if ('year' in patch || 'week' in patch) {
|
||||||
|
applyFilters(next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onReset={handleReset}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onExportPdf={handleExportPdf}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsKpiRow summary={kpi} />
|
||||||
|
|
||||||
|
<div key={contentKey} className="h2s-content h2s-content--enter">
|
||||||
|
<SummaryInsight weekLabel={weekLabel} kpi={kpi} chart={chart} />
|
||||||
|
|
||||||
|
<StatsCharts chart={chart} />
|
||||||
|
|
||||||
|
<header className="h2s-section-head h2s-section-head--tables">
|
||||||
|
<h2 className="h2s-section-title">站点明细</h2>
|
||||||
|
<p className="h2s-section-desc">新增明细展示周期内建档站点;汇总明细展示截止周日的全部台账</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<StationDetailTable
|
||||||
|
title="新增明细"
|
||||||
|
rows={newRows}
|
||||||
|
mode="new"
|
||||||
|
page={newPage}
|
||||||
|
pageSize={newPageSize}
|
||||||
|
onPageChange={setNewPage}
|
||||||
|
onPageSizeChange={(size) => { setNewPageSize(size); setNewPage(1); }}
|
||||||
|
annotationId="h2s-table-new"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StationDetailTable
|
||||||
|
title="汇总明细"
|
||||||
|
rows={allRows}
|
||||||
|
mode="all"
|
||||||
|
page={allPage}
|
||||||
|
pageSize={allPageSize}
|
||||||
|
onPageChange={setAllPage}
|
||||||
|
onPageSizeChange={(size) => { setAllPageSize(size); setAllPage(1); }}
|
||||||
|
onExportCsv={() => {
|
||||||
|
exportStationsCsv(allRows, `加氢站汇总明细-${appliedFilters.year}-W${appliedFilters.week}.csv`);
|
||||||
|
showToast('CSV 已开始下载');
|
||||||
|
}}
|
||||||
|
annotationId="h2s-table-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast ? <div className="h2s-toast" role="status" aria-live="polite">{toast}</div> : null}
|
||||||
|
|
||||||
|
<PrototypeAnnotationHost
|
||||||
|
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||||
|
options={annotationOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1111
src/prototypes/oneos-web-h2-station-stats/styles/index.css
Normal file
1111
src/prototypes/oneos-web-h2-station-stats/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
65
src/prototypes/oneos-web-h2-station-stats/types.ts
Normal file
65
src/prototypes/oneos-web-h2-station-stats/types.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export type BusinessStatus = '营业中' | '暂停营业' | '停止营业';
|
||||||
|
|
||||||
|
export interface H2StationRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
isSigned: boolean;
|
||||||
|
businessStatus: BusinessStatus;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignedFilter = '' | 'yes' | 'no';
|
||||||
|
|
||||||
|
export interface StatsFilters {
|
||||||
|
year: number;
|
||||||
|
week: number;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
signed: SignedFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekRange {
|
||||||
|
year: number;
|
||||||
|
week: number;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareMetric {
|
||||||
|
value: number;
|
||||||
|
mom: number | null;
|
||||||
|
yoy: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsKpiSummary {
|
||||||
|
total: CompareMetric;
|
||||||
|
signed: CompareMetric;
|
||||||
|
ordinary: CompareMetric;
|
||||||
|
newThisWeek: CompareMetric;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekTrendPoint {
|
||||||
|
year: number;
|
||||||
|
week: number;
|
||||||
|
label: string;
|
||||||
|
totalNew: number;
|
||||||
|
signedNew: number;
|
||||||
|
ordinaryNew: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvinceStat {
|
||||||
|
province: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartData {
|
||||||
|
signedCount: number;
|
||||||
|
ordinaryCount: number;
|
||||||
|
weekTrend: WeekTrendPoint[];
|
||||||
|
provinceTop10: ProvinceStat[];
|
||||||
|
newSignedThisWeek: number;
|
||||||
|
newOrdinaryThisWeek: number;
|
||||||
|
}
|
||||||
142
src/prototypes/oneos-web-h2-station-stats/utils/export.ts
Normal file
142
src/prototypes/oneos-web-h2-station-stats/utils/export.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { ChartData, H2StationRecord, StatsFilters, StatsKpiSummary } from '../types';
|
||||||
|
import { formatComparePercent } from './stats';
|
||||||
|
import { formatDateTime, getWeekRange } from './week';
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTableRows(rows: H2StationRecord[], withStatus: boolean): string {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
const cols = withStatus ? 7 : 6;
|
||||||
|
return `<tr><td colspan="${cols}" style="text-align:center;color:#64748b;padding:16px;">本周暂无新增站点</td></tr>`;
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
.map((row, index) => {
|
||||||
|
const signedLabel = row.isSigned ? '签约站点' : '普通站点';
|
||||||
|
const statusCell = withStatus
|
||||||
|
? `<td>${escapeHtml(row.businessStatus)}</td>`
|
||||||
|
: '';
|
||||||
|
return `<tr>
|
||||||
|
<td>${index + 1}</td>
|
||||||
|
<td>${escapeHtml(row.province)}</td>
|
||||||
|
<td>${escapeHtml(row.city)}</td>
|
||||||
|
<td>${escapeHtml(row.name)}</td>
|
||||||
|
<td>${signedLabel}</td>
|
||||||
|
${statusCell}
|
||||||
|
<td>${escapeHtml(formatDateTime(row.createTime))}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportStatsPdfReport(options: {
|
||||||
|
filters: StatsFilters;
|
||||||
|
kpi: StatsKpiSummary;
|
||||||
|
chart: ChartData;
|
||||||
|
allRows: H2StationRecord[];
|
||||||
|
newRows: H2StationRecord[];
|
||||||
|
}): void {
|
||||||
|
const { filters, kpi, chart, allRows, newRows } = options;
|
||||||
|
const week = getWeekRange(filters.year, filters.week);
|
||||||
|
const generatedAt = new Date();
|
||||||
|
const genStr = `${generatedAt.getFullYear()}-${String(generatedAt.getMonth() + 1).padStart(2, '0')}-${String(generatedAt.getDate()).padStart(2, '0')} ${String(generatedAt.getHours()).padStart(2, '0')}:${String(generatedAt.getMinutes()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const filterParts = [`统计周期:${week.label}`];
|
||||||
|
if (filters.province) filterParts.push(`省份:${filters.province}`);
|
||||||
|
if (filters.city) filterParts.push(`城市:${filters.city}`);
|
||||||
|
if (filters.signed === 'yes') filterParts.push('签约状态:签约站点');
|
||||||
|
if (filters.signed === 'no') filterParts.push('签约状态:普通站点');
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>加氢站数量统计分析报告</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 portrait; margin: 16mm; }
|
||||||
|
body { font-family: "PingFang SC", "Microsoft YaHei", sans-serif; color: #1e293b; font-size: 12px; line-height: 1.5; }
|
||||||
|
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||||
|
.meta { color: #64748b; margin-bottom: 16px; }
|
||||||
|
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; }
|
||||||
|
.kpi { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; }
|
||||||
|
.kpi-label { color: #64748b; font-size: 11px; }
|
||||||
|
.kpi-val { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.kpi-sub { font-size: 10px; color: #475569; margin-top: 4px; }
|
||||||
|
h2 { font-size: 14px; margin: 18px 0 8px; border-left: 3px solid #10b981; padding-left: 8px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||||
|
th, td { border: 1px solid #e2e8f0; padding: 6px 8px; text-align: left; font-variant-numeric: tabular-nums; }
|
||||||
|
th { background: #f8fafc; font-weight: 600; }
|
||||||
|
.summary { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; }
|
||||||
|
.chart-note { color: #64748b; font-size: 11px; margin-bottom: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>加氢站数量统计分析报告</h1>
|
||||||
|
<div class="meta">ONE-OS 运管平台 · 加氢站管理 · 生成时间 ${genStr}</div>
|
||||||
|
<div class="meta">${escapeHtml(filterParts.join(' · '))}</div>
|
||||||
|
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="kpi"><div class="kpi-label">全国加氢站总数</div><div class="kpi-val">${kpi.total.value}</div><div class="kpi-sub">环比 ${formatComparePercent(kpi.total.mom)} · 同比 ${formatComparePercent(kpi.total.yoy)}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-label">签约站点</div><div class="kpi-val">${kpi.signed.value}</div><div class="kpi-sub">环比 ${formatComparePercent(kpi.signed.mom)} · 同比 ${formatComparePercent(kpi.signed.yoy)}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-label">普通站点</div><div class="kpi-val">${kpi.ordinary.value}</div><div class="kpi-sub">环比 ${formatComparePercent(kpi.ordinary.mom)} · 同比 ${formatComparePercent(kpi.ordinary.yoy)}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-label">本周新增</div><div class="kpi-val">${kpi.newThisWeek.value}</div><div class="kpi-sub">环比 ${formatComparePercent(kpi.newThisWeek.mom)} · 同比 ${formatComparePercent(kpi.newThisWeek.yoy)}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
截止 ${week.label.split('(')[1]?.replace(')', '') ?? week.label},全国加氢站共 ${kpi.total.value} 座(签约 ${kpi.signed.value} 座,普通 ${kpi.ordinary.value} 座)。
|
||||||
|
${kpi.newThisWeek.value > 0 ? `本周新增 ${kpi.newThisWeek.value} 座(签约 ${chart.newSignedThisWeek} 座,普通 ${chart.newOrdinaryThisWeek} 座)。` : '本周暂无新增站点。'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>图表摘要</h2>
|
||||||
|
<div class="chart-note">签约占比 ${chart.signedCount + chart.ordinaryCount > 0 ? ((chart.signedCount / (chart.signedCount + chart.ordinaryCount)) * 100).toFixed(1) : '0.0'}% · 近 8 周新增合计 ${chart.weekTrend.reduce((s, p) => s + p.totalNew, 0)} 座</div>
|
||||||
|
|
||||||
|
<h2>新增明细(${week.label})</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>序号</th><th>省份</th><th>城市</th><th>站点名称</th><th>签约状态</th><th>建档时间</th></tr></thead>
|
||||||
|
<tbody>${renderTableRows(newRows, false)}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>汇总明细(全部 ${allRows.length} 座)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>序号</th><th>省份</th><th>城市</th><th>站点名称</th><th>签约状态</th><th>营业状态</th><th>建档时间</th></tr></thead>
|
||||||
|
<tbody>${renderTableRows(allRows, true)}</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const win = window.open('', '_blank', 'noopener,noreferrer,width=960,height=720');
|
||||||
|
if (!win) return;
|
||||||
|
win.document.open();
|
||||||
|
win.document.write(html);
|
||||||
|
win.document.close();
|
||||||
|
win.focus();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
win.print();
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportStationsCsv(rows: H2StationRecord[], filename: string): void {
|
||||||
|
const header = ['序号', '省份', '城市', '站点名称', '签约状态', '营业状态', '建档时间'];
|
||||||
|
const lines = rows.map((row, index) => [
|
||||||
|
String(index + 1),
|
||||||
|
row.province,
|
||||||
|
row.city,
|
||||||
|
row.name,
|
||||||
|
row.isSigned ? '签约站点' : '普通站点',
|
||||||
|
row.businessStatus,
|
||||||
|
formatDateTime(row.createTime),
|
||||||
|
].map((cell) => `"${cell.replace(/"/g, '""')}"`).join(','));
|
||||||
|
const csv = '\uFEFF' + [header.join(','), ...lines].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
156
src/prototypes/oneos-web-h2-station-stats/utils/stats.ts
Normal file
156
src/prototypes/oneos-web-h2-station-stats/utils/stats.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type {
|
||||||
|
ChartData,
|
||||||
|
CompareMetric,
|
||||||
|
H2StationRecord,
|
||||||
|
ProvinceStat,
|
||||||
|
StatsFilters,
|
||||||
|
StatsKpiSummary,
|
||||||
|
WeekTrendPoint,
|
||||||
|
} from '../types';
|
||||||
|
import { getWeekRange, isWithinRange, parseDateTime, shiftWeek } from './week';
|
||||||
|
|
||||||
|
function compareRate(current: number, previous: number): number | null {
|
||||||
|
if (previous === 0) return current === 0 ? 0 : null;
|
||||||
|
return ((current - previous) / previous) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetric(current: number, prev: number, yoyBase: number): CompareMetric {
|
||||||
|
return {
|
||||||
|
value: current,
|
||||||
|
mom: compareRate(current, prev),
|
||||||
|
yoy: compareRate(current, yoyBase),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByRegionAndSigned(
|
||||||
|
records: H2StationRecord[],
|
||||||
|
filters: Pick<StatsFilters, 'province' | 'city' | 'signed'>,
|
||||||
|
): H2StationRecord[] {
|
||||||
|
return records.filter((row) => {
|
||||||
|
if (filters.province && row.province !== filters.province) return false;
|
||||||
|
if (filters.city && row.city !== filters.city) return false;
|
||||||
|
if (filters.signed === 'yes' && !row.isSigned) return false;
|
||||||
|
if (filters.signed === 'no' && row.isSigned) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stationsAtWeekEnd(
|
||||||
|
records: H2StationRecord[],
|
||||||
|
year: number,
|
||||||
|
week: number,
|
||||||
|
filters: Pick<StatsFilters, 'province' | 'city' | 'signed'>,
|
||||||
|
): H2StationRecord[] {
|
||||||
|
const { end } = getWeekRange(year, week);
|
||||||
|
return filterByRegionAndSigned(records, filters).filter((row) => parseDateTime(row.createTime) <= end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stationsNewInWeek(
|
||||||
|
records: H2StationRecord[],
|
||||||
|
year: number,
|
||||||
|
week: number,
|
||||||
|
filters: Pick<StatsFilters, 'province' | 'city' | 'signed'>,
|
||||||
|
): H2StationRecord[] {
|
||||||
|
const { start, end } = getWeekRange(year, week);
|
||||||
|
return filterByRegionAndSigned(records, filters).filter((row) => {
|
||||||
|
const created = parseDateTime(row.createTime);
|
||||||
|
return isWithinRange(created, start, end);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeKpiSummary(records: H2StationRecord[], filters: StatsFilters): StatsKpiSummary {
|
||||||
|
const { year, week } = filters;
|
||||||
|
const prev = shiftWeek(year, week, -1);
|
||||||
|
const yoy = { year: year - 1, week };
|
||||||
|
|
||||||
|
const currentAll = stationsAtWeekEnd(records, year, week, filters);
|
||||||
|
const prevAll = stationsAtWeekEnd(records, prev.year, prev.week, filters);
|
||||||
|
const yoyAll = stationsAtWeekEnd(records, yoy.year, yoy.week, filters);
|
||||||
|
|
||||||
|
const countSigned = (list: H2StationRecord[]) => list.filter((r) => r.isSigned).length;
|
||||||
|
const countOrdinary = (list: H2StationRecord[]) => list.filter((r) => !r.isSigned).length;
|
||||||
|
|
||||||
|
const currentNew = stationsNewInWeek(records, year, week, filters);
|
||||||
|
const prevNew = stationsNewInWeek(records, prev.year, prev.week, filters);
|
||||||
|
const yoyNew = stationsNewInWeek(records, yoy.year, yoy.week, filters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: buildMetric(currentAll.length, prevAll.length, yoyAll.length),
|
||||||
|
signed: buildMetric(countSigned(currentAll), countSigned(prevAll), countSigned(yoyAll)),
|
||||||
|
ordinary: buildMetric(countOrdinary(currentAll), countOrdinary(prevAll), countOrdinary(yoyAll)),
|
||||||
|
newThisWeek: buildMetric(currentNew.length, prevNew.length, yoyNew.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeChartData(records: H2StationRecord[], filters: StatsFilters): ChartData {
|
||||||
|
const { year, week } = filters;
|
||||||
|
const snapshot = stationsAtWeekEnd(records, year, week, filters);
|
||||||
|
const signedCount = snapshot.filter((r) => r.isSigned).length;
|
||||||
|
const ordinaryCount = snapshot.length - signedCount;
|
||||||
|
|
||||||
|
const weekTrend: WeekTrendPoint[] = [];
|
||||||
|
for (let i = 7; i >= 0; i -= 1) {
|
||||||
|
const parts = shiftWeek(year, week, -i);
|
||||||
|
const range = getWeekRange(parts.year, parts.week);
|
||||||
|
const added = stationsNewInWeek(records, parts.year, parts.week, filters);
|
||||||
|
weekTrend.push({
|
||||||
|
year: parts.year,
|
||||||
|
week: parts.week,
|
||||||
|
label: `W${parts.week}`,
|
||||||
|
totalNew: added.length,
|
||||||
|
signedNew: added.filter((r) => r.isSigned).length,
|
||||||
|
ordinaryNew: added.filter((r) => !r.isSigned).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const provinceMap = new Map<string, number>();
|
||||||
|
snapshot.forEach((row) => {
|
||||||
|
provinceMap.set(row.province, (provinceMap.get(row.province) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
const provinceTop10: ProvinceStat[] = [...provinceMap.entries()]
|
||||||
|
.map(([province, count]) => ({ province, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const newThisWeek = stationsNewInWeek(records, year, week, filters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
signedCount,
|
||||||
|
ordinaryCount,
|
||||||
|
weekTrend,
|
||||||
|
provinceTop10,
|
||||||
|
newSignedThisWeek: newThisWeek.filter((r) => r.isSigned).length,
|
||||||
|
newOrdinaryThisWeek: newThisWeek.filter((r) => !r.isSigned).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRegionOptions(records: H2StationRecord[]): {
|
||||||
|
provinces: string[];
|
||||||
|
citiesByProvince: Record<string, string[]>;
|
||||||
|
} {
|
||||||
|
const citiesByProvince: Record<string, Set<string>> = {};
|
||||||
|
records.forEach((row) => {
|
||||||
|
if (!citiesByProvince[row.province]) citiesByProvince[row.province] = new Set();
|
||||||
|
citiesByProvince[row.province].add(row.city);
|
||||||
|
});
|
||||||
|
const provinces = Object.keys(citiesByProvince).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||||
|
const mapped: Record<string, string[]> = {};
|
||||||
|
provinces.forEach((p) => {
|
||||||
|
mapped[p] = [...citiesByProvince[p]].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||||
|
});
|
||||||
|
return { provinces, citiesByProvince: mapped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatComparePercent(value: number | null): string {
|
||||||
|
if (value === null) return '—';
|
||||||
|
if (value === 0) return '0.0%';
|
||||||
|
const sign = value > 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareTone(value: number | null): 'up' | 'down' | 'flat' | 'none' {
|
||||||
|
if (value === null) return 'none';
|
||||||
|
if (value > 0) return 'up';
|
||||||
|
if (value < 0) return 'down';
|
||||||
|
return 'flat';
|
||||||
|
}
|
||||||
82
src/prototypes/oneos-web-h2-station-stats/utils/week.ts
Normal file
82
src/prototypes/oneos-web-h2-station-stats/utils/week.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { WeekRange } from '../types';
|
||||||
|
|
||||||
|
export function parseDateTime(value: string): Date {
|
||||||
|
return new Date(value.replace(' ', 'T'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getISOWeekParts(date: Date): { year: number; week: number } {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||||
|
return { year: d.getUTCFullYear(), week };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekRange(year: number, week: number): WeekRange {
|
||||||
|
const jan4 = new Date(year, 0, 4);
|
||||||
|
const dayOfWeek = jan4.getDay() || 7;
|
||||||
|
const week1Monday = new Date(jan4);
|
||||||
|
week1Monday.setDate(jan4.getDate() - dayOfWeek + 1);
|
||||||
|
week1Monday.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const start = new Date(week1Monday);
|
||||||
|
start.setDate(week1Monday.getDate() + (week - 1) * 7);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + 6);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
week,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
label: formatWeekLabel(year, week, start, end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentWeekRange(reference = new Date()): WeekRange {
|
||||||
|
const { year, week } = getISOWeekParts(reference);
|
||||||
|
return getWeekRange(year, week);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWeekLabel(year: number, week: number, start: Date, end: Date): string {
|
||||||
|
const fmt = (d: Date) => {
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${m}-${day}`;
|
||||||
|
};
|
||||||
|
return `${year} 年第 ${week} 周(${fmt(start)} ~ ${fmt(end)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftWeek(year: number, week: number, delta: number): { year: number; week: number } {
|
||||||
|
const range = getWeekRange(year, week);
|
||||||
|
const shifted = new Date(range.start);
|
||||||
|
shifted.setDate(shifted.getDate() + delta * 7);
|
||||||
|
return getISOWeekParts(shifted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRecentWeekOptions(count = 16, reference = new Date()): WeekRange[] {
|
||||||
|
const current = getISOWeekParts(reference);
|
||||||
|
const options: WeekRange[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const parts = shiftWeek(current.year, current.week, -i);
|
||||||
|
options.push(getWeekRange(parts.year, parts.week));
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWithinRange(date: Date, start: Date, end: Date): boolean {
|
||||||
|
return date.getTime() >= start.getTime() && date.getTime() <= end.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string): string {
|
||||||
|
const d = parseDateTime(value);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day} ${hh}:${mm}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 加氢记录(Web)· 数据结构与对账联动
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 代码路径 | UI:`pages/02-加氢记录.jsx`;Store:`src/common/h2VehicleLedgerBridge.js` |
|
||||||
|
| 对齐模块 | H5「加氢订单」`oneos-h5-h2-order`(同一业务、同一 Store;PC 多导出 / 预约加氢) |
|
||||||
|
| 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 业务字段(列表 / 表单口径)
|
||||||
|
|
||||||
|
与 H5「加氢订单」一致:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 加氢时间 | `YYYY-MM-DD HH:mm:ss` |
|
||||||
|
| 加氢站名称 | 站名;写入时同步 `stationId`/`stationCode` |
|
||||||
|
| 车牌号 | 展示去尾缀 `F`;写入 Store 补 `F` |
|
||||||
|
| 氢气单价(元/kg) | 站端口径,映射 `costUnitPrice` / `customerUnitPrice` |
|
||||||
|
| 加氢量(kg) | `hydrogenKg` |
|
||||||
|
| 加氢总额(元) | 映射 `costTotal` / `customerAmount` |
|
||||||
|
| 对账状态 | `pending` / `reconciled` |
|
||||||
|
| 对账时间 | `reconcileDate` / `reconciledAt` 格式化展示 |
|
||||||
|
|
||||||
|
写入 Bridge 时默认:`settlementStatus=customer`、`mileageKm=0`、`creatorName=站端账号`(与 H5 `upsertStationOrder` 相同),不在 Web 表单暴露。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 对账判定优先级
|
||||||
|
|
||||||
|
| 优先级 | 条件 | 用户可见 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `reconcileStatus === 'reconciled'` 或已有对账时间 | **已对账**;禁编辑、禁删除 |
|
||||||
|
| 2 | 否则 | **未对账**;可编辑、删除 |
|
||||||
|
|
||||||
|
## 3. 前置条件
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 共享 Bridge 不可用 | 列表用本地 fallback;保存/删除提示失败 |
|
||||||
|
| 已对账点编辑/删除 | 警告「已对账记录不可编辑/删除」 |
|
||||||
|
| PC 独有 | **导出**当前筛选结果 CSV;**预约加氢**独立弹窗(不影响加氢记录数据结构) |
|
||||||
|
|
||||||
|
## 4. 数据流
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web 加氢记录 新增/编辑/批量
|
||||||
|
→ hrUpsertBridgeRow → Bridge.upsertRow(站端字段映射)
|
||||||
|
→ Store subscribe → 列表刷新
|
||||||
|
|
||||||
|
H5 加氢订单 同上 upsert/remove
|
||||||
|
→ 与 Web 读写同一 Store
|
||||||
|
|
||||||
|
站点信息 · 对账单提交
|
||||||
|
→ applyPatches(reconciled + 对账时间)
|
||||||
|
→ Web / H5 同步展示
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 边界
|
||||||
|
|
||||||
|
- Web 不做 OCR;录入单价×量可自动带出总额,总额仍可手改
|
||||||
|
- 预约加氢为 PC 侧能力,数据与加氢记录 Store 分离
|
||||||
|
- 跨页联动依赖同浏览器会话内共享 Store
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 加氢记录(Web)— 产品需求文档
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 模块名称 | 加氢站管理 → 加氢记录 |
|
||||||
|
| 所属系统 | ONE-OS(PC Web) |
|
||||||
|
| 交互原型 | `/prototypes/oneos-web-h2-station` |
|
||||||
|
| 关联模块 | [加氢订单 H5](/prototypes/oneos-h5-h2-order)、[站点信息](/prototypes/oneos-web-h2-station-site) |
|
||||||
|
| 数据结构全文 | [record-data-model.md](./record-data-model.md) |
|
||||||
|
| H5 对账规则 | [../oneos-h5-h2-order/.spec/reconcile-linkage.md](../oneos-h5-h2-order/.spec/reconcile-linkage.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
与 H5「加氢订单」为同一业务:站端上报加氢流水。Web 供 PC 操作;H5 供移动端。数据结构、共享账本与对账规则一致。Web **额外**提供导出、预约加氢处理。
|
||||||
|
|
||||||
|
### 1.1 功能清单
|
||||||
|
|
||||||
|
| # | 功能 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 列表 / 筛选 | 字段与 H5 一致;可按时间、车牌、站名、对账状态筛选 |
|
||||||
|
| 2 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、单价、量、总额;已对账只读 |
|
||||||
|
| 3 | 删除 | 仅未对账;写共享 Store |
|
||||||
|
| 4 | 导出 | 当前筛选结果 CSV(Web 独有) |
|
||||||
|
| 5 | 预约加氢 | 弹窗处理预约(Web 独有,与记录数据结构分离) |
|
||||||
|
|
||||||
|
### 1.2 列表字段
|
||||||
|
|
||||||
|
加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 验收项
|
||||||
|
|
||||||
|
1. 列表/表单字段与 H5「加氢订单」一致(无公里数 / 充装员 / 承担方式录入)
|
||||||
|
2. 与 H5 读写同一共享 Store;一端增删改后另一端可见(同会话)
|
||||||
|
3. 已对账不可编辑、删除
|
||||||
|
4. 导出列与列表字段一致
|
||||||
|
5. 预约加氢入口与弹窗仍可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 复杂逻辑摘要
|
||||||
|
|
||||||
|
| 主题 | 摘要 | 全文 |
|
||||||
|
|---|---|---|
|
||||||
|
| 数据结构与对账 | 站端口径字段映射 Bridge;对账后禁改;PC 多导出/预约 | [record-data-model.md](./record-data-model.md) |
|
||||||
87
src/prototypes/oneos-web-h2-station/annotation-source.json
Normal file
87
src/prototypes/oneos-web-h2-station/annotation-source.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"documentVersion": 1,
|
||||||
|
"format": "axhub-annotation-source",
|
||||||
|
"data": {
|
||||||
|
"version": 1,
|
||||||
|
"prototypeName": "oneos-web-h2-station",
|
||||||
|
"pageId": "h2-record",
|
||||||
|
"updatedAt": 1784013600000,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "hr-filter",
|
||||||
|
"index": 1,
|
||||||
|
"title": "筛选条件",
|
||||||
|
"pageId": "h2-record",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".lc-filter-card"],
|
||||||
|
"fingerprint": "filter|h2-record",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"annotationText": "按加氢时间、车牌、加氢站、对账状态筛选;字段口径与 H5「加氢订单」一致。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2563EB",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784013600000,
|
||||||
|
"updatedAt": 1784013600000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hr-toolbar",
|
||||||
|
"index": 2,
|
||||||
|
"title": "列表工具栏",
|
||||||
|
"pageId": "h2-record",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".lc-table-toolbar"],
|
||||||
|
"fingerprint": "toolbar|h2-record",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"annotationText": "Web 独有:预约加氢、导出;新增/编辑写入与 H5 同一共享账本。已对账不可改删。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#059669",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784013600000,
|
||||||
|
"updatedAt": 1784013600000,
|
||||||
|
"controls": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hr-table",
|
||||||
|
"index": 3,
|
||||||
|
"title": "加氢记录列表",
|
||||||
|
"pageId": "h2-record",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [".lc-list-table"],
|
||||||
|
"fingerprint": "table|h2-record",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"annotationText": "列:加氢时间、站名、车牌、单价、量、总额、对账状态、对账时间(与 H5 一致)。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#FF7D00",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1784013600000,
|
||||||
|
"updatedAt": 1784013600000,
|
||||||
|
"controls": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"directory": [
|
||||||
|
{
|
||||||
|
"id": "hr-prd-root",
|
||||||
|
"title": "产品需求",
|
||||||
|
"type": "folder",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "hr-prd-main",
|
||||||
|
"title": "加氢记录(Web)PRD",
|
||||||
|
"type": "markdown",
|
||||||
|
"markdownPath": "src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hr-prd-model",
|
||||||
|
"title": "数据结构与对账",
|
||||||
|
"type": "markdown",
|
||||||
|
"markdownPath": "src/prototypes/oneos-web-h2-station/.spec/record-data-model.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* @name 加氢站管理
|
* @name 加氢记录
|
||||||
* 自 ONE-OS web端 原稿复刻(加氢订单、加氢记录)
|
* Web 端加氢记录独立页;与 H5「加氢订单」同 Store/字段;加氢订单见 oneos-h5-h2-order
|
||||||
*/
|
*/
|
||||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||||
import '../../common/h2VehicleLedgerBridge.js';
|
import '../../common/h2VehicleLedgerBridge.js';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import '../vehicle-management/style.css';
|
import '../vehicle-management/style.css';
|
||||||
import Page1 from './pages/01-加氢订单.jsx';
|
|
||||||
import Page2 from './pages/02-加氢记录.jsx';
|
import Page2 from './pages/02-加氢记录.jsx';
|
||||||
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
|
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ id: 'u52a0u6c22u7ad9u7ba1u7406-u52a0u6c22u8ba2u5355', title: '加氢订单', component: Page1 },
|
{ id: 'h2-record', title: '加氢记录', component: Page2 },
|
||||||
{ id: 'u52a0u6c22u7ad9u7ba1u7406-u52a0u6c22u8bb0u5f55', title: '加氢记录', component: Page2 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OneosWebH2Station() {
|
export default function OneosWebH2Station() {
|
||||||
return (
|
return (
|
||||||
<OneosWebLegacyShell
|
<OneosWebLegacyShell
|
||||||
moduleTitle="加氢站管理"
|
moduleTitle="加氢记录"
|
||||||
pages={pages}
|
pages={pages}
|
||||||
defaultPageId="u52a0u6c22u7ad9u7ba1u7406-u52a0u6c22u8ba2u5355"
|
defaultPageId="h2-record"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 【重要】必须使用 const Component 作为组件变量名
|
// 【重要】必须使用 const Component 作为组件变量名
|
||||||
// 加氢站管理 - 加氢记录(列表 + 筛选 + 预约加氢弹窗 + 卡片式新增/编辑 + 导出)
|
// 加氢站管理 - 加氢记录(与 H5 加氢订单同 Store/字段;PC 另含导出 + 预约加氢)
|
||||||
|
|
||||||
import { OperationActions } from '../../../common/OperationActions';
|
import { OperationActions } from '../../../common/OperationActions';
|
||||||
|
|
||||||
@@ -200,23 +200,6 @@ var HR_PRIMARY_BTN_STYLE = {
|
|||||||
|
|
||||||
var HR_MOCK_FILLERS = ['张三', '李四', '王静', '赵敏', '陈浩'];
|
var HR_MOCK_FILLERS = ['张三', '李四', '王静', '赵敏', '陈浩'];
|
||||||
|
|
||||||
var HR_SETTLEMENT_STATUS_OPTIONS = [
|
|
||||||
{ value: 'customer', label: '客户承担' },
|
|
||||||
{ value: 'internal', label: '我司承担' },
|
|
||||||
{ value: 'customer_self', label: '客户自行结算' }
|
|
||||||
];
|
|
||||||
|
|
||||||
var HR_SETTLEMENT_STATUS_MAP = {
|
|
||||||
customer: { label: '客户承担', color: 'blue' },
|
|
||||||
internal: { label: '我司承担', color: 'green' },
|
|
||||||
customer_self: { label: '客户自行结算', color: 'orange' }
|
|
||||||
};
|
|
||||||
|
|
||||||
function hrSettlementStatusLabel(value) {
|
|
||||||
var info = HR_SETTLEMENT_STATUS_MAP[value];
|
|
||||||
return info ? info.label : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hrGetVehicleLedgerBridge() {
|
function hrGetVehicleLedgerBridge() {
|
||||||
return typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null;
|
return typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null;
|
||||||
}
|
}
|
||||||
@@ -230,16 +213,8 @@ function hrBuildInitialRecordsFromLedger() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var HR_FALLBACK_RECORDS = [
|
var HR_FALLBACK_RECORDS = [
|
||||||
{ id: 'hr-1', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', hydrogenKg: 12.5, customerAmount: 562.5, mileageKm: 128560, fillerName: '张三', settlementStatus: 'customer' },
|
{ id: 'rf-1', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', stationName: '嘉兴加氢站(一期)', stationCode: 'JX-H2-001', unitPrice: 42.5, hydrogenKg: 12.5, totalAmount: 531.25, customerAmount: 531.25, reconcileStatus: 'reconciled', reconcileTime: '2026-05-29 10:00:00' },
|
||||||
{ id: 'hr-2', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', hydrogenKg: 10.0, customerAmount: 450.0, mileageKm: 95230, fillerName: '李四', settlementStatus: 'internal' },
|
{ id: 'rf-10', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', stationName: '苏州工业园区备用站', stationCode: 'SZ-H2-004', unitPrice: 41, hydrogenKg: 6.2, totalAmount: 254.2, customerAmount: 254.2, reconcileStatus: 'pending', reconcileTime: '' }
|
||||||
{ id: 'hr-3', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', hydrogenKg: 18.3, customerAmount: 823.5, mileageKm: 201880, fillerName: '王静', settlementStatus: 'customer_self' },
|
|
||||||
{ id: 'hr-4', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', hydrogenKg: 15.6, customerAmount: 702.0, mileageKm: 167420, fillerName: '张三', settlementStatus: 'customer' },
|
|
||||||
{ id: 'hr-5', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', hydrogenKg: 15.3, customerAmount: 703.8, mileageKm: 143200, fillerName: '赵敏', settlementStatus: 'internal' },
|
|
||||||
{ id: 'hr-6', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', hydrogenKg: 18.2, customerAmount: 837.2, mileageKm: 189650, fillerName: '陈浩', settlementStatus: 'customer_self' },
|
|
||||||
{ id: 'hr-7', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', hydrogenKg: 11.8, customerAmount: 542.8, mileageKm: 110340, fillerName: '李四', settlementStatus: 'customer' },
|
|
||||||
{ id: 'hr-8', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', hydrogenKg: 8.0, customerAmount: 376.0, mileageKm: 88420, fillerName: '王静', settlementStatus: 'internal' },
|
|
||||||
{ id: 'hr-9', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', hydrogenKg: 9.5, customerAmount: 446.5, mileageKm: 76500, fillerName: '张三', settlementStatus: 'customer_self' },
|
|
||||||
{ id: 'hr-10', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', hydrogenKg: 6.2, customerAmount: 272.8, mileageKm: 45210, fillerName: '赵敏', settlementStatus: 'customer' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
var HR_APPOINTMENT_STATUS_MAP = {
|
var HR_APPOINTMENT_STATUS_MAP = {
|
||||||
@@ -273,13 +248,6 @@ function hrFormatYuanSymbol(v) {
|
|||||||
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function hrFormatKm(v) {
|
|
||||||
if (v == null || v === '') return '—';
|
|
||||||
var n = typeof v === 'number' ? v : parseFloat(String(v).replace(/,/g, ''));
|
|
||||||
if (isNaN(n)) return '—';
|
|
||||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) + ' km';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hrCsvEscape(v) {
|
function hrCsvEscape(v) {
|
||||||
var s = v == null ? '' : String(v);
|
var s = v == null ? '' : String(v);
|
||||||
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
||||||
@@ -372,12 +340,12 @@ function hrInTimeRange(timeStr, range, dayjs) {
|
|||||||
function hrEmptyForm() {
|
function hrEmptyForm() {
|
||||||
return {
|
return {
|
||||||
hydrogenTime: '',
|
hydrogenTime: '',
|
||||||
|
stationName: '',
|
||||||
|
stationCode: '',
|
||||||
plateNo: '',
|
plateNo: '',
|
||||||
|
unitPrice: '',
|
||||||
hydrogenKg: '',
|
hydrogenKg: '',
|
||||||
customerAmount: '',
|
totalAmount: ''
|
||||||
mileageKm: '',
|
|
||||||
fillerName: undefined,
|
|
||||||
settlementStatus: undefined
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,27 +357,28 @@ function hrNextBatchRowId() {
|
|||||||
return 'hr-row-' + Date.now() + '-' + Math.floor(Math.random() * 10000);
|
return 'hr-row-' + Date.now() + '-' + Math.floor(Math.random() * 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hrEmptyBatchRow() {
|
function hrEmptyBatchRow(defaults) {
|
||||||
|
defaults = defaults || {};
|
||||||
return {
|
return {
|
||||||
_rowId: hrNextBatchRowId(),
|
_rowId: hrNextBatchRowId(),
|
||||||
hydrogenTime: '',
|
hydrogenTime: '',
|
||||||
|
stationName: defaults.stationName || '',
|
||||||
|
stationCode: defaults.stationCode || '',
|
||||||
plateNo: '',
|
plateNo: '',
|
||||||
|
unitPrice: '',
|
||||||
hydrogenKg: '',
|
hydrogenKg: '',
|
||||||
customerAmount: '',
|
totalAmount: ''
|
||||||
mileageKm: '',
|
|
||||||
fillerName: undefined,
|
|
||||||
settlementStatus: undefined
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hrIsBatchRowEmpty(row) {
|
function hrIsBatchRowEmpty(row) {
|
||||||
if (!row) return true;
|
if (!row) return true;
|
||||||
if ((row.hydrogenTime || '').trim()) return false;
|
if ((row.hydrogenTime || '').trim()) return false;
|
||||||
|
if ((row.stationName || '').trim()) return false;
|
||||||
if ((row.plateNo || '').trim()) return false;
|
if ((row.plateNo || '').trim()) return false;
|
||||||
|
if ((row.unitPrice || '').trim()) return false;
|
||||||
if ((row.hydrogenKg || '').trim()) return false;
|
if ((row.hydrogenKg || '').trim()) return false;
|
||||||
if ((row.customerAmount || '').trim()) return false;
|
if ((row.totalAmount || '').trim()) return false;
|
||||||
if ((row.mileageKm || '').trim()) return false;
|
|
||||||
if (row.fillerName) return false;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,30 +393,91 @@ function hrBatchRowsDirty(rows) {
|
|||||||
function hrValidateBatchRow(row, rowIndex) {
|
function hrValidateBatchRow(row, rowIndex) {
|
||||||
var label = '第 ' + rowIndex + ' 行';
|
var label = '第 ' + rowIndex + ' 行';
|
||||||
if (!(row.hydrogenTime || '').trim()) return label + ':请填写加氢时间';
|
if (!(row.hydrogenTime || '').trim()) return label + ':请填写加氢时间';
|
||||||
|
if (!(row.stationName || '').trim()) return label + ':请选择加氢站';
|
||||||
if (!(row.plateNo || '').trim()) return label + ':请填写车牌号';
|
if (!(row.plateNo || '').trim()) return label + ':请填写车牌号';
|
||||||
|
var price = parseFloat(row.unitPrice);
|
||||||
|
if (isNaN(price) || price < 0) return label + ':请填写有效的氢气单价';
|
||||||
var kg = parseFloat(row.hydrogenKg);
|
var kg = parseFloat(row.hydrogenKg);
|
||||||
if (isNaN(kg) || kg <= 0) return label + ':请填写有效的加氢量(kg)';
|
if (isNaN(kg) || kg <= 0) return label + ':请填写有效的加氢量(kg)';
|
||||||
var amount = parseFloat(row.customerAmount);
|
var amount = parseFloat(row.totalAmount);
|
||||||
if (isNaN(amount) || amount < 0) return label + ':请填写有效的加氢金额';
|
if (isNaN(amount) || amount < 0) return label + ':请填写有效的加氢总额';
|
||||||
var km = parseFloat(row.mileageKm);
|
|
||||||
if (isNaN(km) || km < 0) return label + ':请填写有效的公里数';
|
|
||||||
if (!row.fillerName) return label + ':请选择充装员';
|
|
||||||
if (!row.settlementStatus) return label + ':请选择承担方式';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hrBatchRowToPayload(row) {
|
function hrBatchRowToPayload(row) {
|
||||||
|
var plate = (row.plateNo || '').trim().toUpperCase();
|
||||||
|
if (plate && !/F$/u.test(plate)) plate += 'F';
|
||||||
|
var unitPrice = Math.round(parseFloat(row.unitPrice) * 100) / 100;
|
||||||
|
var hydrogenKg = Math.round(parseFloat(row.hydrogenKg) * 100) / 100;
|
||||||
|
var totalAmount = Math.round(parseFloat(row.totalAmount) * 100) / 100;
|
||||||
return {
|
return {
|
||||||
hydrogenTime: (row.hydrogenTime || '').trim(),
|
hydrogenTime: (row.hydrogenTime || '').trim(),
|
||||||
plateNo: (row.plateNo || '').trim().toUpperCase(),
|
stationName: (row.stationName || '').trim(),
|
||||||
hydrogenKg: Math.round(parseFloat(row.hydrogenKg) * 100) / 100,
|
stationCode: (row.stationCode || '').trim(),
|
||||||
customerAmount: Math.round(parseFloat(row.customerAmount) * 100) / 100,
|
plateNo: plate,
|
||||||
mileageKm: Math.round(parseFloat(row.mileageKm)),
|
unitPrice: unitPrice,
|
||||||
fillerName: row.fillerName,
|
hydrogenKg: hydrogenKg,
|
||||||
settlementStatus: row.settlementStatus
|
totalAmount: totalAmount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hrUpsertBridgeRow(payload) {
|
||||||
|
var bridge = hrGetVehicleLedgerBridge();
|
||||||
|
if (!bridge || typeof bridge.upsertRow !== 'function') return null;
|
||||||
|
var plate = (payload.plateNo || '').trim().toUpperCase();
|
||||||
|
if (plate && !/F$/u.test(plate)) plate += 'F';
|
||||||
|
var stationName = payload.stationName || '';
|
||||||
|
var stationCode = payload.stationCode || '';
|
||||||
|
var unitPrice = Number(payload.unitPrice);
|
||||||
|
var hydrogenKg = Number(payload.hydrogenKg);
|
||||||
|
var totalAmount = Number(payload.totalAmount);
|
||||||
|
return bridge.upsertRow({
|
||||||
|
id: payload.id,
|
||||||
|
key: payload.id,
|
||||||
|
stationId: stationCode,
|
||||||
|
stationName: stationName,
|
||||||
|
hydrogenTime: payload.hydrogenTime,
|
||||||
|
plateNo: plate,
|
||||||
|
customerName: stationName ? (stationName + '·站端上报') : '站端上报',
|
||||||
|
hydrogenKg: hydrogenKg,
|
||||||
|
costUnitPrice: unitPrice,
|
||||||
|
costTotal: totalAmount,
|
||||||
|
customerUnitPrice: unitPrice,
|
||||||
|
customerAmount: totalAmount,
|
||||||
|
settlementStatus: 'customer',
|
||||||
|
mileageKm: payload.mileageKm != null ? payload.mileageKm : 0,
|
||||||
|
creatorName: payload.fillerName || '站端账号',
|
||||||
|
reconcileStatus: payload.reconcileStatus || 'pending'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrRemoveBridgeRow(id) {
|
||||||
|
var bridge = hrGetVehicleLedgerBridge();
|
||||||
|
if (!bridge || typeof bridge.removeRow !== 'function') return false;
|
||||||
|
return bridge.removeRow(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrIsReconciled(record) {
|
||||||
|
return record && (record.reconcileStatus === 'reconciled' || Boolean(record.reconcileTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrFormatReconcileTime(v) {
|
||||||
|
if (!v) return '—';
|
||||||
|
var s = String(v);
|
||||||
|
return s.length >= 16 ? s.slice(0, 16) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrFormatPlateDisplay(plateNo) {
|
||||||
|
return String(plateNo || '').replace(/F$/u, '') || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrRecalcTotalAmount(unitPrice, hydrogenKg) {
|
||||||
|
var p = parseFloat(unitPrice);
|
||||||
|
var k = parseFloat(hydrogenKg);
|
||||||
|
if (isNaN(p) || isNaN(k)) return '';
|
||||||
|
return String(Math.round(p * k * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
function hrCardTitleWithStep(step, text) {
|
function hrCardTitleWithStep(step, text) {
|
||||||
return React.createElement('span', { className: 'h2-card-title-bar h2-card-title-bar--step' },
|
return React.createElement('span', { className: 'h2-card-title-bar h2-card-title-bar--step' },
|
||||||
React.createElement('span', { className: 'h2-card-step-badge', 'aria-hidden': true }, step),
|
React.createElement('span', { className: 'h2-card-step-badge', 'aria-hidden': true }, step),
|
||||||
@@ -457,12 +487,11 @@ function hrCardTitleWithStep(step, text) {
|
|||||||
|
|
||||||
function hrFormDirty(form) {
|
function hrFormDirty(form) {
|
||||||
if ((form.hydrogenTime || '').trim()) return true;
|
if ((form.hydrogenTime || '').trim()) return true;
|
||||||
|
if ((form.stationName || '').trim()) return true;
|
||||||
if ((form.plateNo || '').trim()) return true;
|
if ((form.plateNo || '').trim()) return true;
|
||||||
|
if ((form.unitPrice || '').trim()) return true;
|
||||||
if ((form.hydrogenKg || '').trim()) return true;
|
if ((form.hydrogenKg || '').trim()) return true;
|
||||||
if ((form.customerAmount || '').trim()) return true;
|
if ((form.totalAmount || '').trim()) return true;
|
||||||
if ((form.mileageKm || '').trim()) return true;
|
|
||||||
if (form.fillerName) return true;
|
|
||||||
if (form.settlementStatus) return true;
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,11 +545,11 @@ const Component = function () {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
var draftFiltersState = useState({ timeRange: [], plateNo: undefined, fillerName: undefined });
|
var draftFiltersState = useState({ timeRange: [], plateNo: undefined, stationName: undefined, reconcileStatus: undefined });
|
||||||
var draftFilters = draftFiltersState[0];
|
var draftFilters = draftFiltersState[0];
|
||||||
var setDraftFilters = draftFiltersState[1];
|
var setDraftFilters = draftFiltersState[1];
|
||||||
|
|
||||||
var appliedFiltersState = useState({ timeRange: [], plateNo: undefined, fillerName: undefined });
|
var appliedFiltersState = useState({ timeRange: [], plateNo: undefined, stationName: undefined, reconcileStatus: undefined });
|
||||||
var appliedFilters = appliedFiltersState[0];
|
var appliedFilters = appliedFiltersState[0];
|
||||||
var setAppliedFilters = appliedFiltersState[1];
|
var setAppliedFilters = appliedFiltersState[1];
|
||||||
|
|
||||||
@@ -582,14 +611,19 @@ const Component = function () {
|
|||||||
});
|
});
|
||||||
}, [listData]);
|
}, [listData]);
|
||||||
|
|
||||||
var fillerOptions = useMemo(function () {
|
var stationOptions = useMemo(function () {
|
||||||
var map = {};
|
var bridge = hrGetVehicleLedgerBridge();
|
||||||
HR_MOCK_FILLERS.forEach(function (n) { map[n] = true; });
|
if (bridge && typeof bridge.getStationList === 'function') {
|
||||||
listData.forEach(function (r) {
|
return bridge.getStationList().map(function (item) {
|
||||||
if (r.fillerName) map[r.fillerName] = true;
|
return { value: item.label, label: item.label, stationCode: item.value };
|
||||||
});
|
});
|
||||||
return Object.keys(map).sort().map(function (n) {
|
}
|
||||||
return { value: n, label: n };
|
var map = {};
|
||||||
|
listData.forEach(function (r) {
|
||||||
|
if (r.stationName) map[r.stationName] = r.stationCode || '';
|
||||||
|
});
|
||||||
|
return Object.keys(map).sort().map(function (name) {
|
||||||
|
return { value: name, label: name, stationCode: map[name] };
|
||||||
});
|
});
|
||||||
}, [listData]);
|
}, [listData]);
|
||||||
|
|
||||||
@@ -630,8 +664,14 @@ const Component = function () {
|
|||||||
if (appliedFilters.plateNo) {
|
if (appliedFilters.plateNo) {
|
||||||
list = list.filter(function (r) { return r.plateNo === appliedFilters.plateNo; });
|
list = list.filter(function (r) { return r.plateNo === appliedFilters.plateNo; });
|
||||||
}
|
}
|
||||||
if (appliedFilters.fillerName) {
|
if (appliedFilters.stationName) {
|
||||||
list = list.filter(function (r) { return r.fillerName === appliedFilters.fillerName; });
|
list = list.filter(function (r) { return r.stationName === appliedFilters.stationName; });
|
||||||
|
}
|
||||||
|
if (appliedFilters.reconcileStatus) {
|
||||||
|
list = list.filter(function (r) {
|
||||||
|
var done = hrIsReconciled(r);
|
||||||
|
return appliedFilters.reconcileStatus === 'reconciled' ? done : !done;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (appliedFilters.timeRange && appliedFilters.timeRange.length === 2) {
|
if (appliedFilters.timeRange && appliedFilters.timeRange.length === 2) {
|
||||||
list = list.filter(function (r) {
|
list = list.filter(function (r) {
|
||||||
@@ -664,14 +704,14 @@ const Component = function () {
|
|||||||
setAppliedFilters({
|
setAppliedFilters({
|
||||||
timeRange: (draftFilters.timeRange || []).slice(),
|
timeRange: (draftFilters.timeRange || []).slice(),
|
||||||
plateNo: draftFilters.plateNo,
|
plateNo: draftFilters.plateNo,
|
||||||
fillerName: draftFilters.fillerName
|
stationName: draftFilters.stationName, reconcileStatus: draftFilters.reconcileStatus
|
||||||
});
|
});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setDailyStatsPage(0);
|
setDailyStatsPage(0);
|
||||||
}, [draftFilters]);
|
}, [draftFilters]);
|
||||||
|
|
||||||
var handleFilterReset = useCallback(function () {
|
var handleFilterReset = useCallback(function () {
|
||||||
var empty = { timeRange: [], plateNo: undefined, fillerName: undefined };
|
var empty = { timeRange: [], plateNo: undefined, stationName: undefined, reconcileStatus: undefined };
|
||||||
setDraftFilters(empty);
|
setDraftFilters(empty);
|
||||||
setAppliedFilters(empty);
|
setAppliedFilters(empty);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -703,12 +743,21 @@ const Component = function () {
|
|||||||
}, [form, batchRows, formMode, formSubmitting, resetFormView]);
|
}, [form, batchRows, formMode, formSubmitting, resetFormView]);
|
||||||
|
|
||||||
var openCreate = useCallback(function () {
|
var openCreate = useCallback(function () {
|
||||||
setForm(hrEmptyForm());
|
var firstStation = stationOptions[0] || {};
|
||||||
setBatchRows([hrEmptyBatchRow()]);
|
var defaults = {
|
||||||
|
stationName: firstStation.value || '',
|
||||||
|
stationCode: firstStation.stationCode || ''
|
||||||
|
};
|
||||||
|
var empty = hrEmptyForm();
|
||||||
|
empty.stationName = defaults.stationName;
|
||||||
|
empty.stationCode = defaults.stationCode;
|
||||||
|
if (dayjs) empty.hydrogenTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
setForm(empty);
|
||||||
|
setBatchRows([hrEmptyBatchRow(defaults)]);
|
||||||
setFormMode('create');
|
setFormMode('create');
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setSubView('form');
|
setSubView('form');
|
||||||
}, []);
|
}, [stationOptions, dayjs]);
|
||||||
|
|
||||||
var updateBatchRow = useCallback(function (rowId, patch) {
|
var updateBatchRow = useCallback(function (rowId, patch) {
|
||||||
setBatchRows(function (prev) {
|
setBatchRows(function (prev) {
|
||||||
@@ -731,14 +780,18 @@ const Component = function () {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
var openEdit = useCallback(function (record) {
|
var openEdit = useCallback(function (record) {
|
||||||
|
if (hrIsReconciled(record)) {
|
||||||
|
message.warning('已对账记录不可编辑');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setForm({
|
setForm({
|
||||||
hydrogenTime: record.hydrogenTime || '',
|
hydrogenTime: record.hydrogenTime || '',
|
||||||
plateNo: record.plateNo || '',
|
stationName: record.stationName || '',
|
||||||
|
stationCode: record.stationCode || '',
|
||||||
|
plateNo: (record.plateNo || '').replace(/F$/u, ''),
|
||||||
|
unitPrice: record.unitPrice != null ? String(record.unitPrice) : (record.costUnitPrice != null ? String(record.costUnitPrice) : ''),
|
||||||
hydrogenKg: record.hydrogenKg != null ? String(record.hydrogenKg) : '',
|
hydrogenKg: record.hydrogenKg != null ? String(record.hydrogenKg) : '',
|
||||||
customerAmount: record.customerAmount != null ? String(record.customerAmount) : '',
|
totalAmount: record.totalAmount != null ? String(record.totalAmount) : (record.customerAmount != null ? String(record.customerAmount) : '')
|
||||||
mileageKm: record.mileageKm != null ? String(record.mileageKm) : '',
|
|
||||||
fillerName: record.fillerName,
|
|
||||||
settlementStatus: record.settlementStatus
|
|
||||||
});
|
});
|
||||||
setFormMode('edit');
|
setFormMode('edit');
|
||||||
setEditingId(record.id);
|
setEditingId(record.id);
|
||||||
@@ -750,31 +803,27 @@ const Component = function () {
|
|||||||
message.warning('请填写加氢时间');
|
message.warning('请填写加氢时间');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!(form.stationName || '').trim()) {
|
||||||
|
message.warning('请选择加氢站');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!(form.plateNo || '').trim()) {
|
if (!(form.plateNo || '').trim()) {
|
||||||
message.warning('请填写车牌号');
|
message.warning('请填写车牌号');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
var price = parseFloat(form.unitPrice);
|
||||||
|
if (isNaN(price) || price < 0) {
|
||||||
|
message.warning('请填写有效的氢气单价');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
var kg = parseFloat(form.hydrogenKg);
|
var kg = parseFloat(form.hydrogenKg);
|
||||||
if (isNaN(kg) || kg <= 0) {
|
if (isNaN(kg) || kg <= 0) {
|
||||||
message.warning('请填写有效的加氢量(kg)');
|
message.warning('请填写有效的加氢量(kg)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
var amount = parseFloat(form.customerAmount);
|
var amount = parseFloat(form.totalAmount);
|
||||||
if (isNaN(amount) || amount < 0) {
|
if (isNaN(amount) || amount < 0) {
|
||||||
message.warning('请填写有效的加氢金额');
|
message.warning('请填写有效的加氢总额');
|
||||||
return false;
|
|
||||||
}
|
|
||||||
var km = parseFloat(form.mileageKm);
|
|
||||||
if (isNaN(km) || km < 0) {
|
|
||||||
message.warning('请填写有效的公里数');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!form.fillerName) {
|
|
||||||
message.warning('请选择充装员');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!form.settlementStatus) {
|
|
||||||
message.warning('请选择承担方式');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -784,24 +833,15 @@ const Component = function () {
|
|||||||
if (formSubmitting) return;
|
if (formSubmitting) return;
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
setFormSubmitting(true);
|
setFormSubmitting(true);
|
||||||
var payload = {
|
var payload = hrBatchRowToPayload(form);
|
||||||
hydrogenTime: (form.hydrogenTime || '').trim(),
|
payload.id = editingId || undefined;
|
||||||
plateNo: (form.plateNo || '').trim().toUpperCase(),
|
var saved = hrUpsertBridgeRow(payload);
|
||||||
hydrogenKg: Math.round(parseFloat(form.hydrogenKg) * 100) / 100,
|
if (!saved) {
|
||||||
customerAmount: Math.round(parseFloat(form.customerAmount) * 100) / 100,
|
setFormSubmitting(false);
|
||||||
mileageKm: Math.round(parseFloat(form.mileageKm)),
|
message.error('保存失败:共享账本不可用');
|
||||||
fillerName: form.fillerName,
|
return;
|
||||||
settlementStatus: form.settlementStatus
|
|
||||||
};
|
|
||||||
if (formMode === 'edit' && editingId) {
|
|
||||||
setListData(function (prev) {
|
|
||||||
return prev.map(function (r) {
|
|
||||||
if (r.id !== editingId) return r;
|
|
||||||
return Object.assign({}, r, payload);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
message.success('加氢记录已更新');
|
|
||||||
}
|
}
|
||||||
|
message.success(formMode === 'edit' ? '加氢记录已更新' : '加氢记录已新增');
|
||||||
window.setTimeout(function () {
|
window.setTimeout(function () {
|
||||||
setFormSubmitting(false);
|
setFormSubmitting(false);
|
||||||
resetFormView();
|
resetFormView();
|
||||||
@@ -832,11 +872,17 @@ const Component = function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFormSubmitting(true);
|
setFormSubmitting(true);
|
||||||
var newRecords = filledRows.map(function (row) {
|
var okCount = 0;
|
||||||
return Object.assign({ id: hrNextId() }, hrBatchRowToPayload(row));
|
filledRows.forEach(function (row) {
|
||||||
|
var payload = hrBatchRowToPayload(row);
|
||||||
|
if (hrUpsertBridgeRow(payload)) okCount += 1;
|
||||||
});
|
});
|
||||||
setListData(function (prev) { return newRecords.concat(prev); });
|
if (!okCount) {
|
||||||
message.success('已成功新增 ' + newRecords.length + ' 条加氢记录');
|
setFormSubmitting(false);
|
||||||
|
message.error('保存失败:共享账本不可用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.success('已成功新增 ' + okCount + ' 条加氢记录');
|
||||||
window.setTimeout(function () {
|
window.setTimeout(function () {
|
||||||
setFormSubmitting(false);
|
setFormSubmitting(false);
|
||||||
resetFormView();
|
resetFormView();
|
||||||
@@ -898,17 +944,18 @@ const Component = function () {
|
|||||||
message.warning('当前筛选条件下暂无数据可导出');
|
message.warning('当前筛选条件下暂无数据可导出');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var headers = ['序号', '加氢时间', '车牌号', '加氢量(kg)', '加氢金额(元)', '公里数(km)', '承担方式', '充装员'];
|
var headers = ['序号', '加氢时间', '加氢站名称', '车牌号', '氢气单价(元/kg)', '加氢量(kg)', '加氢总额(元)', '对账状态', '对账时间'];
|
||||||
var rows = filteredList.map(function (r, index) {
|
var rows = filteredList.map(function (r, index) {
|
||||||
return [
|
return [
|
||||||
index + 1,
|
index + 1,
|
||||||
r.hydrogenTime || '',
|
r.hydrogenTime || '',
|
||||||
|
r.stationName || '',
|
||||||
r.plateNo || '',
|
r.plateNo || '',
|
||||||
|
hrFormatYuan(r.unitPrice != null ? r.unitPrice : r.costUnitPrice),
|
||||||
hrFormatKg(r.hydrogenKg),
|
hrFormatKg(r.hydrogenKg),
|
||||||
hrFormatYuan(r.customerAmount),
|
hrFormatYuan(r.totalAmount != null ? r.totalAmount : r.customerAmount),
|
||||||
r.mileageKm != null ? r.mileageKm : '',
|
hrIsReconciled(r) ? '已对账' : '未对账',
|
||||||
hrSettlementStatusLabel(r.settlementStatus),
|
hrFormatReconcileTime(r.reconcileTime)
|
||||||
r.fillerName || ''
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
hrDownloadCsv('加氢记录_' + new Date().getTime() + '.csv', headers, rows);
|
hrDownloadCsv('加氢记录_' + new Date().getTime() + '.csv', headers, rows);
|
||||||
@@ -926,62 +973,76 @@ const Component = function () {
|
|||||||
return React.createElement('span', { className: 'hr-refuel-time' }, v || '—');
|
return React.createElement('span', { className: 'hr-refuel-time' }, v || '—');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '加氢站名称',
|
||||||
|
dataIndex: 'stationName',
|
||||||
|
key: 'stationName',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: function (v) { return v || '—'; }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '车牌号',
|
title: '车牌号',
|
||||||
dataIndex: 'plateNo',
|
dataIndex: 'plateNo',
|
||||||
key: 'plateNo',
|
key: 'plateNo',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: function (v) {
|
render: function (v) {
|
||||||
return React.createElement('span', { className: 'hr-refuel-plate' }, v || '—');
|
return React.createElement('span', { className: 'hr-refuel-plate' }, hrFormatPlateDisplay(v));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '氢气单价(元/kg)',
|
||||||
|
dataIndex: 'unitPrice',
|
||||||
|
key: 'unitPrice',
|
||||||
|
width: 128,
|
||||||
|
align: 'right',
|
||||||
|
render: function (v, record) {
|
||||||
|
var price = v != null ? v : record.costUnitPrice;
|
||||||
|
return React.createElement('span', { className: 'hr-refuel-money' }, hrFormatYuan(price));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '加氢量(kg)',
|
title: '加氢量(kg)',
|
||||||
dataIndex: 'hydrogenKg',
|
dataIndex: 'hydrogenKg',
|
||||||
key: 'hydrogenKg',
|
key: 'hydrogenKg',
|
||||||
width: 120,
|
width: 112,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: function (v) {
|
render: function (v) {
|
||||||
return React.createElement('span', { className: 'hr-refuel-kg' }, hrFormatKg(v));
|
return React.createElement('span', { className: 'hr-refuel-kg' }, hrFormatKg(v));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '加氢金额',
|
title: '加氢总额(元)',
|
||||||
dataIndex: 'customerAmount',
|
dataIndex: 'totalAmount',
|
||||||
key: 'customerAmount',
|
key: 'totalAmount',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: function (v) {
|
render: function (v, record) {
|
||||||
return React.createElement('span', { className: 'hr-refuel-money' }, hrFormatYuanSymbol(v));
|
var amt = v != null ? v : record.customerAmount;
|
||||||
|
return React.createElement('span', { className: 'hr-refuel-money' }, hrFormatYuanSymbol(amt));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '公里数',
|
title: '对账状态',
|
||||||
dataIndex: 'mileageKm',
|
dataIndex: 'reconcileStatus',
|
||||||
key: 'mileageKm',
|
key: 'reconcileStatus',
|
||||||
width: 120,
|
|
||||||
align: 'right',
|
|
||||||
render: function (v) {
|
|
||||||
return React.createElement('span', { className: 'hr-refuel-km' }, hrFormatKm(v));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '承担方式',
|
|
||||||
dataIndex: 'settlementStatus',
|
|
||||||
key: 'settlementStatus',
|
|
||||||
width: 120,
|
|
||||||
render: function (v) {
|
|
||||||
var info = HR_SETTLEMENT_STATUS_MAP[v];
|
|
||||||
if (!info) return '—';
|
|
||||||
return React.createElement(Tag, { color: info.color, style: { margin: 0, borderRadius: 6, fontWeight: 600 } }, info.label);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '充装员',
|
|
||||||
dataIndex: 'fillerName',
|
|
||||||
key: 'fillerName',
|
|
||||||
width: 100,
|
width: 100,
|
||||||
ellipsis: true
|
render: function (_, record) {
|
||||||
|
var done = hrIsReconciled(record);
|
||||||
|
return React.createElement(Tag, {
|
||||||
|
color: done ? 'success' : 'default',
|
||||||
|
style: { margin: 0, borderRadius: 6, fontWeight: 600 }
|
||||||
|
}, done ? '已对账' : '未对账');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '对账时间',
|
||||||
|
dataIndex: 'reconcileTime',
|
||||||
|
key: 'reconcileTime',
|
||||||
|
width: 148,
|
||||||
|
render: function (v) {
|
||||||
|
return React.createElement('span', { className: 'hr-refuel-time' }, hrFormatReconcileTime(v));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -989,9 +1050,25 @@ const Component = function () {
|
|||||||
width: 168,
|
width: 168,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: function (_, record) {
|
render: function (_, record) {
|
||||||
|
var done = hrIsReconciled(record);
|
||||||
return React.createElement(OperationActions, {
|
return React.createElement(OperationActions, {
|
||||||
edit: { onClick: function () { openEdit(record); } },
|
edit: {
|
||||||
more: [{ key: 'del', label: '删除', danger: true, onClick: function () { setDeleteModal({ open: true, record: record }); } }]
|
onClick: function () { openEdit(record); },
|
||||||
|
disabled: done
|
||||||
|
},
|
||||||
|
more: [{
|
||||||
|
key: 'del',
|
||||||
|
label: '删除',
|
||||||
|
danger: true,
|
||||||
|
disabled: done,
|
||||||
|
onClick: function () {
|
||||||
|
if (done) {
|
||||||
|
message.warning('已对账记录不可删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteModal({ open: true, record: record });
|
||||||
|
}
|
||||||
|
}]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1178,6 +1255,32 @@ const Component = function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '加氢站'),
|
||||||
|
width: 160,
|
||||||
|
render: function (_, record) {
|
||||||
|
return React.createElement(Select, {
|
||||||
|
showSearch: true,
|
||||||
|
placeholder: '加氢站',
|
||||||
|
optionFilterProp: 'label',
|
||||||
|
value: record.stationName || undefined,
|
||||||
|
options: stationOptions,
|
||||||
|
style: { width: '100%' },
|
||||||
|
onChange: function (v, opt) {
|
||||||
|
var code = (opt && opt.stationCode) || '';
|
||||||
|
if (!code && v) {
|
||||||
|
var found = stationOptions.find(function (o) { return o.value === v; });
|
||||||
|
if (found) code = found.stationCode || '';
|
||||||
|
}
|
||||||
|
updateBatchRow(record._rowId, {
|
||||||
|
stationName: v || '',
|
||||||
|
stationCode: code
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dropdownStyle: { borderRadius: 8 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '车牌号'),
|
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '车牌号'),
|
||||||
width: 130,
|
width: 130,
|
||||||
@@ -1190,6 +1293,27 @@ const Component = function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '单价(元/kg)'),
|
||||||
|
width: 120,
|
||||||
|
render: function (_, record) {
|
||||||
|
return React.createElement(InputNumber, {
|
||||||
|
style: { width: '100%' },
|
||||||
|
min: 0,
|
||||||
|
step: 0.01,
|
||||||
|
precision: 2,
|
||||||
|
placeholder: '单价',
|
||||||
|
value: record.unitPrice === '' ? null : parseFloat(record.unitPrice),
|
||||||
|
onChange: function (v) {
|
||||||
|
var unitPrice = v == null ? '' : String(v);
|
||||||
|
updateBatchRow(record._rowId, {
|
||||||
|
unitPrice: unitPrice,
|
||||||
|
totalAmount: hrRecalcTotalAmount(unitPrice, record.hydrogenKg)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '加氢量(kg)'),
|
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '加氢量(kg)'),
|
||||||
width: 110,
|
width: 110,
|
||||||
@@ -1201,12 +1325,18 @@ const Component = function () {
|
|||||||
precision: 2,
|
precision: 2,
|
||||||
placeholder: 'kg',
|
placeholder: 'kg',
|
||||||
value: record.hydrogenKg === '' ? null : parseFloat(record.hydrogenKg),
|
value: record.hydrogenKg === '' ? null : parseFloat(record.hydrogenKg),
|
||||||
onChange: function (v) { updateBatchRow(record._rowId, { hydrogenKg: v == null ? '' : String(v) }); }
|
onChange: function (v) {
|
||||||
|
var hydrogenKg = v == null ? '' : String(v);
|
||||||
|
updateBatchRow(record._rowId, {
|
||||||
|
hydrogenKg: hydrogenKg,
|
||||||
|
totalAmount: hrRecalcTotalAmount(record.unitPrice, hydrogenKg)
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '加氢金额(元)'),
|
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '加氢总额(元)'),
|
||||||
width: 120,
|
width: 120,
|
||||||
render: function (_, record) {
|
render: function (_, record) {
|
||||||
return React.createElement(InputNumber, {
|
return React.createElement(InputNumber, {
|
||||||
@@ -1214,54 +1344,9 @@ const Component = function () {
|
|||||||
min: 0,
|
min: 0,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
precision: 2,
|
precision: 2,
|
||||||
placeholder: '金额',
|
placeholder: '总额',
|
||||||
value: record.customerAmount === '' ? null : parseFloat(record.customerAmount),
|
value: record.totalAmount === '' ? null : parseFloat(record.totalAmount),
|
||||||
onChange: function (v) { updateBatchRow(record._rowId, { customerAmount: v == null ? '' : String(v) }); }
|
onChange: function (v) { updateBatchRow(record._rowId, { totalAmount: v == null ? '' : String(v) }); }
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '公里数(km)'),
|
|
||||||
width: 110,
|
|
||||||
render: function (_, record) {
|
|
||||||
return React.createElement(InputNumber, {
|
|
||||||
style: { width: '100%' },
|
|
||||||
min: 0,
|
|
||||||
step: 1,
|
|
||||||
precision: 0,
|
|
||||||
placeholder: 'km',
|
|
||||||
value: record.mileageKm === '' ? null : parseFloat(record.mileageKm),
|
|
||||||
onChange: function (v) { updateBatchRow(record._rowId, { mileageKm: v == null ? '' : String(v) }); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '承担方式'),
|
|
||||||
width: 130,
|
|
||||||
render: function (_, record) {
|
|
||||||
return React.createElement(Select, {
|
|
||||||
placeholder: '承担方式',
|
|
||||||
value: record.settlementStatus,
|
|
||||||
options: HR_SETTLEMENT_STATUS_OPTIONS,
|
|
||||||
style: { width: '100%' },
|
|
||||||
onChange: function (v) { updateBatchRow(record._rowId, { settlementStatus: v }); },
|
|
||||||
dropdownStyle: { borderRadius: 8 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '充装员'),
|
|
||||||
width: 120,
|
|
||||||
render: function (_, record) {
|
|
||||||
return React.createElement(Select, {
|
|
||||||
showSearch: true,
|
|
||||||
placeholder: '充装员',
|
|
||||||
optionFilterProp: 'label',
|
|
||||||
value: record.fillerName,
|
|
||||||
options: fillerOptions,
|
|
||||||
style: { width: '100%' },
|
|
||||||
onChange: function (v) { updateBatchRow(record._rowId, { fillerName: v }); },
|
|
||||||
dropdownStyle: { borderRadius: 8 }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1280,7 +1365,7 @@ const Component = function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [batchRows.length, dayjs, DatePicker, Input, InputNumber, Select, fillerOptions, updateBatchRow, removeBatchRow]);
|
}, [batchRows.length, dayjs, DatePicker, Input, InputNumber, Select, stationOptions, updateBatchRow, removeBatchRow]);
|
||||||
|
|
||||||
var emptyNode = React.createElement('div', { style: { padding: '40px 0', textAlign: 'center' } },
|
var emptyNode = React.createElement('div', { style: { padding: '40px 0', textAlign: 'center' } },
|
||||||
HR_ICONS.empty,
|
HR_ICONS.empty,
|
||||||
@@ -1395,7 +1480,7 @@ const Component = function () {
|
|||||||
dataSource: batchRows,
|
dataSource: batchRows,
|
||||||
pagination: false,
|
pagination: false,
|
||||||
locale: { emptyText: '请点击「添加一行」开始录入' },
|
locale: { emptyText: '请点击「添加一行」开始录入' },
|
||||||
scroll: { x: 1240 }
|
scroll: { x: 1100 }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -1470,6 +1555,30 @@ const Component = function () {
|
|||||||
onChange: function (e) { setForm(function (f) { return Object.assign({}, f, { hydrogenTime: e.target.value }); }); }
|
onChange: function (e) { setForm(function (f) { return Object.assign({}, f, { hydrogenTime: e.target.value }); }); }
|
||||||
})
|
})
|
||||||
)),
|
)),
|
||||||
|
col12(formItem('加氢站', true, React.createElement(Select, {
|
||||||
|
className: inputCls,
|
||||||
|
showSearch: true,
|
||||||
|
placeholder: '请选择加氢站',
|
||||||
|
optionFilterProp: 'label',
|
||||||
|
value: form.stationName || undefined,
|
||||||
|
options: stationOptions,
|
||||||
|
onChange: function (v, opt) {
|
||||||
|
var code = (opt && opt.stationCode) || '';
|
||||||
|
if (!code && v) {
|
||||||
|
var found = stationOptions.find(function (o) { return o.value === v; });
|
||||||
|
if (found) code = found.stationCode || '';
|
||||||
|
}
|
||||||
|
setForm(function (f) {
|
||||||
|
return Object.assign({}, f, {
|
||||||
|
stationName: v || '',
|
||||||
|
stationCode: code
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dropdownStyle: { borderRadius: 8 }
|
||||||
|
})))
|
||||||
|
),
|
||||||
|
formRow(
|
||||||
col12(formItem('车牌号', true, React.createElement(Input, {
|
col12(formItem('车牌号', true, React.createElement(Input, {
|
||||||
className: inputCls,
|
className: inputCls,
|
||||||
placeholder: '请输入车牌号',
|
placeholder: '请输入车牌号',
|
||||||
@@ -1488,6 +1597,24 @@ const Component = function () {
|
|||||||
},
|
},
|
||||||
gridBlock(
|
gridBlock(
|
||||||
formRow(
|
formRow(
|
||||||
|
col8(formItem('氢气单价(元/kg)', true, React.createElement(InputNumber, {
|
||||||
|
className: inputCls,
|
||||||
|
style: { width: '100%' },
|
||||||
|
min: 0,
|
||||||
|
step: 0.01,
|
||||||
|
precision: 2,
|
||||||
|
placeholder: '请输入单价',
|
||||||
|
value: form.unitPrice === '' ? null : parseFloat(form.unitPrice),
|
||||||
|
onChange: function (v) {
|
||||||
|
var unitPrice = v == null ? '' : String(v);
|
||||||
|
setForm(function (f) {
|
||||||
|
return Object.assign({}, f, {
|
||||||
|
unitPrice: unitPrice,
|
||||||
|
totalAmount: hrRecalcTotalAmount(unitPrice, f.hydrogenKg)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}))),
|
||||||
col8(formItem('加氢量(kg)', true, React.createElement(InputNumber, {
|
col8(formItem('加氢量(kg)', true, React.createElement(InputNumber, {
|
||||||
className: inputCls,
|
className: inputCls,
|
||||||
style: { width: '100%' },
|
style: { width: '100%' },
|
||||||
@@ -1496,56 +1623,25 @@ const Component = function () {
|
|||||||
precision: 2,
|
precision: 2,
|
||||||
placeholder: '请输入加氢量',
|
placeholder: '请输入加氢量',
|
||||||
value: form.hydrogenKg === '' ? null : parseFloat(form.hydrogenKg),
|
value: form.hydrogenKg === '' ? null : parseFloat(form.hydrogenKg),
|
||||||
onChange: function (v) { setForm(function (f) { return Object.assign({}, f, { hydrogenKg: v == null ? '' : String(v) }); }); }
|
onChange: function (v) {
|
||||||
|
var hydrogenKg = v == null ? '' : String(v);
|
||||||
|
setForm(function (f) {
|
||||||
|
return Object.assign({}, f, {
|
||||||
|
hydrogenKg: hydrogenKg,
|
||||||
|
totalAmount: hrRecalcTotalAmount(f.unitPrice, hydrogenKg)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}))),
|
}))),
|
||||||
col8(formItem('加氢金额(元)', true, React.createElement(InputNumber, {
|
col8(formItem('加氢总额(元)', true, React.createElement(InputNumber, {
|
||||||
className: inputCls,
|
className: inputCls,
|
||||||
style: { width: '100%' },
|
style: { width: '100%' },
|
||||||
min: 0,
|
min: 0,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
precision: 2,
|
precision: 2,
|
||||||
placeholder: '请输入加氢金额',
|
placeholder: '请输入加氢总额',
|
||||||
value: form.customerAmount === '' ? null : parseFloat(form.customerAmount),
|
value: form.totalAmount === '' ? null : parseFloat(form.totalAmount),
|
||||||
onChange: function (v) { setForm(function (f) { return Object.assign({}, f, { customerAmount: v == null ? '' : String(v) }); }); }
|
onChange: function (v) { setForm(function (f) { return Object.assign({}, f, { totalAmount: v == null ? '' : String(v) }); }); }
|
||||||
}))),
|
|
||||||
col8(formItem('公里数(km)', true, React.createElement(InputNumber, {
|
|
||||||
className: inputCls,
|
|
||||||
style: { width: '100%' },
|
|
||||||
min: 0,
|
|
||||||
step: 1,
|
|
||||||
precision: 0,
|
|
||||||
placeholder: '请输入公里数',
|
|
||||||
value: form.mileageKm === '' ? null : parseFloat(form.mileageKm),
|
|
||||||
onChange: function (v) { setForm(function (f) { return Object.assign({}, f, { mileageKm: v == null ? '' : String(v) }); }); }
|
|
||||||
})))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement(Card, {
|
|
||||||
className: 'h2-create-card',
|
|
||||||
id: 'hr-create-operator-card',
|
|
||||||
title: hrCardTitleWithStep(3, '操作人员'),
|
|
||||||
bordered: false
|
|
||||||
},
|
|
||||||
gridBlock(
|
|
||||||
formRow(
|
|
||||||
col12(formItem('充装员', true, React.createElement(Select, {
|
|
||||||
className: inputCls,
|
|
||||||
showSearch: true,
|
|
||||||
placeholder: '请选择充装员',
|
|
||||||
optionFilterProp: 'label',
|
|
||||||
value: form.fillerName,
|
|
||||||
options: fillerOptions,
|
|
||||||
onChange: function (v) { setForm(function (f) { return Object.assign({}, f, { fillerName: v }); }); },
|
|
||||||
dropdownStyle: { borderRadius: 8 }
|
|
||||||
}))),
|
|
||||||
col12(formItem('承担方式', true, React.createElement(Select, {
|
|
||||||
className: inputCls,
|
|
||||||
placeholder: '请选择承担方式',
|
|
||||||
value: form.settlementStatus,
|
|
||||||
options: HR_SETTLEMENT_STATUS_OPTIONS,
|
|
||||||
onChange: function (v) { setForm(function (f) { return Object.assign({}, f, { settlementStatus: v }); }); },
|
|
||||||
dropdownStyle: { borderRadius: 8 }
|
|
||||||
})))
|
})))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1602,14 +1698,26 @@ const Component = function () {
|
|||||||
style: { width: '100%' },
|
style: { width: '100%' },
|
||||||
dropdownStyle: { borderRadius: 8 }
|
dropdownStyle: { borderRadius: 8 }
|
||||||
})),
|
})),
|
||||||
renderFilterField('充装员', React.createElement(Select, {
|
renderFilterField('加氢站', React.createElement(Select, {
|
||||||
placeholder: '请选择充装员',
|
placeholder: '请选择加氢站',
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
optionFilterProp: 'label',
|
optionFilterProp: 'label',
|
||||||
value: draftFilters.fillerName,
|
value: draftFilters.stationName,
|
||||||
options: fillerOptions,
|
options: stationOptions,
|
||||||
onChange: function (v) { setDraftFilters(function (p) { return Object.assign({}, p, { fillerName: v }); }); },
|
onChange: function (v) { setDraftFilters(function (p) { return Object.assign({}, p, { stationName: v }); }); },
|
||||||
|
style: { width: '100%' },
|
||||||
|
dropdownStyle: { borderRadius: 8 }
|
||||||
|
})),
|
||||||
|
renderFilterField('对账状态', React.createElement(Select, {
|
||||||
|
placeholder: '请选择对账状态',
|
||||||
|
allowClear: true,
|
||||||
|
value: draftFilters.reconcileStatus,
|
||||||
|
options: [
|
||||||
|
{ value: 'pending', label: '未对账' },
|
||||||
|
{ value: 'reconciled', label: '已对账' }
|
||||||
|
],
|
||||||
|
onChange: function (v) { setDraftFilters(function (p) { return Object.assign({}, p, { reconcileStatus: v }); }); },
|
||||||
style: { width: '100%' },
|
style: { width: '100%' },
|
||||||
dropdownStyle: { borderRadius: 8 }
|
dropdownStyle: { borderRadius: 8 }
|
||||||
}))
|
}))
|
||||||
@@ -1708,7 +1816,7 @@ const Component = function () {
|
|||||||
columns: columns,
|
columns: columns,
|
||||||
dataSource: filteredList,
|
dataSource: filteredList,
|
||||||
size: 'middle',
|
size: 'middle',
|
||||||
scroll: { x: 1100 },
|
scroll: { x: 1280 },
|
||||||
pagination: {
|
pagination: {
|
||||||
current: page,
|
current: page,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
@@ -1749,9 +1857,16 @@ const Component = function () {
|
|||||||
onCancel: function () { setDeleteModal({ open: false, record: null }); },
|
onCancel: function () { setDeleteModal({ open: false, record: null }); },
|
||||||
onOk: function () {
|
onOk: function () {
|
||||||
if (deleteModal.record) {
|
if (deleteModal.record) {
|
||||||
setListData(function (prev) {
|
if (hrIsReconciled(deleteModal.record)) {
|
||||||
return prev.filter(function (r) { return r.id !== deleteModal.record.id; });
|
message.warning('已对账记录不可删除');
|
||||||
});
|
setDeleteModal({ open: false, record: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ok = hrRemoveBridgeRow(deleteModal.record.id);
|
||||||
|
if (!ok) {
|
||||||
|
message.error('删除失败:共享账本不可用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
message.success('已删除');
|
message.success('已删除');
|
||||||
}
|
}
|
||||||
setDeleteModal({ open: false, record: null });
|
setDeleteModal({ open: false, record: null });
|
||||||
|
|||||||
@@ -3512,8 +3512,10 @@ function h2GetAvailableStatementLedgerRows(store, stationName, startDate, endDat
|
|||||||
if (!name || isNaN(startMs) || isNaN(endMs)) return [];
|
if (!name || isNaN(startMs) || isNaN(endMs)) return [];
|
||||||
var endInclusive = endMs + 86400000 - 1;
|
var endInclusive = endMs + 86400000 - 1;
|
||||||
return (store || []).filter(function (r) {
|
return (store || []).filter(function (r) {
|
||||||
if (r.reconcileStatus !== H2_RECONCILE_RECONCILED) return false;
|
/* 站端 H5 上报多为 pending;完成对账后亦可能尚待结算 — 均纳入对账单来源 */
|
||||||
if (r.statementRecordId) return false;
|
if (r.statementRecordId) return false;
|
||||||
|
var st = r.reconcileStatus || H2_RECONCILE_PENDING;
|
||||||
|
if (st !== H2_RECONCILE_RECONCILED && st !== H2_RECONCILE_PENDING) return false;
|
||||||
if ((r.stationName || '') !== name) return false;
|
if ((r.stationName || '') !== name) return false;
|
||||||
var t = h2ParseDateTimeMs(r.hydrogenTime);
|
var t = h2ParseDateTimeMs(r.hydrogenTime);
|
||||||
if (isNaN(t)) return false;
|
if (isNaN(t)) return false;
|
||||||
@@ -6708,10 +6710,13 @@ const Component = function () {
|
|||||||
hydrogenTime: r.hydrogenTime,
|
hydrogenTime: r.hydrogenTime,
|
||||||
reconcileDate: operateTime,
|
reconcileDate: operateTime,
|
||||||
receiptDate: operateDate,
|
receiptDate: operateDate,
|
||||||
paymentStatus: H2_PAYMENT_STATUS_PAID
|
paymentStatus: H2_PAYMENT_STATUS_PAID,
|
||||||
|
statementRecordId: statementId,
|
||||||
|
reconcileStatus: H2_RECONCILE_RECONCILED
|
||||||
});
|
});
|
||||||
return Object.assign({}, r, {
|
return Object.assign({}, r, {
|
||||||
statementRecordId: statementId,
|
statementRecordId: statementId,
|
||||||
|
reconcileStatus: H2_RECONCILE_RECONCILED,
|
||||||
reconcileDate: operateTime,
|
reconcileDate: operateTime,
|
||||||
receiptDate: operateDate,
|
receiptDate: operateDate,
|
||||||
paymentStatus: H2_PAYMENT_STATUS_PAID
|
paymentStatus: H2_PAYMENT_STATUS_PAID
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 自营业务台账 · 产品需求说明(PRD)
|
# 物流业务明细 · 产品需求说明(PRD)
|
||||||
|
|
||||||
> 参照《2026年业务二部运营台账总表》物流业务台账子表,按自营业务场景重新表达;行内编辑与导入为原型 Toast 演示。
|
> 参照《2026年业务二部运营台账总表》物流业务明细子表;行内编辑与导入为原型 Toast 演示。
|
||||||
> **界面设计规范**与租赁业务台账、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md`(`vm-page ldb-page`)。
|
> **界面设计规范**与租赁业务明细、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md`(`vm-page ldb-page`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 模块名称 | 数据分析 — 自营业务台账 |
|
| 模块名称 | 台账数据 — 物流业务明细 |
|
||||||
| 目标用户 | 业务服务组操作人、主管 |
|
| 目标用户 | 业务服务组操作人、主管 |
|
||||||
| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |
|
| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |
|
||||||
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |
|
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |
|
||||||
@@ -34,18 +34,42 @@
|
|||||||
|
|
||||||
## 3. KPI 统计卡片(6 张)
|
## 3. KPI 统计卡片(6 张)
|
||||||
|
|
||||||
台账条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。
|
明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。
|
||||||
|
|
||||||
|
卡片样式对齐租赁业务明细:左侧色条、图标、悬浮说明、盈亏正负着色。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 列表工具栏
|
## 4. 列表工具栏
|
||||||
|
|
||||||
批量导入、导出、批量删除、添加行。
|
| 按钮 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 批量导入 | 下载模板(23 列手工录入)→ 上传 Excel → 系统车型/金额/总成本/盈亏自动计算 |
|
||||||
|
| 导出 | 导出当前筛选结果(原型演示) |
|
||||||
|
| 添加行 | 表格底栏新增行(原型演示) |
|
||||||
|
|
||||||
|
不支持批量删除与行多选。
|
||||||
|
|
||||||
|
### 导入模板列(23 列)
|
||||||
|
|
||||||
|
年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、是否多趟、线路计价、备注。
|
||||||
|
|
||||||
|
**必填**:年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、薪资、是否多趟、线路计价。
|
||||||
|
|
||||||
|
**不含**(导入后自动计算/反写):系统车型、金额、总成本、盈亏。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 列表字段(核心列)
|
## 5. 列表字段
|
||||||
|
|
||||||
月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。
|
月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。
|
||||||
|
|
||||||
盈亏 = 金额 − 总成本(样例数据已预计算)。
|
**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。
|
||||||
|
|
||||||
|
**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。
|
||||||
|
|
||||||
|
**金额** = 单价 × 数量。
|
||||||
|
|
||||||
|
**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。
|
||||||
|
|
||||||
|
**盈亏** = 金额 − 总成本;参照 A 股配色:**正数红色、负数绿色**。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "self-operated-business-ledger",
|
"prototypeName": "self-operated-business-ledger",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1783800844968,
|
"updatedAt": 1783910257820,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "sob-filter",
|
"id": "sob-filter",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"color": "#2563eb",
|
"color": "#2563eb",
|
||||||
"images": [],
|
"images": [],
|
||||||
"createdAt": 1740787200000,
|
"createdAt": 1740787200000,
|
||||||
"updatedAt": 1740787200000
|
"updatedAt": 1783910257820
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sob-kpi",
|
"id": "sob-kpi",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"color": "#64748b",
|
"color": "#64748b",
|
||||||
"images": [],
|
"images": [],
|
||||||
"createdAt": 1740787200000,
|
"createdAt": 1740787200000,
|
||||||
"updatedAt": 1740787200000
|
"updatedAt": 1783910257820
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sob-toolbar",
|
"id": "sob-toolbar",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"color": "#0f766e",
|
"color": "#0f766e",
|
||||||
"images": [],
|
"images": [],
|
||||||
"createdAt": 1740787200000,
|
"createdAt": 1740787200000,
|
||||||
"updatedAt": 1740787200000
|
"updatedAt": 1783910257820
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sob-table",
|
"id": "sob-table",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"color": "#2563eb",
|
"color": "#2563eb",
|
||||||
"images": [],
|
"images": [],
|
||||||
"createdAt": 1740787200000,
|
"createdAt": 1740787200000,
|
||||||
"updatedAt": 1740787200000
|
"updatedAt": 1783910257820
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "sob-doc-prd",
|
"id": "sob-doc-prd",
|
||||||
"title": "PRD 全文",
|
"title": "PRD 全文",
|
||||||
"markdown": "# 物流业务明细 · 产品需求说明(PRD)\n\n> 参照《2026年业务二部运营台账总表》物流业务明细子表;行内编辑与导入为原型 Toast 演示。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 物流业务明细 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n\n---\n\n## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---\n\n## 3. KPI 统计卡片(6 张)\n\n明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n---\n\n## 4. 列表工具栏\n\n批量导入、导出、批量删除、添加行。\n\n---\n\n## 5. 列表字段(核心列)\n\n月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。\n\n盈亏 = 金额 − 总成本(样例数据已预计算)。\n"
|
"markdown": "# 物流业务明细 · 产品需求说明(PRD)\n\n> 参照《2026年业务二部运营台账总表》物流业务明细子表;行内编辑与导入为原型 Toast 演示。 \n> **界面设计规范**与租赁业务明细、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md`(`vm-page ldb-page`)。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 物流业务明细 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n\n---\n\n## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---\n\n## 3. KPI 统计卡片(6 张)\n\n明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n卡片样式对齐租赁业务明细:左侧色条、图标、悬浮说明、盈亏正负着色。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(23 列手工录入)→ 上传 Excel → 系统车型/金额/总成本/盈亏自动计算 |\n| 导出 | 导出当前筛选结果(原型演示) |\n| 添加行 | 表格底栏新增行(原型演示) |\n\n不支持批量删除与行多选。\n\n### 导入模板列(23 列)\n\n年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、是否多趟、线路计价、备注。\n\n**必填**:年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、薪资、是否多趟、线路计价。\n\n**不含**(导入后自动计算/反写):系统车型、金额、总成本、盈亏。\n\n---\n\n## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 − 总成本;参照 A 股配色:**正数红色、负数绿色**。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
@@ -133,19 +133,19 @@
|
|||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "sob-doc-sob-kpi",
|
"id": "sob-doc-sob-kpi",
|
||||||
"title": "KPI 统计",
|
"title": "KPI 统计",
|
||||||
"markdown": "## 3. KPI 统计卡片(6 张)\n\n明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n卡片样式对齐租赁业务明细。\n\n---"
|
"markdown": "## 3. KPI 统计卡片(6 张)\n\n明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n卡片样式对齐租赁业务明细:左侧色条、图标、悬浮说明、盈亏正负着色。\n\n---"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "sob-doc-sob-toolbar",
|
"id": "sob-doc-sob-toolbar",
|
||||||
"title": "列表工具栏",
|
"title": "列表工具栏",
|
||||||
"markdown": "## 4. 列表工具栏\n\n批量导入、导出、批量删除、添加行。\n\n---"
|
"markdown": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(23 列手工录入)→ 上传 Excel → 系统车型/金额/总成本/盈亏自动计算 |\n| 导出 | 导出当前筛选结果(原型演示) |\n| 添加行 | 表格底栏新增行(原型演示) |\n\n不支持批量删除与行多选。\n\n### 导入模板列(23 列)\n\n年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、是否多趟、线路计价、备注。\n\n**必填**:年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、薪资、是否多趟、线路计价。\n\n**不含**(导入后自动计算/反写):系统车型、金额、总成本、盈亏。\n\n---"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"id": "sob-doc-sob-table",
|
"id": "sob-doc-sob-table",
|
||||||
"title": "物流业务明细宽表",
|
"title": "物流业务明细宽表",
|
||||||
"markdown": "## 5. 列表字段(核心列)\n\n月份、出车日期、业务名称、司机、电话、车辆承接方、车牌、品牌、型号、单价、数量、金额、氢费、ETC、总成本、盈亏、维护人、操作。\n\n盈亏 = 金额 − 总成本(样例数据已预计算)。"
|
"markdown": "## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 − 总成本;参照 A 股配色:**正数红色、负数绿色**。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CircleHelp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CalcFormulaTipProps {
|
||||||
|
tip: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalcFormulaTip({ tip, label }: CalcFormulaTipProps) {
|
||||||
|
return (
|
||||||
|
<span className="sob-calc-tip" tabIndex={0} aria-label={label}>
|
||||||
|
<CircleHelp size={14} aria-hidden />
|
||||||
|
<span className="sob-kpi-tooltip sob-calc-tooltip" role="tooltip">
|
||||||
|
{tip}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { ChevronDown, Search } from 'lucide-react';
|
|
||||||
import type { SelfOperatedFilters, SelfOperatedLedgerRow } from '../types';
|
import type { SelfOperatedFilters, SelfOperatedLedgerRow } from '../types';
|
||||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||||
import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField';
|
import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField';
|
||||||
import { MultiSelectField } from '../../customer-management/components/MultiSelectField';
|
import { PlateMultiSelectField } from './PlateMultiSelectField';
|
||||||
import { buildMonthFilterOptions, buildSelfOptions, buildSystemVehicleTypeOptions } from '../utils/ledger';
|
import { buildMonthFilterOptions, buildSelfOptions, buildSystemVehicleTypeOptions } from '../utils/ledger';
|
||||||
|
|
||||||
const DEFAULT_VISIBLE = 4;
|
const DEFAULT_VISIBLE = 4;
|
||||||
@@ -40,7 +39,7 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ label: '业务名称', control: <FilterPickerField value={filters.businessName} options={businessOptions} placeholder="请选择业务名称" ariaLabel="业务名称" onChange={(businessName) => onChange({ businessName })} /> },
|
{ label: '业务名称', control: <FilterPickerField value={filters.businessName} options={businessOptions} placeholder="请选择业务名称" ariaLabel="业务名称" onChange={(businessName) => onChange({ businessName })} /> },
|
||||||
{ label: '车牌号码', control: <MultiSelectField values={filters.plateNos} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNos) => onChange({ plateNos })} /> },
|
{ label: '车牌号码', control: <PlateMultiSelectField values={filters.plateNos} options={plateOptions} placeholder="输入车牌模糊搜索" ariaLabel="车牌号码" onChange={(plateNos) => onChange({ plateNos })} /> },
|
||||||
{
|
{
|
||||||
label: '系统车型',
|
label: '系统车型',
|
||||||
control: (
|
control: (
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ChevronDown, X } from 'lucide-react';
|
||||||
|
import { useDismissOnFocusOutside } from '../../vehicle-management/components/useDismissOnFocusOutside';
|
||||||
|
|
||||||
|
interface PlateMultiSelectFieldProps {
|
||||||
|
values: string[];
|
||||||
|
options: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
onChange: (values: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlate(text: string): string {
|
||||||
|
return text.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlateMultiSelectField({
|
||||||
|
values,
|
||||||
|
options,
|
||||||
|
placeholder = '全部车牌',
|
||||||
|
ariaLabel,
|
||||||
|
onChange,
|
||||||
|
}: PlateMultiSelectFieldProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const fieldRef = useRef<HTMLDivElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useDismissOnFocusOutside(open, [fieldRef, popoverRef], dismiss);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (values.length === 0) return '';
|
||||||
|
if (values.length <= 2) return values.join('、');
|
||||||
|
return `已选 ${values.length} 个车牌`;
|
||||||
|
}, [values]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = normalizePlate(query);
|
||||||
|
if (!q) return options;
|
||||||
|
return options.filter((plate) => normalizePlate(plate).includes(q));
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
const toggle = (plate: string) => {
|
||||||
|
if (values.includes(plate)) {
|
||||||
|
onChange(values.filter((item) => item !== plate));
|
||||||
|
} else {
|
||||||
|
onChange([...values, plate]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
setOpen(true);
|
||||||
|
setQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePicker = () => {
|
||||||
|
if (open) {
|
||||||
|
dismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openPicker();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onChange([]);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(true);
|
||||||
|
window.requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`vm-filter-picker-field sob-plate-multi-field ${open ? 'is-open' : ''}`} ref={fieldRef}>
|
||||||
|
<div className={`vm-filter-picker-control ${open ? 'open' : ''}`}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="vm-filter-picker-input"
|
||||||
|
value={open ? query : summary}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
role="combobox"
|
||||||
|
onFocus={openPicker}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') dismiss();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{values.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-filter-picker-clear"
|
||||||
|
onClick={clearAll}
|
||||||
|
aria-label={`清空${ariaLabel}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<X size={14} aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-filter-picker-chevron-btn"
|
||||||
|
aria-label={`${open ? '收起' : '展开'}${ariaLabel}列表`}
|
||||||
|
aria-expanded={open}
|
||||||
|
tabIndex={-1}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={togglePicker}
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} aria-hidden className={`vm-filter-picker-chevron ${open ? 'is-open' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="vm-filter-picker-popover sob-plate-multi-popover"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div className="sob-plate-multi-options">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="vm-filter-picker-empty">暂无匹配车牌</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((plate) => (
|
||||||
|
<label key={plate} className="cm-multi-option sob-plate-multi-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="vm-checkbox"
|
||||||
|
checked={values.includes(plate)}
|
||||||
|
onChange={() => toggle(plate)}
|
||||||
|
/>
|
||||||
|
<span>{plate}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
displayText,
|
displayText,
|
||||||
formatDisplayMonth,
|
formatDisplayMonth,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
formatQuantity,
|
||||||
profitLossClassName,
|
profitLossClassName,
|
||||||
} from '../utils/ledger';
|
} from '../utils/ledger';
|
||||||
import { resolveSystemVehicleType } from '../utils/vehicle-ref';
|
import { resolveSystemVehicleType } from '../utils/vehicle-ref';
|
||||||
|
import { CalcFormulaTip } from './CalcFormulaTip';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SELF_COLUMN_WIDTHS,
|
DEFAULT_SELF_COLUMN_WIDTHS,
|
||||||
SELF_STICKY_LEFT_KEYS,
|
SELF_STICKY_LEFT_KEYS,
|
||||||
@@ -93,7 +95,14 @@ function renderCell(row: SelfOperatedLedgerRow, col: SelfTableColumn) {
|
|||||||
return resolveSystemVehicleType(row.plateNo) || displayText(row.systemVehicleType);
|
return resolveSystemVehicleType(row.plateNo) || displayText(row.systemVehicleType);
|
||||||
}
|
}
|
||||||
if (col.key === 'amount') {
|
if (col.key === 'amount') {
|
||||||
return formatMoney(computeAmount(row));
|
const amount = computeAmount(row);
|
||||||
|
const tip = `金额 = 单价 × 数量\n${formatMoney(row.unitPrice)} × ${formatQuantity(row.quantity)} = ${formatMoney(amount)}`;
|
||||||
|
return (
|
||||||
|
<span className="sob-calc-cell">
|
||||||
|
<span className="tabular-nums">{formatMoney(amount)}</span>
|
||||||
|
<CalcFormulaTip tip={tip} label="金额计算方式" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (col.key === 'totalCost') {
|
if (col.key === 'totalCost') {
|
||||||
return formatMoney(computeTotalCost(row));
|
return formatMoney(computeTotalCost(row));
|
||||||
@@ -107,8 +116,10 @@ function renderCell(row: SelfOperatedLedgerRow, col: SelfTableColumn) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const raw = row[col.key as keyof SelfOperatedLedgerRow];
|
const raw = row[col.key as keyof SelfOperatedLedgerRow];
|
||||||
|
if (col.key === 'quantity' && typeof raw === 'number') {
|
||||||
|
return formatQuantity(raw);
|
||||||
|
}
|
||||||
if (col.money && typeof raw === 'number') {
|
if (col.money && typeof raw === 'number') {
|
||||||
if (col.key === 'quantity') return raw === 0 ? '' : String(raw);
|
|
||||||
return formatMoney(raw);
|
return formatMoney(raw);
|
||||||
}
|
}
|
||||||
return displayText(raw);
|
return displayText(raw);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user