Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a9f303ec | ||
|
|
06fe75b4c7 | ||
|
|
d610e4b841 | ||
|
|
7fce6b8474 | ||
|
|
bd0627d00f |
@@ -1,51 +0,0 @@
|
||||
# Core asset database
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
|
||||
# Hydrogen ledger database
|
||||
HYDROGEN_DB_HOST=
|
||||
HYDROGEN_DB_PORT=3306
|
||||
HYDROGEN_DB_USER=
|
||||
HYDROGEN_DB_PASSWORD=
|
||||
HYDROGEN_DB_NAME=
|
||||
|
||||
# Mileage reference database
|
||||
MILEAGE_DB_HOST=
|
||||
MILEAGE_DB_PORT=3306
|
||||
MILEAGE_DB_USER=
|
||||
MILEAGE_DB_PASSWORD=
|
||||
MILEAGE_DB_NAME=
|
||||
|
||||
# OneOS mileage API
|
||||
ONEOS_MILEAGE_API_BASE_URL=http://172.17.111.55:20310
|
||||
ONEOS_MILEAGE_API_KEY=
|
||||
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
|
||||
|
||||
# Authentication and external API
|
||||
EXTERNAL_API_BASE=https://lnh2e.com
|
||||
JWT_SECRET=
|
||||
DEV_BYPASS_AUTH=0
|
||||
|
||||
# Vehicle heatmap database and map SDK
|
||||
HEATMAP_DB_HOST=
|
||||
HEATMAP_DB_PORT=36453
|
||||
HEATMAP_DB_USER=
|
||||
HEATMAP_DB_PASSWORD=
|
||||
HEATMAP_DB_NAME=lingniu_vehicle_data
|
||||
HEATMAP_DB_SSL=false
|
||||
AMAP_WEB_KEY=
|
||||
AMAP_SECURITY_JS_CODE=
|
||||
|
||||
# Feedback attachment object storage
|
||||
OSS_ENDPOINT=
|
||||
OSS_REGION=oss-cn-shanghai
|
||||
OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
OSS_BUCKET=
|
||||
OSS_BASE_DIR=/dos
|
||||
OSS_HOST=
|
||||
|
||||
SERVER_PORT=3001
|
||||
+26
-31
@@ -5,40 +5,35 @@ services:
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||
network_mode: host
|
||||
environment:
|
||||
DB_HOST: "${DB_HOST:?Set DB_HOST in the deployment environment}"
|
||||
DB_PORT: "${DB_PORT:-3306}"
|
||||
DB_USER: "${DB_USER:?Set DB_USER in the deployment environment}"
|
||||
DB_PASSWORD: "${DB_PASSWORD:?Set DB_PASSWORD in the deployment environment}"
|
||||
DB_NAME: "${DB_NAME:?Set DB_NAME in the deployment environment}"
|
||||
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:?Set HYDROGEN_DB_HOST in the deployment environment}"
|
||||
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
|
||||
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:?Set HYDROGEN_DB_USER in the deployment environment}"
|
||||
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:?Set HYDROGEN_DB_PASSWORD in the deployment environment}"
|
||||
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:?Set HYDROGEN_DB_NAME in the deployment environment}"
|
||||
MILEAGE_DB_HOST: "${MILEAGE_DB_HOST:?Set MILEAGE_DB_HOST in the deployment environment}"
|
||||
MILEAGE_DB_PORT: "${MILEAGE_DB_PORT:-3306}"
|
||||
MILEAGE_DB_USER: "${MILEAGE_DB_USER:?Set MILEAGE_DB_USER in the deployment environment}"
|
||||
MILEAGE_DB_PASSWORD: "${MILEAGE_DB_PASSWORD:?Set MILEAGE_DB_PASSWORD in the deployment environment}"
|
||||
MILEAGE_DB_NAME: "${MILEAGE_DB_NAME:?Set MILEAGE_DB_NAME in the deployment environment}"
|
||||
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "oneos_db_prod"
|
||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||
DB_NAME: "ln_asset_management"
|
||||
HYDROGEN_DB_HOST: "47.99.185.173"
|
||||
HYDROGEN_DB_PORT: "3306"
|
||||
HYDROGEN_DB_USER: "root"
|
||||
HYDROGEN_DB_PASSWORD: "lnMysql."
|
||||
HYDROGEN_DB_NAME: "ln_asset_management"
|
||||
MILEAGE_DB_HOST: "101.133.130.65"
|
||||
MILEAGE_DB_PORT: "3306"
|
||||
MILEAGE_DB_USER: "bi_reader_02"
|
||||
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
||||
MILEAGE_DB_NAME: "hydrogen_energy"
|
||||
# ECS production accesses OneOS over the private network. Configure the key
|
||||
# as a Portainer stack environment variable; never commit it to this file.
|
||||
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
||||
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY:?Set ONEOS_MILEAGE_API_KEY in the deployment environment}"
|
||||
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
|
||||
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||
SERVER_PORT: "${SERVER_PORT:-8111}"
|
||||
EXTERNAL_API_BASE: "${EXTERNAL_API_BASE:-https://lnh2e.com}"
|
||||
JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}"
|
||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY:?Set AMAP_WEB_KEY in the deployment environment}"
|
||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE:?Set AMAP_SECURITY_JS_CODE in the deployment environment}"
|
||||
OSS_ENDPOINT: "${OSS_ENDPOINT:?Set OSS_ENDPOINT in the deployment environment}"
|
||||
OSS_REGION: "${OSS_REGION:-oss-cn-shanghai}"
|
||||
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID:?Set OSS_ACCESS_KEY_ID in the deployment environment}"
|
||||
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET:?Set OSS_ACCESS_KEY_SECRET in the deployment environment}"
|
||||
OSS_BUCKET: "${OSS_BUCKET:?Set OSS_BUCKET in the deployment environment}"
|
||||
OSS_BASE_DIR: "${OSS_BASE_DIR:-/dos}"
|
||||
OSS_HOST: "${OSS_HOST:-}"
|
||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST:?Set HEATMAP_DB_HOST in the deployment environment}"
|
||||
SERVER_PORT: "8111"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
|
||||
HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}"
|
||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD:?Set HEATMAP_DB_PASSWORD in the deployment environment}"
|
||||
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
|
||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||
deploy:
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
# BI 渐进式重构方案
|
||||
|
||||
更新日期:2026-08-07
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本次重构不重做整套系统,采用可回滚的小批次交付,逐步解决三个核心问题:
|
||||
|
||||
1. UI/UX:统一信息层级、筛选方式、状态反馈和移动端行为。
|
||||
2. 下钻:任何汇总指标都能沿同一统计口径进入可核对的明细。
|
||||
3. 统计逻辑:指标公式、时间语义、数据来源和数据截至时间均可查询、可测试。
|
||||
|
||||
不在当前阶段引入新的 BI 引擎或替换现有 React、Hono、MySQL 技术栈。
|
||||
|
||||
## 2. 设计原则
|
||||
|
||||
- 先口径,后图表:没有明确公式和数据源的指标不得进入主看板。
|
||||
- 父子一致:下钻前后的时间、车辆范围、组织范围和数据源必须一致。
|
||||
- URL 即分析状态:日期、范围、实体和数据源写入 URL,支持刷新、返回和分享。
|
||||
- 空值不等于零值:接口失败、尚未接入、没有权限和真实零业务使用不同状态。
|
||||
- 汇总可对账:页面汇总应等于明细全量聚合,明细截断不能影响顶部合计。
|
||||
- 数据新鲜度显式展示:业务时间与页面刷新时间分开表达。
|
||||
- 渐进发布:每批只修改一个口径或一条下钻链路,通过测试和真实数据核对后提交。
|
||||
|
||||
## 3. 目标信息架构
|
||||
|
||||
每个 BI 模块统一为三级信息结构:
|
||||
|
||||
| 层级 | 用途 | 页面内容 |
|
||||
| --- | --- | --- |
|
||||
| 总览 | 判断经营状态 | 核心 KPI、趋势、结构、异常、数据截至时间 |
|
||||
| 分析 | 定位变化来源 | 时间筛选、组织/车辆范围、分组排行、归因 |
|
||||
| 明细 | 核对原始业务 | 车辆、订单、站点、账单或通行记录 |
|
||||
|
||||
页面头部统一提供模块名称、简短业务说明和“指标口径”入口。筛选条件位于内容上方,实体下钻状态紧邻筛选区展示,避免用户忘记当前分析范围。
|
||||
|
||||
## 4. 模块与下钻矩阵
|
||||
|
||||
### 4.1 里程
|
||||
|
||||
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 实时监控汇总 | 车辆详情 | 日期区间、协议优先级、车牌 | 已完成 |
|
||||
| 考核目标 | 部门/客户归因 | 考核年份、目标、归因维度 | 已完成 |
|
||||
| 归因分组 | 车辆清单 | 目标、部门或客户值 | 已完成 |
|
||||
| 每日汇报 | 实时监控/考核目标 | 汇报日期、目标或车牌 | 已完成 |
|
||||
|
||||
考核完成率使用目标里程加权:
|
||||
|
||||
```text
|
||||
SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100
|
||||
```
|
||||
|
||||
禁止直接平均车辆完成率或考核组完成率。
|
||||
|
||||
### 4.2 氢能
|
||||
|
||||
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 站点排行 | 站点每日明细 | 年份、站点、日期区间、车辆范围 | 已完成 |
|
||||
| 客户排行 | 客户每日明细 | 年份、客户、日期区间、车辆范围 | 已完成 |
|
||||
| 每日趋势 | 加氢订单 | 日期、车辆范围、站点或客户 | 下钻代码已完成,待数据源恢复后验收 |
|
||||
|
||||
成本、收入和毛利必须分开:
|
||||
|
||||
```text
|
||||
成本 = SUM(cost_total)
|
||||
客户收入 = SUM(fee_total)
|
||||
客户单毛利 = 客户收入 - 客户单对应成本
|
||||
```
|
||||
|
||||
数据库不可用时返回可识别的 503 状态,页面不得显示派生零值。
|
||||
|
||||
### 4.3 电能
|
||||
|
||||
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 总览日柱 | 当日订单 | 日期、全部车辆范围 | 已完成 |
|
||||
| 月份 | 日期 | 车辆范围、日期区间 | 已完成 |
|
||||
| 日期 | 充电订单 | 日期、车辆范围 | 已完成 |
|
||||
|
||||
电能范围包括“全部车辆、羚牛车辆、外部车辆”。总览使用全部车辆口径,因此总览下钻必须进入 `all`,不能默认切换为羚牛车辆。
|
||||
|
||||
综合费用强度使用加权公式:
|
||||
|
||||
```text
|
||||
SUM(fee) / NULLIF(SUM(kwh), 0)
|
||||
```
|
||||
|
||||
零费用订单参与电量和订单数统计。当前月无数据时可以展示最近数据月,但所有月度标签必须显示实际月份,并展示 `MAX(start_time)` 数据截至时间。
|
||||
|
||||
### 4.4 ETC
|
||||
|
||||
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 通行费用 | 通行记录 | 日期、车牌、客户、费用承担类型 | 下钻代码已完成,待首次真实同步验收 |
|
||||
| 账单应收 | ETC 账单 | 账期、客户、支付状态 | 下钻代码已完成,待首次真实同步验收 |
|
||||
|
||||
供应商未配置或从未同步时展示接入状态,不使用模拟业务数据填充图表。
|
||||
|
||||
## 5. 指标治理
|
||||
|
||||
指标目录由 `/api/analytics/metrics` 发布,当前采用版本化合同。每个指标必须包含:
|
||||
|
||||
- 唯一且带领域前缀的指标 ID。
|
||||
- 中文名称和业务解释。
|
||||
- 单位、聚合方式和时间语义。
|
||||
- 可审计公式与真实数据源。
|
||||
- 支持的筛选维度和下钻实体。
|
||||
|
||||
指标变更规则:
|
||||
|
||||
1. 修改公式、来源或时间语义时提升目录版本。
|
||||
2. 后端查询、指标目录、页面标签和测试在同一批次修改。
|
||||
3. 比率类指标必须由分子、分母汇总后计算,禁止平均子级比率。
|
||||
4. 金额同时标明成本、收入、应收、实收或总费用,不使用模糊的“费用”。
|
||||
5. 快照、区间流量和数据新鲜度不得混在同一时间标签下。
|
||||
|
||||
## 6. URL 与交互约定
|
||||
|
||||
- 模块和子页使用 hash,例如 `#electric/overview`。
|
||||
- 分析上下文使用领域前缀查询参数,例如 `electricDate`、`hydrogenStationId`、`mileagePlate`。
|
||||
- 不删除其他领域的查询参数。
|
||||
- 总览进入明细使用 `pushState`,同页筛选调整使用 `replaceState`。
|
||||
- 浏览器返回、前进和刷新必须恢复筛选与展开状态。
|
||||
- 图表下钻同时支持鼠标和键盘,交互图形提供可读名称。
|
||||
|
||||
## 7. 接口与性能边界
|
||||
|
||||
| 类型 | 目标 |
|
||||
| --- | --- |
|
||||
| 热缓存汇总接口 | P95 小于 500 ms |
|
||||
| 普通数据库聚合 | P95 小于 1.5 s |
|
||||
| 明细查询 | 首屏小于 2 s,超过上限时分页或截断 |
|
||||
| 并发同参 GET | 前端和服务端均只执行一次 |
|
||||
| 外部数据源失败 | 返回结构化 503,不返回连接信息 |
|
||||
|
||||
查询要求:
|
||||
|
||||
- 日期条件使用范围比较,避免对索引列套 `DATE()` 或 `DATE_FORMAT()` 后再过滤。
|
||||
- 汇总与明细可以并行查询,但必须使用相同过滤条件。
|
||||
- 明细展示上限与全量汇总分离,截断时明确提示。
|
||||
- 缓存键必须包含所有影响统计结果的筛选维度。
|
||||
- 失败请求不得驻留在并发请求缓存中。
|
||||
|
||||
## 8. 分阶段路线
|
||||
|
||||
### 阶段 A:口径与导航基线
|
||||
|
||||
- 已完成版本化指标目录和四模块入口。
|
||||
- 已完成里程、电能、氢能主要 URL 状态。
|
||||
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
|
||||
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
|
||||
- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。
|
||||
- 电能与氢能可根据 URL 日期恢复快捷区间选中态;过期区间自动归为自定义,避免标签与统计日期不一致。
|
||||
- 已统一电能和氢能车辆范围 segmented control,并区分前端车辆归属状态与后端 `customer` 查询参数语义。
|
||||
- 已完成并发同参 GET 去重。
|
||||
|
||||
### 阶段 B:核心下钻闭环
|
||||
|
||||
- 已完成里程考核归因、车辆详情和每日汇报跳转。
|
||||
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
|
||||
- 车辆级里程详情继承 `mileageStart`、`mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
|
||||
- 里程实时监控的日期、来源、批次、区域、车牌、归属、高级筛选和排序已纳入独立 URL 状态;分享或刷新链接可恢复完整查询,车辆详情前进后退保留列表上下文且不把下钻车牌误写为筛选车牌。
|
||||
- 已完成氢能站点、客户下钻代码。
|
||||
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
||||
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
|
||||
- 已完成电能全部车辆、日期和订单下钻。
|
||||
- 电能每日列表打开新日期订单使用浏览器历史 `pushState`,返回/前进可恢复进入前后的分析状态;筛选调整仍使用 `replaceState`。
|
||||
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
|
||||
|
||||
### 阶段 C:数据可信度
|
||||
|
||||
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
||||
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
||||
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
|
||||
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
|
||||
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
|
||||
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
|
||||
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
|
||||
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
|
||||
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
|
||||
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
|
||||
- 已完成电能数据截至时间和实际趋势月展示。
|
||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||
- 已完成氢能结构化故障状态与重试体验。
|
||||
- 已完成里程今日仪表快照复用:监控查询和考核车辆当前日明细命中同一分钟级快照,手动刷新生成一致的分页快照;历史日仍使用独立日期缓存。
|
||||
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
|
||||
- 待恢复氢能数据库后进行总览、站点、客户三层对账。
|
||||
- 待 ETC 同步后进行通行记录、账单和收款三层对账。
|
||||
|
||||
### 阶段 D:部署与安全
|
||||
|
||||
- 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。
|
||||
- 轮换已经进入版本历史的氢能数据库凭据。
|
||||
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
|
||||
- 已拆分进程存活检查与无敏感值的配置就绪检查。
|
||||
- 已增加基于真实业务请求的数据源最后结果、最后成功时间和滚动失败率监控。
|
||||
|
||||
## 9. 每批验收门槛
|
||||
|
||||
每次提交至少满足:
|
||||
|
||||
1. TypeScript 检查通过。
|
||||
2. 受影响的口径、URL 或查询测试通过。
|
||||
3. 生产前端构建通过,服务端入口可加载。
|
||||
4. `git diff --check` 通过。
|
||||
5. 桌面和移动端检查无溢出、遮挡或不可操作控件。
|
||||
6. 使用真实接口核对至少一个父级汇总与子级明细。
|
||||
7. 不提交本地数据、临时脚本、凭据或无关工作区修改。
|
||||
|
||||
## 10. 当前外部依赖
|
||||
|
||||
- 氢能数据库连接尚未恢复,真实数据对账未完成。
|
||||
- ETC 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
|
||||
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
|
||||
@@ -0,0 +1,97 @@
|
||||
# LN-BI 渐进式重构方案
|
||||
|
||||
## 目标
|
||||
|
||||
本轮只调整代码结构,不改变业务功能。以下内容视为冻结契约:
|
||||
|
||||
- 页面文案、布局、交互和 URL 行为
|
||||
- API 路径、请求参数、状态码和响应字段
|
||||
- SQL、数据源优先级、权限与脱敏顺序
|
||||
- 里程、资产、能源和调度的统计口径
|
||||
- 缓存刷新频率与日报归档时间
|
||||
|
||||
## 当前结构
|
||||
|
||||
项目已经按业务模块组织,但部分文件仍承担了过多职责:
|
||||
|
||||
| 热点 | 规模 | 主要职责混合 |
|
||||
| --- | ---: | --- |
|
||||
| `modules/assets/AssetsModule.tsx` | 3240 行 | 请求、53 组状态、筛选、图表、弹窗、导出和四个视图 |
|
||||
| `modules/mileage/MonitoringView.tsx` | 1611 行 | 查询参数、44 组状态、列表、全屏、导出和统计 |
|
||||
| `server/routes/vehicles.ts` | 1426 行 | SQL、缓存、权限、领域映射、聚合和 15 个接口 |
|
||||
| `modules/mileage/DailyReportView.tsx` | 1192 行 | 日期、领域计算、图表、表格和页面编排 |
|
||||
|
||||
目标结构遵循同一方向:
|
||||
|
||||
```text
|
||||
route / page
|
||||
-> controller / hook # 流程与状态
|
||||
-> service / model # 业务规则与纯计算
|
||||
-> repository / api # 数据访问
|
||||
```
|
||||
|
||||
React 组件负责展示和交互编排;纯函数负责排序、筛选、分组和口径;服务端路由只解析请求并组织响应;数据库查询集中在 repository 层。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. **建立回归门禁**:类型检查、领域测试和生产构建必须同时通过。
|
||||
2. **提取纯模型**:先移动无副作用的日期、映射、筛选、排序和聚合逻辑,并补特征测试。
|
||||
3. **拆分前端组件**:日期选择器、表格、展开详情等独立,页面保留状态和组合。
|
||||
4. **拆分后端边界**:服务器应用创建与进程启动分离;大型路由按 model、service、repository、routes 拆分。
|
||||
5. **清理重复与废弃代码**:只有在引用和行为测试都证明无影响后执行。
|
||||
|
||||
每一批只处理一个边界,不把现存 Bug 修复、新功能或视觉调整混入重构提交。
|
||||
|
||||
## 模块迭代路线
|
||||
|
||||
| 批次 | 模块 | 主要边界 | 风险控制 |
|
||||
| --- | --- | --- | --- |
|
||||
| 第一轮 | 应用入口、服务启动、日报、资产模型、监控查询、车辆模型 | 路由装配、启动副作用、纯计算 | 特征测试锁定路由、日期、口径和请求参数 |
|
||||
| 第二轮 | 资产页、里程监控、车辆接口、统计报表 | 展示组件、数据访问、弹层和图表 | 保持 DOM、SQL、缓存覆盖和请求时机不变 |
|
||||
| 第三轮 | 氢能展示、能源查询路由 | 图表展示、日期模型、数据库查询 | 固定金额/重量单位、客户范围和日期边界 |
|
||||
| 第四轮 | 智能调度 | 筛选模型、建议详情、通知历史 | 写操作与只读派生分开,通知接口单独回归 |
|
||||
| 第五轮 | OneOS 接入、里程缓存 | 外部 API、协议降级、缓存与合并 | 使用响应夹具验证降级顺序、TTL 和数据源优先级 |
|
||||
|
||||
高风险模块不会与普通页面组件放在同一批:能源路由依赖两套数据库,调度包含通知写入,OneOS 层还包含协议兼容、超时和缓存淘汰。拆分这些模块前必须先建立可重复的输入夹具。
|
||||
|
||||
## 当前进度
|
||||
|
||||
截至 2026-08-13,前五轮可独立验证的边界已经完成:
|
||||
|
||||
- 应用路由配置与服务器启动副作用已经从入口文件分离。
|
||||
- 资产、里程监控、每日汇报、统计报表和智能调度已改为“页面协调层 + 展示组件 + 纯模型”。
|
||||
- 车辆接口已改为“路由 + repository + model”,原 SQL、缓存范围和接口顺序由特征测试锁定。
|
||||
- 能源接口已按氢能总览、氢能每日、电能总览和电能每日拆分,入口只保留权限守卫与装配。
|
||||
- 氢能与电能每日页共用自然日范围模型和筛选控件;金额、重量、环比及 30% 波动边界已有测试。
|
||||
- 反馈入口和电能导入页已收敛为“副作用协调层 + 无状态展示组件”;上传、筛选和请求时机仍由主组件负责。
|
||||
- OneOS 响应归一化、连续日期分组和请求键已提取为纯模型;协议别名、去重和区间边界由固定样例锁定。
|
||||
- 里程缓存中的车辆合并、筛选项、考核映射和自然日工具已与刷新流程分离;数据源分类和累计里程空值规则保持不变。
|
||||
- 调度通知的数据库行映射、请求校验和更新字段生成已有契约测试;数据库写入顺序和单车有效干预约束仍留在路由层。
|
||||
- 车辆与加氢热力图已把参数解析、距离计算、网格/排名和序列化提取为纯模型;原 SQL 及参数顺序由契约测试锁定。
|
||||
- CI 已把类型检查、全量测试和生产构建设为同一发布门禁。
|
||||
|
||||
下一批继续只处理能够独立回归的边界:
|
||||
|
||||
1. 继续缩小 `AssetsModule.tsx` 的状态协调职责,但不改请求、筛选、导出和弹窗时序。
|
||||
2. 拆分车辆详情、调度建议详情和通知历史中的纯展示区块,主组件继续持有写操作。
|
||||
3. 为电能导入服务端路由补 SQL 与去重契约后,再拆解析、repository 和路由装配。
|
||||
4. OneOS 网络重试、协议降级和缓存 TTL 暂不移动;只有在可注入请求层覆盖 400 降级和重试后再处理。
|
||||
|
||||
## 注释约定
|
||||
|
||||
注释解释“为什么”,不复述语句本身。优先说明:
|
||||
|
||||
- 统计边界和历史口径
|
||||
- 时区、日期范围和特殊状态映射
|
||||
- 外部 API 的兼容处理
|
||||
- 看似可合并但必须保持差异的流程
|
||||
|
||||
命名和类型能够表达清楚的代码不额外添加注释。
|
||||
|
||||
## 已识别但不在本轮修复的问题
|
||||
|
||||
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
|
||||
- 监控页不同入口的请求参数目前不完全一致。
|
||||
- 数据库连接配置与部署凭据需要独立安全治理。
|
||||
|
||||
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.13",
|
||||
"version": "1.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.13",
|
||||
"version": "1.1.14",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.13",
|
||||
"version": "1.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
@@ -9,7 +9,9 @@
|
||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"start": "node --import tsx src/server/index.ts",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
|
||||
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../src/server/db.js';
|
||||
import type {
|
||||
DailyMileageReport,
|
||||
MileageReportTrendPoint,
|
||||
} from '../src/shared/mileage/daily-report.js';
|
||||
import { fetchOneOsMileageDates } from '../src/server/routes/mileage/oneos-api.js';
|
||||
import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from '../src/server/routes/mileage/source-policy.js';
|
||||
import { roundMileage } from '../src/server/routes/mileage/daily-report-model.js';
|
||||
import { updateDailyReportSnapshotPayload } from '../src/server/routes/mileage/daily-report-store.js';
|
||||
|
||||
interface SnapshotRow extends RowDataPacket {
|
||||
report_date: string;
|
||||
source: string;
|
||||
report_payload: DailyMileageReport | string;
|
||||
}
|
||||
|
||||
function reportDates(reportDate: string): string[] {
|
||||
const end = new Date(`${reportDate}T00:00:00Z`);
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(end);
|
||||
date.setUTCDate(end.getUTCDate() - (6 - index));
|
||||
return date.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function parseReport(value: DailyMileageReport | string): DailyMileageReport {
|
||||
return typeof value === 'string' ? JSON.parse(value) as DailyMileageReport : value;
|
||||
}
|
||||
|
||||
async function trendByPlate(report: DailyMileageReport): Promise<Map<string, MileageReportTrendPoint[]>> {
|
||||
const dates = reportDates(report.reportDate);
|
||||
const plates = report.groups.flatMap(group => group.vehicles.map(vehicle => vehicle.plate));
|
||||
const rowsByDate = await fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY);
|
||||
const result = new Map<string, MileageReportTrendPoint[]>();
|
||||
for (const plate of plates) {
|
||||
result.set(plate, dates.map(date => {
|
||||
const mileage = (rowsByDate.get(date) || [])
|
||||
.filter(row => row.status === 'NORMAL' && row.plateNumber === plate)
|
||||
.reduce((maximum, row) => Math.max(maximum, Number(row.dailyMileageKm) || 0), 0);
|
||||
return { date, totalMileage: roundMileage(Math.max(0, mileage)) };
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const apply = process.argv.includes('--apply');
|
||||
const [rows] = await pool.query<SnapshotRow[]>(`
|
||||
SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, source, report_payload
|
||||
FROM lingniu_prod.tab_mileage_daily_report
|
||||
WHERE source <> 'XLSX_IMPORT'
|
||||
ORDER BY report_date
|
||||
`);
|
||||
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const report = parseReport(row.report_payload);
|
||||
if (report.groups.every(group => group.vehicles.every(vehicle => vehicle.trend?.length === 7))) {
|
||||
console.log(`${row.report_date}: already complete`);
|
||||
continue;
|
||||
}
|
||||
const trends = await trendByPlate(report);
|
||||
const updated: DailyMileageReport = {
|
||||
...report,
|
||||
groups: report.groups.map(group => ({
|
||||
...group,
|
||||
vehicles: group.vehicles.map(vehicle => ({
|
||||
...vehicle,
|
||||
trend: trends.get(vehicle.plate) || [],
|
||||
})),
|
||||
})),
|
||||
};
|
||||
if (apply) await updateDailyReportSnapshotPayload(updated);
|
||||
console.log(`${row.report_date}: ${apply ? 'updated' : 'would update'} ${trends.size} vehicles`);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import XLSX from 'xlsx';
|
||||
import type { WorkBook } from 'xlsx';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../src/server/db.js';
|
||||
import type {
|
||||
MileageReportBreakdown,
|
||||
MileageReportGroup,
|
||||
MileageReportVehicle,
|
||||
} from '../src/shared/mileage/daily-report.js';
|
||||
import {
|
||||
buildDailyMileageReport,
|
||||
mileageBand,
|
||||
roundMileage,
|
||||
} from '../src/server/routes/mileage/daily-report-model.js';
|
||||
import { saveDailyReportSnapshot } from '../src/server/routes/mileage/daily-report-store.js';
|
||||
|
||||
interface TargetRow extends RowDataPacket { id: number; target_name: string }
|
||||
|
||||
interface ImportSpec {
|
||||
date: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
const SHEET_SPECS = [
|
||||
{ sheet: '40台普货', targetMatch: '交投40', summaryRow: 2, displayName: '40台普货' },
|
||||
{ sheet: '190台冷藏车', targetMatch: '交投190', summaryRow: 3, displayName: '190台冷链' },
|
||||
{ sheet: '136台冷藏车', targetMatch: '羚牛136', summaryRow: 4, displayName: '136台冷链' },
|
||||
{ sheet: '100台双飞翼', targetMatch: '羚牛100', summaryRow: 5, displayName: '100台18T双飞翼' },
|
||||
{ sheet: '50台普货', targetMatch: '恒运', summaryRow: 6, displayName: '26台恒运普货' },
|
||||
] as const;
|
||||
|
||||
const INVENTORY_REGIONS: Record<string, Record<string, Record<string, number>>> = {
|
||||
'2026-03-30': {
|
||||
'40台普货': { 广东: 2 },
|
||||
'190台冷链': { 嘉兴: 2, 广东: 38 },
|
||||
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 31 },
|
||||
'100台18T双飞翼': { 嘉兴: 9, 广东: 29 },
|
||||
'26台恒运普货': { 广东: 11, 嘉兴: 13 },
|
||||
},
|
||||
'2026-03-31': {
|
||||
'40台普货': { 广东: 2 },
|
||||
'190台冷链': { 嘉兴: 2, 广东: 36 },
|
||||
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 32 },
|
||||
'100台18T双飞翼': { 嘉兴: 8, 广东: 29 },
|
||||
'26台恒运普货': { 广东: 11, 嘉兴: 12 },
|
||||
},
|
||||
};
|
||||
|
||||
function parseArgs(values: string[]): ImportSpec[] {
|
||||
return values.map(value => {
|
||||
const split = value.indexOf(':');
|
||||
if (split < 10) throw new Error(`参数格式应为 YYYY-MM-DD:/path/file.xlsx,收到 ${value}`);
|
||||
const date = value.slice(0, split);
|
||||
const file = value.slice(split + 1);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !file) throw new Error(`参数不合法:${value}`);
|
||||
return { date, file };
|
||||
});
|
||||
}
|
||||
|
||||
function valueNumber(value: unknown): number {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
function sheetRows(workbook: WorkBook, name: string): unknown[][] {
|
||||
const sheet = workbook.Sheets[name];
|
||||
if (!sheet) throw new Error(`工作表不存在:${name}`);
|
||||
return XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: null });
|
||||
}
|
||||
|
||||
function trendDate(reportDate: string, label: unknown): string {
|
||||
const match = /^(\d{1,2})\.(\d{1,2})日/.exec(String(label || ''));
|
||||
if (!match) throw new Error(`无法识别趋势日期:${String(label)}`);
|
||||
let year = Number(reportDate.slice(0, 4));
|
||||
const candidate = `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
|
||||
if (candidate > reportDate) year -= 1;
|
||||
return `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function breakdown(rows: MileageReportVehicle[], key: 'department'): MileageReportBreakdown[] {
|
||||
const grouped = new Map<string, MileageReportVehicle[]>();
|
||||
for (const row of rows) {
|
||||
const name = row[key] || '未标注';
|
||||
const items = grouped.get(name) || [];
|
||||
items.push(row);
|
||||
grouped.set(name, items);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, items]) => ({
|
||||
name,
|
||||
vehicleCount: items.length,
|
||||
totalMileage: roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0)),
|
||||
averageMileage: items.length > 0
|
||||
? roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0) / items.length)
|
||||
: 0,
|
||||
})).sort((a, b) => b.vehicleCount - a.vehicleCount);
|
||||
}
|
||||
|
||||
function inventoryBreakdown(date: string, groupName: string): MileageReportBreakdown[] {
|
||||
return Object.entries(INVENTORY_REGIONS[date]?.[groupName] || {}).map(([name, count]) => ({
|
||||
name,
|
||||
vehicleCount: count,
|
||||
totalMileage: 0,
|
||||
averageMileage: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function generatedAt(date: string): string {
|
||||
const next = new Date(`${date}T00:00:00Z`);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
return `${next.toISOString().slice(0, 10)}T09:30:00+08:00`;
|
||||
}
|
||||
|
||||
async function importWorkbook(spec: ImportSpec, targets: TargetRow[]): Promise<void> {
|
||||
const workbook = XLSX.readFile(spec.file, { cellDates: true });
|
||||
const summary = sheetRows(workbook, '汇总');
|
||||
const groups: MileageReportGroup[] = SHEET_SPECS.map(sheetSpec => {
|
||||
const rows = sheetRows(workbook, sheetSpec.sheet);
|
||||
const headerIndex = rows.findIndex(row => row[0] === '序号');
|
||||
if (headerIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到明细表头`);
|
||||
const header = rows[headerIndex];
|
||||
const currentDayIndex = header.findIndex(value => String(value || '').includes(`${Number(spec.date.slice(5, 7))}.${Number(spec.date.slice(8, 10))}日行驶里程`));
|
||||
if (currentDayIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到 ${spec.date} 日里程列`);
|
||||
const trendStartIndex = currentDayIndex - 6;
|
||||
const trendDates = header.slice(trendStartIndex, currentDayIndex + 1).map(label => trendDate(spec.date, label));
|
||||
const detailRows = rows.slice(headerIndex + 1).filter(row => typeof row[0] === 'number');
|
||||
const vehicles: MileageReportVehicle[] = detailRows.map(row => {
|
||||
const rawStatus = String(row[1] || '在库');
|
||||
const dailyMileage = roundMileage(valueNumber(row[currentDayIndex]));
|
||||
const status = rawStatus === '租赁' || rawStatus === '自营' ? rawStatus : '在库';
|
||||
return {
|
||||
plate: String(row[2] || ''),
|
||||
status,
|
||||
customer: row[3] ? String(row[3]) : null,
|
||||
department: status === '在库' || !row[4] ? null : String(row[4]),
|
||||
inventoryRegion: null,
|
||||
dailyMileage,
|
||||
cumulativeMileage: roundMileage(valueNumber(row[13])),
|
||||
completionRate: Math.round(valueNumber(row[17]) * 10000) / 100,
|
||||
mileageBand: mileageBand(status, dailyMileage),
|
||||
trend: trendDates.map((date, index) => ({
|
||||
date,
|
||||
totalMileage: roundMileage(valueNumber(row[trendStartIndex + index])),
|
||||
})),
|
||||
};
|
||||
});
|
||||
const operating = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY');
|
||||
const inventory = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY');
|
||||
const summaryRow = summary[sheetSpec.summaryRow];
|
||||
const target = targets.find(item => item.target_name.includes(sheetSpec.targetMatch));
|
||||
if (!target) throw new Error(`未找到考核目标:${sheetSpec.targetMatch}`);
|
||||
const periods = String(summaryRow[5] || '').split(/\n+|\s{2,}/).map(value => value.trim()).filter(Boolean);
|
||||
const trend = trendDates.map((date, index) => ({
|
||||
date,
|
||||
totalMileage: roundMileage(detailRows.reduce((total, row) => total + valueNumber(row[trendStartIndex + index]), 0)),
|
||||
}));
|
||||
const dailyMileage = roundMileage(vehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0));
|
||||
const yesterdayMileage = trend[trend.length - 2]?.totalMileage || 0;
|
||||
const currentYearTarget = roundMileage(valueNumber(summaryRow[10]));
|
||||
const currentYearCompleted = roundMileage(valueNumber(summaryRow[11]));
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.target_name,
|
||||
displayName: sheetSpec.displayName,
|
||||
vehicleCount: vehicles.length,
|
||||
operatingCount: operating.length,
|
||||
inventoryCount: inventory.length,
|
||||
dailyMileage,
|
||||
operatingMileage: roundMileage(operating.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
|
||||
inventoryMileage: roundMileage(inventory.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
|
||||
yesterdayMileage,
|
||||
dayOverDayDelta: 0,
|
||||
dayOverDayRate: null,
|
||||
lowMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'LOW').length,
|
||||
highMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'HIGH').length,
|
||||
cumulativeMileage: roundMileage(valueNumber(summaryRow[3])),
|
||||
totalMileageTarget: roundMileage(valueNumber(summaryRow[2])),
|
||||
completionRate: Math.round(valueNumber(summaryRow[4]) * 10000) / 100,
|
||||
qualifiedCount: valueNumber(summaryRow[7]),
|
||||
halfQualifiedCount: valueNumber(summaryRow[8]),
|
||||
currentYearTarget,
|
||||
currentYearCompleted,
|
||||
remainingMileage: roundMileage(valueNumber(summaryRow[12])),
|
||||
dailyRequiredMileage: roundMileage(valueNumber(summaryRow[14])),
|
||||
assessmentPeriods: periods,
|
||||
trend,
|
||||
inventoryRegions: inventoryBreakdown(spec.date, sheetSpec.displayName),
|
||||
departments: breakdown(operating, 'department'),
|
||||
vehicles: vehicles.sort((a, b) => b.dailyMileage - a.dailyMileage),
|
||||
};
|
||||
});
|
||||
|
||||
const qualityNotes = [
|
||||
{ level: 'info' as const, message: '历史日报由已审核人工台账导入,车辆规模与当日台账保持一致。' },
|
||||
];
|
||||
if (spec.date === '2026-03-30') {
|
||||
qualityNotes.push({ level: 'info', message: '136台冷链库存按车辆明细修正为45台;100km车辆按低里程口径统计。' });
|
||||
}
|
||||
qualityNotes.push({ level: 'info', message: '190台冷链累计里程采用190辆明细求和及汇总表值,忽略明细页失效的手工合计单元格。' });
|
||||
|
||||
const report = buildDailyMileageReport({
|
||||
reportDate: spec.date,
|
||||
status: 'ARCHIVED',
|
||||
source: 'XLSX_IMPORT',
|
||||
generatedAt: generatedAt(spec.date),
|
||||
sourceUpdatedAt: null,
|
||||
groups,
|
||||
qualityNotes,
|
||||
});
|
||||
const inserted = await saveDailyReportSnapshot(report, 'XLSX_IMPORT', { replace });
|
||||
console.log(`${spec.date}: ${inserted ? (replace ? 'replaced' : 'imported') : 'already exists'}; ${report.totals.vehicleCount} vehicles; ${report.totals.dailyMileage} km`);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const replace = args.includes('--replace');
|
||||
const specs = parseArgs(args.filter(value => value !== '--replace'));
|
||||
if (specs.length === 0) throw new Error('请提供至少一个 YYYY-MM-DD:/path/file.xlsx 参数');
|
||||
const [targets] = await pool.query<TargetRow[]>(
|
||||
'SELECT id, target_name FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
|
||||
);
|
||||
try {
|
||||
for (const spec of specs) await importWorkbook(spec, targets);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
+15
-141
@@ -1,142 +1,29 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
|
||||
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||
import { Shell } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||
import { canAccessEnergy } from "./shared/auth/roles";
|
||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||
import { buildModules } from "./app/modules";
|
||||
import {
|
||||
normalizeBrowserPath,
|
||||
readBrowserRoute,
|
||||
} from "./app/routing";
|
||||
|
||||
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
|
||||
const MileageModule = lazy(() => import("./modules/mileage/MileageModule"));
|
||||
const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule"));
|
||||
const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule"));
|
||||
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
|
||||
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
|
||||
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
|
||||
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
|
||||
|
||||
const ASSETS_MODULE: ModuleConfig = {
|
||||
id: "assets",
|
||||
label: "资产管理",
|
||||
icon: Truck,
|
||||
component: AssetsModule,
|
||||
};
|
||||
|
||||
const MILEAGE_MODULE: ModuleConfig = {
|
||||
id: "mileage",
|
||||
label: "里程管理",
|
||||
icon: Route,
|
||||
component: MileageModule,
|
||||
};
|
||||
|
||||
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "vehicle-heatmap",
|
||||
label: "车辆",
|
||||
icon: MapPinned,
|
||||
component: VehicleHeatmapModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: "scheduling",
|
||||
label: "智能调度",
|
||||
icon: Activity,
|
||||
component: SchedulingModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_MODULE: ModuleConfig = {
|
||||
id: "hydrogen",
|
||||
label: "氢能",
|
||||
icon: Fuel,
|
||||
component: HydrogenModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "hydrogen-heatmap",
|
||||
label: "加氢",
|
||||
icon: MapPinned,
|
||||
component: HydrogenHeatmapModule,
|
||||
};
|
||||
|
||||
const ELECTRIC_MODULE: ModuleConfig = {
|
||||
id: "electric",
|
||||
label: "电能",
|
||||
icon: BatteryCharging,
|
||||
component: ElectricModule,
|
||||
};
|
||||
|
||||
const ETC_MODULE: ModuleConfig = {
|
||||
id: "etc",
|
||||
label: "ETC",
|
||||
icon: Receipt,
|
||||
component: EtcModule,
|
||||
};
|
||||
|
||||
/**
|
||||
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
|
||||
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
|
||||
*/
|
||||
function normalizePath() {
|
||||
if (typeof window === "undefined") return;
|
||||
const { pathname, hash, search } = window.location;
|
||||
|
||||
// 主路径 & 隐藏后台页保持不变
|
||||
if (pathname === "/asset" || pathname === "/energy") return;
|
||||
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
|
||||
|
||||
const legacyMap: Record<string, { path: string; hash?: string }> = {
|
||||
"/": { path: "/asset" },
|
||||
"/vehicle": { path: "/asset", hash: "assets" },
|
||||
"/assets": { path: "/asset", hash: "assets" },
|
||||
"/mileage": { path: "/asset", hash: "mileage" },
|
||||
"/scheduling": { path: "/asset", hash: "scheduling" },
|
||||
};
|
||||
|
||||
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
|
||||
const target = legacyMap[pathname] ?? { path: "/asset" };
|
||||
const finalHash = target.hash ? `#${target.hash}` : hash || "";
|
||||
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
|
||||
}
|
||||
|
||||
normalizePath();
|
||||
|
||||
type PathSet = "asset" | "energy";
|
||||
|
||||
function getPathSet(): PathSet {
|
||||
return window.location.pathname === "/energy" ? "energy" : "asset";
|
||||
}
|
||||
|
||||
function getRouteKey(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
if (
|
||||
path === "/ele/import" ||
|
||||
hash === "#/ele/import" ||
|
||||
hash === "#ele/import"
|
||||
)
|
||||
return "ele/import";
|
||||
if (
|
||||
path === "/admin/feedback" ||
|
||||
hash === "#/admin/feedback" ||
|
||||
hash === "#admin/feedback"
|
||||
)
|
||||
return "admin/feedback";
|
||||
return "";
|
||||
}
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||
const [route, setRoute] = useState(readBrowserRoute);
|
||||
const { routeKey, pathSet } = route;
|
||||
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setRouteKey(getRouteKey());
|
||||
setPathSet(getPathSet());
|
||||
setRoute(readBrowserRoute());
|
||||
};
|
||||
window.addEventListener("hashchange", update);
|
||||
window.addEventListener("popstate", update);
|
||||
@@ -150,23 +37,10 @@ function AuthGate() {
|
||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet]);
|
||||
|
||||
const modules = useMemo<ModuleConfig[]>(() => {
|
||||
if (pathSet === "energy") {
|
||||
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||
}
|
||||
const heatmapChildren = canAccessEnergy(user?.roles)
|
||||
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
|
||||
: [VEHICLE_HEATMAP_MODULE];
|
||||
const heatmapModule: ModuleConfig = {
|
||||
id: "heatmap",
|
||||
label: "热力图",
|
||||
icon: MapPinned,
|
||||
children: heatmapChildren,
|
||||
};
|
||||
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [pathSet, user?.roles]);
|
||||
const modules = useMemo(
|
||||
() => buildModules(pathSet, user?.roles),
|
||||
[pathSet, user?.roles],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildModules } from './modules.js';
|
||||
|
||||
function moduleIds(roles?: string[]): string[] {
|
||||
return buildModules('asset', roles).map(module => module.id);
|
||||
}
|
||||
|
||||
function heatmapChildIds(roles?: string[]): string[] {
|
||||
const heatmap = buildModules('asset', roles).find(module => module.id === 'heatmap');
|
||||
return heatmap?.children?.map(module => module.id) ?? [];
|
||||
}
|
||||
|
||||
test('keeps the base asset navigation and role-gated scheduling module', () => {
|
||||
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
|
||||
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
|
||||
'assets',
|
||||
'mileage',
|
||||
'heatmap',
|
||||
'scheduling',
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps energy heatmap visibility and the independent energy navigation', () => {
|
||||
assert.deepEqual(heatmapChildIds(), ['vehicle-heatmap']);
|
||||
assert.deepEqual(heatmapChildIds(['BI-LEADER-ENERGY']), [
|
||||
'hydrogen-heatmap',
|
||||
'vehicle-heatmap',
|
||||
]);
|
||||
assert.deepEqual(heatmapChildIds(['所有权限']), [
|
||||
'hydrogen-heatmap',
|
||||
'vehicle-heatmap',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
buildModules('energy', []).map(module => module.id),
|
||||
['hydrogen', 'electric', 'etc'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { lazy } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BatteryCharging,
|
||||
Fuel,
|
||||
MapPinned,
|
||||
Receipt,
|
||||
Route,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import type { ModuleConfig } from '../components/Shell';
|
||||
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
|
||||
import type { PathSet } from './routing';
|
||||
|
||||
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
|
||||
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
|
||||
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
|
||||
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
|
||||
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
|
||||
const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
|
||||
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
|
||||
const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/HydrogenHeatmapModule'));
|
||||
|
||||
const ASSET_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: 'vehicle-heatmap',
|
||||
label: '车辆',
|
||||
icon: MapPinned,
|
||||
component: VehicleHeatmapModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: 'hydrogen-heatmap',
|
||||
label: '加氢',
|
||||
icon: MapPinned,
|
||||
component: HydrogenHeatmapModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: 'scheduling',
|
||||
label: '智能调度',
|
||||
icon: Activity,
|
||||
component: SchedulingModule,
|
||||
};
|
||||
|
||||
const ENERGY_MODULES: ModuleConfig[] = [
|
||||
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
|
||||
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
|
||||
];
|
||||
|
||||
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
|
||||
export function buildModules(
|
||||
pathSet: PathSet,
|
||||
roles: readonly string[] | null | undefined,
|
||||
): ModuleConfig[] {
|
||||
if (pathSet === 'energy') return [...ENERGY_MODULES];
|
||||
|
||||
const heatmapChildren = canAccessEnergy(roles)
|
||||
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
|
||||
: [VEHICLE_HEATMAP_MODULE];
|
||||
const modules: ModuleConfig[] = [
|
||||
...ASSET_MODULES,
|
||||
{ id: 'heatmap', label: '热力图', icon: MapPinned, children: heatmapChildren },
|
||||
];
|
||||
if (canAccessScheduling(roles)) modules.push(SCHEDULING_MODULE);
|
||||
return modules;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getPathSet, getRouteKey, resolveCanonicalUrl } from './routing.js';
|
||||
|
||||
test('normalizes legacy asset paths without losing search parameters', () => {
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/mileage', search: '?date=2026-08-12', hash: '' }), '/asset?date=2026-08-12#mileage');
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/unknown', search: '?x=1', hash: '#mileage' }), '/asset?x=1#mileage');
|
||||
});
|
||||
test('keeps canonical and hidden administration paths unchanged', () => {
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
|
||||
});
|
||||
|
||||
test('derives path groups and hidden routes from path or hash', () => {
|
||||
assert.equal(getPathSet('/energy'), 'energy');
|
||||
assert.equal(getPathSet('/asset'), 'asset');
|
||||
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export type PathSet = 'asset' | 'energy';
|
||||
|
||||
interface BrowserLocation {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
|
||||
|
||||
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
|
||||
'/': { path: '/asset' },
|
||||
'/vehicle': { path: '/asset', hash: 'assets' },
|
||||
'/assets': { path: '/asset', hash: 'assets' },
|
||||
'/mileage': { path: '/asset', hash: 'mileage' },
|
||||
'/scheduling': { path: '/asset', hash: 'scheduling' },
|
||||
};
|
||||
|
||||
/** 返回旧地址应替换成的规范地址;主路径返回 null。 */
|
||||
export function resolveCanonicalUrl(location: BrowserLocation): string | null {
|
||||
if (ROOT_PATHS.has(location.pathname)) return null;
|
||||
|
||||
const target = LEGACY_PATHS[location.pathname] ?? { path: '/asset' };
|
||||
const finalHash = target.hash ? `#${target.hash}` : location.hash;
|
||||
return `${target.path}${location.search}${finalHash}`;
|
||||
}
|
||||
|
||||
export function normalizeBrowserPath(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const target = resolveCanonicalUrl(window.location);
|
||||
if (target) window.history.replaceState(null, '', target);
|
||||
}
|
||||
|
||||
export function getPathSet(pathname: string): PathSet {
|
||||
return pathname === '/energy' ? 'energy' : 'asset';
|
||||
}
|
||||
|
||||
export function getRouteKey(pathname: string, hash: string): string {
|
||||
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||
return 'ele/import';
|
||||
}
|
||||
if (
|
||||
pathname === '/admin/feedback'
|
||||
|| hash === '#/admin/feedback'
|
||||
|| hash === '#admin/feedback'
|
||||
) {
|
||||
return 'admin/feedback';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function readBrowserRoute(): { routeKey: string; pathSet: PathSet } {
|
||||
return {
|
||||
routeKey: getRouteKey(window.location.pathname, window.location.hash),
|
||||
pathSet: getPathSet(window.location.pathname),
|
||||
};
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
ApiRequestError,
|
||||
_clearApiClientInflightForTests,
|
||||
fetchJson,
|
||||
setTokenGetter,
|
||||
} from './api-client.js';
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
test('deduplicates concurrent GET requests for the same URL and token', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
let resolveFetch: ((response: Response) => void) | undefined;
|
||||
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
|
||||
calls += 1;
|
||||
assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer test-token');
|
||||
return new Promise<Response>(resolve => { resolveFetch = resolve; });
|
||||
}) as typeof fetch;
|
||||
setTokenGetter(() => 'test-token');
|
||||
_clearApiClientInflightForTests();
|
||||
|
||||
try {
|
||||
const first = fetchJson<{ value: number }>('/api/test');
|
||||
const second = fetchJson<{ value: number }>('/api/test');
|
||||
assert.equal(calls, 1);
|
||||
resolveFetch?.(jsonResponse({ value: 42 }));
|
||||
assert.deepEqual(await first, { value: 42 });
|
||||
assert.deepEqual(await second, { value: 42 });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
setTokenGetter(() => null);
|
||||
_clearApiClientInflightForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not retain a failed GET request', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('network down');
|
||||
return jsonResponse({ ok: true });
|
||||
}) as typeof fetch;
|
||||
setTokenGetter(() => null);
|
||||
_clearApiClientInflightForTests();
|
||||
|
||||
try {
|
||||
await assert.rejects(fetchJson('/api/retry'), /network down/);
|
||||
assert.deepEqual(await fetchJson('/api/retry'), { ok: true });
|
||||
assert.equal(calls, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
_clearApiClientInflightForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('surfaces structured API errors without retaining the failed request', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
calls += 1;
|
||||
if (calls > 1) return jsonResponse({ ok: true });
|
||||
return new Response(JSON.stringify({
|
||||
error: '当前无法读取氢能业务数据,请稍后重试',
|
||||
code: 'HYDROGEN_SOURCE_UNAVAILABLE',
|
||||
retryable: true,
|
||||
}), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
_clearApiClientInflightForTests();
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
fetchJson('/api/energy/hydrogen/overview'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiRequestError);
|
||||
assert.equal(error.message, '当前无法读取氢能业务数据,请稍后重试');
|
||||
assert.equal(error.status, 503);
|
||||
assert.equal(error.code, 'HYDROGEN_SOURCE_UNAVAILABLE');
|
||||
assert.equal(error.retryable, true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await fetchJson('/api/energy/hydrogen/overview'), { ok: true });
|
||||
assert.equal(calls, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
_clearApiClientInflightForTests();
|
||||
}
|
||||
});
|
||||
+3
-62
@@ -1,34 +1,13 @@
|
||||
/** 全局认证 fetch 客户端 */
|
||||
|
||||
let tokenGetter: () => string | null = () => null;
|
||||
const inflightGetRequests = new Map<string, Promise<unknown>>();
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | null;
|
||||
readonly retryable: boolean;
|
||||
|
||||
constructor(message: string, status: number, code: string | null, retryable: boolean) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
code?: unknown;
|
||||
retryable?: unknown;
|
||||
}
|
||||
|
||||
export function setTokenGetter(fn: () => string | null) {
|
||||
tokenGetter = fn;
|
||||
}
|
||||
|
||||
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> {
|
||||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const token = tokenGetter();
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -40,44 +19,6 @@ async function executeJsonRequest<T>(url: string, options: RequestInit | undefin
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let payload: ApiErrorPayload = {};
|
||||
try {
|
||||
const parsed: unknown = await res.json();
|
||||
if (typeof parsed === 'object' && parsed !== null) payload = parsed as ApiErrorPayload;
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
const message = typeof payload?.error === 'string'
|
||||
? payload.error
|
||||
: typeof payload?.message === 'string'
|
||||
? payload.message
|
||||
: `请求失败(${res.status})`;
|
||||
const code = typeof payload?.code === 'string' ? payload.code : null;
|
||||
const retryable = typeof payload?.retryable === 'boolean' ? payload.retryable : res.status >= 500;
|
||||
throw new ApiRequestError(message, res.status, code, retryable);
|
||||
}
|
||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const token = tokenGetter();
|
||||
const method = (options?.method || 'GET').toUpperCase();
|
||||
const canDedupe = method === 'GET' && options?.body == null && options?.signal == null;
|
||||
if (!canDedupe) return executeJsonRequest<T>(url, options, token);
|
||||
|
||||
const key = `${token || 'anonymous'}:${url}`;
|
||||
const existing = inflightGetRequests.get(key) as Promise<T> | undefined;
|
||||
if (existing) return existing;
|
||||
|
||||
const request = executeJsonRequest<T>(url, options, token)
|
||||
.finally(() => {
|
||||
if (inflightGetRequests.get(key) === request) inflightGetRequests.delete(key);
|
||||
});
|
||||
inflightGetRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function _clearApiClientInflightForTests(): void {
|
||||
inflightGetRequests.clear();
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { MetricDomain } from '../shared/analytics/catalog';
|
||||
import DataSourceStatusButton from './DataSourceStatusButton';
|
||||
import MetricCatalogButton from './MetricCatalogButton';
|
||||
|
||||
export default function BiHeaderActions({ domain }: { domain: MetricDomain }) {
|
||||
return (
|
||||
<>
|
||||
<DataSourceStatusButton domain={domain} />
|
||||
<MetricCatalogButton domain={domain} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Activity, AlertTriangle, CheckCircle2, Clock3, Loader2, RefreshCw, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
import type { MetricDomain } from '../shared/analytics/catalog';
|
||||
import {
|
||||
BI_SOURCE_BY_DOMAIN,
|
||||
type DataSourceId,
|
||||
type DataSourceState,
|
||||
} from '../shared/analytics/source-status';
|
||||
|
||||
interface SourceTelemetry {
|
||||
source: DataSourceId;
|
||||
state: DataSourceState;
|
||||
attempts: number;
|
||||
failures: number;
|
||||
failureRate: number | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
}
|
||||
|
||||
interface SourceTelemetryResponse {
|
||||
semantics: 'observed-requests';
|
||||
windowSize: number;
|
||||
processStartedAt: string;
|
||||
checkedAt: string;
|
||||
sources: SourceTelemetry[];
|
||||
}
|
||||
|
||||
const STATE_META = {
|
||||
available: {
|
||||
label: '最近请求成功',
|
||||
icon: CheckCircle2,
|
||||
className: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
},
|
||||
failing: {
|
||||
label: '最近请求失败',
|
||||
icon: AlertTriangle,
|
||||
className: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
},
|
||||
unobserved: {
|
||||
label: '启动后尚无真实请求',
|
||||
icon: Clock3,
|
||||
className: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function formatObservedAt(value: string | null): string {
|
||||
if (!value) return '暂无';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function DataSourceStatusButton({ domain }: { domain: MetricDomain }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [data, setData] = useState<SourceTelemetryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const focusCloseButton = useCallback((node: HTMLButtonElement | null) => node?.focus(), []);
|
||||
const sourceMeta = BI_SOURCE_BY_DOMAIN[domain];
|
||||
const source = useMemo(
|
||||
() => data?.sources.find(item => item.source === sourceMeta.source) || null,
|
||||
[data, sourceMeta.source],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await fetchJson<SourceTelemetryResponse>('/api/analytics/data-sources'));
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [load, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const stateMeta = source ? STATE_META[source.state] : STATE_META.unobserved;
|
||||
const StateIcon = stateMeta.icon;
|
||||
const triggerTone = source?.state === 'failing'
|
||||
? 'border-rose-200 bg-rose-50 text-rose-600'
|
||||
: source?.state === 'available'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-600'
|
||||
: 'border-slate-200 bg-white text-slate-500';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 ${triggerTone}`}
|
||||
aria-label="查看数据源运行状态"
|
||||
title="数据源运行状态"
|
||||
>
|
||||
<Activity size={16} />
|
||||
</button>
|
||||
|
||||
{createPortal(<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-[10020]">
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="点击遮罩关闭数据源状态"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
|
||||
/>
|
||||
<motion.section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${domain}-source-status-title`}
|
||||
initial={{ opacity: 0, x: 32 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 32 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="absolute inset-y-0 right-0 flex w-full max-w-sm flex-col bg-white shadow-2xl"
|
||||
>
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Activity size={17} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id={`${domain}-source-status-title`} className="text-sm font-black text-slate-900">数据源运行状态</h2>
|
||||
<p className="mt-0.5 truncate text-[10px] font-bold text-slate-400">{sourceMeta.label}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:opacity-50"
|
||||
aria-label="刷新数据源状态"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
ref={focusCloseButton}
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
|
||||
aria-label="关闭数据源状态"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
|
||||
) : loading && !data ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
|
||||
<Loader2 size={16} className="animate-spin" />正在读取运行状态
|
||||
</div>
|
||||
) : source ? (
|
||||
<>
|
||||
<div className={`flex items-center gap-2 rounded-lg border px-3 py-3 ${stateMeta.className}`}>
|
||||
<StateIcon size={18} />
|
||||
<span className="text-sm font-black">{stateMeta.label}</span>
|
||||
</div>
|
||||
<dl className="mt-5 divide-y divide-slate-100 border-y border-slate-100 text-xs">
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<dt className="font-bold text-slate-400">观察窗口</dt>
|
||||
<dd className="font-black text-slate-700">最近 {data?.windowSize ?? 100} 次真实读取</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<dt className="font-bold text-slate-400">请求 / 失败</dt>
|
||||
<dd className="font-black tabular-nums text-slate-700">{source.attempts} / {source.failures}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<dt className="font-bold text-slate-400">滚动失败率</dt>
|
||||
<dd className="font-black tabular-nums text-slate-700">{source.failureRate == null ? '暂无' : `${(source.failureRate * 100).toFixed(1)}%`}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<dt className="font-bold text-slate-400">最后成功</dt>
|
||||
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastSuccessAt)}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<dt className="font-bold text-slate-400">最后失败</dt>
|
||||
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastFailureAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-5 text-[10px] font-bold leading-relaxed text-slate-400">
|
||||
状态来自本进程真实数据读取;缓存命中不计入,最近成功不代表持续可达。
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.section>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+38
-352
@@ -1,24 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
|
||||
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { canManageFeedback } from '../shared/auth/roles';
|
||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||
import RotatingFooterHint from './RotatingFooterHint';
|
||||
import FeedbackLauncher from './feedback/FeedbackLauncher';
|
||||
import FeedbackModal from './feedback/FeedbackModal';
|
||||
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
|
||||
|
||||
const MAX_SCREENSHOTS = 6;
|
||||
const MAX_IMG_SIZE_MB = 5;
|
||||
|
||||
interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
@@ -42,38 +33,6 @@ function readAsDataUrl(file: File): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||
|
||||
interface TypeOption {
|
||||
key: FeedbackType;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
sub: string;
|
||||
iconBg: string;
|
||||
iconFg: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: TypeOption[] = [
|
||||
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||||
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||||
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||||
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||||
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||||
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||||
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||||
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||||
];
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
'': '通用',
|
||||
};
|
||||
|
||||
function detectModule(): string {
|
||||
const hash = (window.location.hash || '').slice(1);
|
||||
const path = window.location.pathname;
|
||||
@@ -94,7 +53,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
||||
const [step, setStep] = useState<FeedbackStep>(1);
|
||||
const [type, setType] = useState<FeedbackType | null>(null);
|
||||
const [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
@@ -128,14 +87,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Open: detect current module
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
setMod(moduleProp ?? detectModule());
|
||||
}
|
||||
}, [open, step, moduleProp]);
|
||||
|
||||
// Lock scroll when open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
@@ -153,7 +110,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTimeout(reset, 300); // 等动画完
|
||||
setTimeout(reset, 300);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -193,319 +150,48 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
||||
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep);
|
||||
|
||||
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||||
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={() => setMenuOpen(m => !m)}
|
||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||
aria-label="反馈"
|
||||
title="提建议 / 我的反馈"
|
||||
>
|
||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||
>
|
||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||
>
|
||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="h-px bg-slate-100 my-0.5" />
|
||||
<a
|
||||
href="#/admin/feedback"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||
>
|
||||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 点击外面关菜单 */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[-1]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FeedbackLauncher
|
||||
isAdmin={isAdmin}
|
||||
menuOpen={menuOpen}
|
||||
onCloseMenu={() => setMenuOpen(false)}
|
||||
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
||||
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
onToggleMenu={() => setMenuOpen(current => !current)}
|
||||
/>
|
||||
|
||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Header + progress */}
|
||||
<div className="px-4 pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||
{step === 3 ? '收到啦~' : '提个建议'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{step === 1 && '第一步 / 共 2 步'}
|
||||
{step === 2 && '第二步 / 共 2 步'}
|
||||
{step === 3 && '感谢你的反馈'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
|
||||
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||||
<RotatingFooterHint className="justify-start mb-4" />
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{TYPE_OPTIONS.map((opt, i) => {
|
||||
const Icon = opt.icon;
|
||||
const selected = type === opt.key;
|
||||
return (
|
||||
<motion.button
|
||||
key={opt.key}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||||
whileHover={{ y: -1 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
onClick={() => { setType(opt.key); setStep(2); }}
|
||||
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||||
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||||
</div>
|
||||
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imgs: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile();
|
||||
if (f) imgs.push(f);
|
||||
}
|
||||
}
|
||||
if (imgs.length > 0) {
|
||||
e.preventDefault();
|
||||
addFiles(imgs);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder={
|
||||
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
||||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
||||
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
||||
: '随便聊聊你的想法'
|
||||
}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||
</div>
|
||||
|
||||
{/* 截图上传 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shots.map((s, i) => (
|
||||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{shots.length < MAX_SCREENSHOTS && (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||
>
|
||||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(MODULE_LABELS).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setMod(k)}
|
||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||||
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||||
>
|
||||
<Check size={28} strokeWidth={3} className="text-white" />
|
||||
</motion.div>
|
||||
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step < 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={back}
|
||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一步
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={!canNext || submitting}
|
||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||
>
|
||||
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||||
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100">
|
||||
<button
|
||||
onClick={close}
|
||||
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<FeedbackModal
|
||||
canNext={canNext}
|
||||
content={content}
|
||||
error={error}
|
||||
fileRef={fileRef}
|
||||
maxScreenshots={MAX_SCREENSHOTS}
|
||||
module={mod}
|
||||
open={open}
|
||||
progress={progress}
|
||||
screenshots={shots}
|
||||
step={step}
|
||||
submitting={submitting}
|
||||
textareaRef={taRef}
|
||||
type={type}
|
||||
uploading={uploading}
|
||||
onAddFiles={addFiles}
|
||||
onBack={back}
|
||||
onChangeContent={setContent}
|
||||
onChangeModule={setMod}
|
||||
onClose={close}
|
||||
onNext={next}
|
||||
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
||||
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { BookOpen, Database, Loader2, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
import type { MetricDefinition, MetricDomain } from '../shared/analytics/catalog';
|
||||
|
||||
interface MetricCatalogResponse {
|
||||
catalogVersion: number;
|
||||
domains: MetricDomain[];
|
||||
metrics: MetricDefinition[];
|
||||
}
|
||||
|
||||
const AGGREGATION_LABEL: Record<MetricDefinition['aggregation'], string> = {
|
||||
sum: '求和',
|
||||
count: '计数',
|
||||
derived: '派生计算',
|
||||
'weighted-ratio': '加权比率',
|
||||
'distinct-count': '去重计数',
|
||||
latest: '取最新值',
|
||||
};
|
||||
|
||||
const TIME_LABEL: Record<MetricDefinition['timeSemantics'], string> = {
|
||||
flow: '区间流量',
|
||||
snapshot: '时点快照',
|
||||
freshness: '数据新鲜度',
|
||||
};
|
||||
|
||||
export default function MetricCatalogButton({ domain }: { domain: MetricDomain }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [data, setData] = useState<MetricCatalogResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const setCloseRef = useCallback((node: HTMLButtonElement | null) => {
|
||||
closeRef.current = node;
|
||||
node?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || data) return undefined;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchJson<MetricCatalogResponse>(`/api/analytics/metrics?domain=${domain}`)
|
||||
.then(result => { if (!cancelled) setData(result); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [open, data, domain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||
aria-label="查看指标口径"
|
||||
title="指标口径"
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
</button>
|
||||
|
||||
{createPortal(<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-[10020]">
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="点击遮罩关闭指标口径"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
|
||||
/>
|
||||
<motion.section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${domain}-metric-catalog-title`}
|
||||
initial={{ opacity: 0, x: 32 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 32 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="absolute inset-y-0 right-0 flex w-full max-w-lg flex-col bg-white shadow-2xl"
|
||||
>
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4 md:px-5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<BookOpen size={17} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id={`${domain}-metric-catalog-title`} className="text-sm font-black text-slate-900">指标口径</h2>
|
||||
<p className="mt-0.5 text-[10px] font-bold text-slate-400">
|
||||
{data ? `目录版本 v${data.catalogVersion} · ${data.metrics.length} 项指标` : '读取已发布指标目录'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
ref={setCloseRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-slate-800"
|
||||
aria-label="关闭指标口径"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-2 md:px-5">
|
||||
{error ? (
|
||||
<div className="my-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
|
||||
<Loader2 size={16} className="animate-spin" />正在加载指标口径
|
||||
</div>
|
||||
) : (
|
||||
data.metrics.map(metric => (
|
||||
<article key={metric.id} className="border-b border-slate-100 py-4 last:border-b-0">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-900">{metric.label}</h3>
|
||||
<div className="mt-1 font-mono text-[9px] font-bold text-slate-400">{metric.id}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-[9px] font-black">
|
||||
<span className="rounded bg-blue-50 px-1.5 py-1 text-blue-600">{AGGREGATION_LABEL[metric.aggregation]}</span>
|
||||
<span className="rounded bg-slate-100 px-1.5 py-1 text-slate-600">{TIME_LABEL[metric.timeSemantics]}</span>
|
||||
<span className="rounded bg-emerald-50 px-1.5 py-1 text-emerald-700">{metric.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] font-bold leading-relaxed text-slate-600">{metric.description}</p>
|
||||
<div className="mt-3 rounded-lg bg-slate-950 px-3 py-2 font-mono text-[10px] leading-relaxed text-slate-200 break-words">
|
||||
{metric.formula}
|
||||
</div>
|
||||
<div className="mt-3 flex items-start gap-2 text-[10px] font-bold text-slate-500">
|
||||
<Database size={12} className="mt-0.5 shrink-0 text-slate-400" />
|
||||
<span className="break-words">{metric.sources.join(' · ')}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{metric.dimensions.map(dimension => (
|
||||
<span key={dimension} className="rounded border border-slate-200 px-1.5 py-0.5 text-[9px] font-bold text-slate-500">{dimension}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.section>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { ImagePlus, Loader2, X } from 'lucide-react';
|
||||
import type { FeedbackType, UploadedImg } from './types';
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
'': '通用',
|
||||
};
|
||||
|
||||
function getPlaceholder(type: FeedbackType | null): string {
|
||||
if (type === 'dimension') return '比如:希望按客户/区域/日期范围 等等切片看里程数据…';
|
||||
if (type === 'bug') return '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)';
|
||||
if (type === 'ux') return '比如:能不能把外部 tab 默认收起,加载快一点…';
|
||||
return '随便聊聊你的想法';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
error: string | null;
|
||||
fileRef: RefObject<HTMLInputElement | null>;
|
||||
maxScreenshots: number;
|
||||
module: string;
|
||||
screenshots: UploadedImg[];
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
type: FeedbackType | null;
|
||||
uploading: boolean;
|
||||
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeModule: (module: string) => void;
|
||||
onRemoveScreenshot: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function FeedbackFormStep({
|
||||
content,
|
||||
error,
|
||||
fileRef,
|
||||
maxScreenshots,
|
||||
module,
|
||||
screenshots,
|
||||
textareaRef,
|
||||
type,
|
||||
uploading,
|
||||
onAddFiles,
|
||||
onChangeContent,
|
||||
onChangeModule,
|
||||
onRemoveScreenshot,
|
||||
}: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => onChangeContent(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imgs: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile();
|
||||
if (f) imgs.push(f);
|
||||
}
|
||||
}
|
||||
if (imgs.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddFiles(imgs);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder={getPlaceholder(type)}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||
<span className="text-[10px] text-slate-300 font-bold">{screenshots.length}/{maxScreenshots},可粘贴</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) onAddFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{screenshots.map((s, i) => (
|
||||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => onRemoveScreenshot(i)}
|
||||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{screenshots.length < maxScreenshots && (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||
>
|
||||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.entries(MODULE_LABELS).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onChangeModule(key)}
|
||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${module === key ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
Inbox,
|
||||
MessageCircleHeart,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
isAdmin: boolean;
|
||||
menuOpen: boolean;
|
||||
onCloseMenu: () => void;
|
||||
onOpenFeedback: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onToggleMenu: () => void;
|
||||
}
|
||||
|
||||
export default function FeedbackLauncher({
|
||||
isAdmin,
|
||||
menuOpen,
|
||||
onCloseMenu,
|
||||
onOpenFeedback,
|
||||
onOpenHistory,
|
||||
onToggleMenu,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={onToggleMenu}
|
||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||
aria-label="反馈"
|
||||
title="提建议 / 我的反馈"
|
||||
>
|
||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||
>
|
||||
<button
|
||||
onClick={onOpenFeedback}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||
>
|
||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenHistory}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||
>
|
||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="h-px bg-slate-100 my-0.5" />
|
||||
<a
|
||||
href="#/admin/feedback"
|
||||
onClick={onCloseMenu}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||
>
|
||||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[-1]"
|
||||
onClick={onCloseMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ChevronLeft, ChevronRight, Sparkles, X } from 'lucide-react';
|
||||
import FeedbackFormStep from './FeedbackFormStep';
|
||||
import FeedbackSuccessStep from './FeedbackSuccessStep';
|
||||
import FeedbackTypeStep from './FeedbackTypeStep';
|
||||
import type { FeedbackStep, FeedbackType, UploadedImg } from './types';
|
||||
|
||||
interface Props {
|
||||
canNext: boolean;
|
||||
content: string;
|
||||
error: string | null;
|
||||
fileRef: RefObject<HTMLInputElement | null>;
|
||||
maxScreenshots: number;
|
||||
module: string;
|
||||
open: boolean;
|
||||
progress: number;
|
||||
screenshots: UploadedImg[];
|
||||
step: FeedbackStep;
|
||||
submitting: boolean;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
type: FeedbackType | null;
|
||||
uploading: boolean;
|
||||
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||
onBack: () => void;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeModule: (module: string) => void;
|
||||
onClose: () => void;
|
||||
onNext: () => void;
|
||||
onRemoveScreenshot: (index: number) => void;
|
||||
onSelectType: (type: FeedbackType) => void;
|
||||
}
|
||||
|
||||
export default function FeedbackModal({
|
||||
canNext,
|
||||
content,
|
||||
error,
|
||||
fileRef,
|
||||
maxScreenshots,
|
||||
module,
|
||||
open,
|
||||
progress,
|
||||
screenshots,
|
||||
step,
|
||||
submitting,
|
||||
textareaRef,
|
||||
type,
|
||||
uploading,
|
||||
onAddFiles,
|
||||
onBack,
|
||||
onChangeContent,
|
||||
onChangeModule,
|
||||
onClose,
|
||||
onNext,
|
||||
onRemoveScreenshot,
|
||||
onSelectType,
|
||||
}: Props) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||
{step === 3 ? '收到啦~' : '提个建议'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{step === 1 && '第一步 / 共 2 步'}
|
||||
{step === 2 && '第二步 / 共 2 步'}
|
||||
{step === 3 && '感谢你的反馈'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<FeedbackTypeStep
|
||||
key="s1"
|
||||
selectedType={type}
|
||||
onSelect={onSelectType}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<FeedbackFormStep
|
||||
key="s2"
|
||||
content={content}
|
||||
error={error}
|
||||
fileRef={fileRef}
|
||||
maxScreenshots={maxScreenshots}
|
||||
module={module}
|
||||
screenshots={screenshots}
|
||||
textareaRef={textareaRef}
|
||||
type={type}
|
||||
uploading={uploading}
|
||||
onAddFiles={onAddFiles}
|
||||
onChangeContent={onChangeContent}
|
||||
onChangeModule={onChangeModule}
|
||||
onRemoveScreenshot={onRemoveScreenshot}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && <FeedbackSuccessStep key="s3" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{step < 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一步
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canNext || submitting}
|
||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||
>
|
||||
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||||
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="px-4 py-3 border-t border-slate-100">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
export default function FeedbackSuccessStep() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-center py-6"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||||
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||||
>
|
||||
<Check size={28} strokeWidth={3} className="text-white" />
|
||||
</motion.div>
|
||||
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Bug,
|
||||
ChevronRight,
|
||||
Lightbulb,
|
||||
NotebookPen,
|
||||
Palette,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import RotatingFooterHint from '../RotatingFooterHint';
|
||||
import type { FeedbackType } from './types';
|
||||
|
||||
interface TypeOption {
|
||||
key: FeedbackType;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
sub: string;
|
||||
iconBg: string;
|
||||
iconFg: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: TypeOption[] = [
|
||||
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||||
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||||
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||||
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||||
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||||
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||||
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||||
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
selectedType: FeedbackType | null;
|
||||
onSelect: (type: FeedbackType) => void;
|
||||
}
|
||||
|
||||
export default function FeedbackTypeStep({ selectedType, onSelect }: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||||
<RotatingFooterHint className="justify-start mb-4" />
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{TYPE_OPTIONS.map((opt, i) => {
|
||||
const Icon = opt.icon;
|
||||
const selected = selectedType === opt.key;
|
||||
return (
|
||||
<motion.button
|
||||
key={opt.key}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||||
whileHover={{ y: -1 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
onClick={() => onSelect(opt.key)}
|
||||
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||||
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||||
</div>
|
||||
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type FeedbackStep = 1 | 2 | 3;
|
||||
|
||||
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||
|
||||
export interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { AlertCircle, Info, Loader2, RefreshCw, SearchX, X } from 'lucide-react';
|
||||
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
@@ -15,6 +15,7 @@ export function PageFrame({
|
||||
children,
|
||||
maxWidth = 'max-w-6xl',
|
||||
compactInfo = false,
|
||||
hideHeader = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -25,13 +26,14 @@ export function PageFrame({
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
compactInfo?: boolean;
|
||||
hideHeader?: boolean;
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
|
||||
<motion.header
|
||||
{!hideHeader ? <motion.header
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
@@ -56,7 +58,7 @@ export function PageFrame({
|
||||
</div>
|
||||
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
|
||||
{subtitle ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -106,7 +108,7 @@ export function PageFrame({
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.header>
|
||||
</motion.header> : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +122,7 @@ export function SurfaceCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
title?: ReactNode;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -149,6 +151,8 @@ export function MetricTile({
|
||||
helper,
|
||||
icon: Icon,
|
||||
tone = 'blue',
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
@@ -156,6 +160,8 @@ export function MetricTile({
|
||||
helper?: string;
|
||||
icon?: SurfaceIcon;
|
||||
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const toneClass = {
|
||||
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
|
||||
@@ -166,22 +172,29 @@ export function MetricTile({
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className={cn(
|
||||
'rounded-2xl border border-slate-100 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||
compact ? 'p-3' : 'p-4',
|
||||
className,
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
|
||||
{Icon ? (
|
||||
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
|
||||
<Icon size={18} />
|
||||
<span className={cn('inline-flex shrink-0 items-center justify-center rounded-xl ring-1', compact ? 'h-8 w-8' : 'h-9 w-9', toneClass)}>
|
||||
<Icon size={compact ? 16 : 18} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 items-end gap-1">
|
||||
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
|
||||
<div className={cn('flex min-w-0 items-end gap-1', compact ? 'mt-2' : 'mt-3')}>
|
||||
<span className={cn(
|
||||
'min-w-0 whitespace-nowrap font-black leading-none tracking-tight text-slate-950 tabular-nums',
|
||||
compact ? 'text-[clamp(1.45rem,6vw,1.7rem)]' : 'text-[clamp(1.65rem,6vw,2rem)]',
|
||||
)}>
|
||||
{value}
|
||||
</span>
|
||||
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
|
||||
{helper ? <div className={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,51 +203,23 @@ export function SegmentedNav<T extends string>({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
idPrefix,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
idPrefix: string;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const activateFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;
|
||||
if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
if (event.key === 'Home') nextIndex = 0;
|
||||
if (event.key === 'End') nextIndex = tabs.length - 1;
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
const next = tabs[nextIndex];
|
||||
onChange(next.id);
|
||||
window.requestAnimationFrame(() => document.getElementById(`${idPrefix}-tab-${next.id}`)?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="grid gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tabs.map(({ id, label, icon: Icon }, index) => {
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
|
||||
{tabs.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = active === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
id={`${idPrefix}-tab-${id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${idPrefix}-panel-${id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(id)}
|
||||
onKeyDown={event => activateFromKeyboard(event, index)}
|
||||
className={cn(
|
||||
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
||||
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
|
||||
@@ -261,20 +246,9 @@ export function SkeletonBlock({ className }: { className?: string }) {
|
||||
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
|
||||
}
|
||||
|
||||
type StateVariant = 'card' | 'inline';
|
||||
|
||||
export function LoadingState({
|
||||
label = '数据加载中',
|
||||
variant = 'card',
|
||||
}: {
|
||||
label?: string;
|
||||
variant?: StateVariant;
|
||||
}) {
|
||||
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-8 text-center',
|
||||
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
|
||||
)}>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
|
||||
<div className="text-xs font-black text-slate-500">{label}</div>
|
||||
</div>
|
||||
@@ -284,17 +258,12 @@ export function LoadingState({
|
||||
export function EmptyState({
|
||||
title = '暂无数据',
|
||||
description = '换个筛选条件或稍后再试',
|
||||
variant = 'card',
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: StateVariant;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-8 text-center',
|
||||
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
|
||||
)}>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
|
||||
<div className="text-sm font-black text-slate-700">{title}</div>
|
||||
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
|
||||
@@ -302,40 +271,14 @@ export function EmptyState({
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
title = '加载失败',
|
||||
onRetry,
|
||||
retrying = false,
|
||||
variant = 'card',
|
||||
}: {
|
||||
message: string;
|
||||
title?: string;
|
||||
onRetry?: () => void;
|
||||
retrying?: boolean;
|
||||
variant?: StateVariant;
|
||||
}) {
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'bg-rose-50 p-4 text-rose-700',
|
||||
variant === 'card' && 'rounded-2xl border border-rose-100 shadow-sm',
|
||||
)}>
|
||||
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-black">{title}</div>
|
||||
<div>
|
||||
<div className="text-sm font-black">加载失败</div>
|
||||
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-xs font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw size={13} className={retrying ? 'animate-spin' : ''} />
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+157
-1695
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SubjectOption } from '../api';
|
||||
|
||||
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
|
||||
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
|
||||
|
||||
interface AssetsHeaderProps {
|
||||
activeTab: AssetsTab;
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
|
||||
theme: AssetsTheme;
|
||||
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
|
||||
lastUpdate: string;
|
||||
loading: boolean;
|
||||
selectedSubject: string | null;
|
||||
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
subjects: SubjectOption[];
|
||||
subjectDropdownOpen: boolean;
|
||||
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
subjectSearch: string;
|
||||
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
// --- Constants ---
|
||||
const TABS = [
|
||||
{ id: 'overview', label: '总览' },
|
||||
{ id: 'department', label: '按部门' },
|
||||
{ id: 'region', label: '按区域' },
|
||||
{ id: 'customer', label: '按客户' },
|
||||
];
|
||||
|
||||
function MarqueeBanner() {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
if (!trackRef.current || !innerRef.current) return;
|
||||
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
|
||||
};
|
||||
check();
|
||||
const ro = new ResizeObserver(check);
|
||||
ro.observe(trackRef.current!);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
|
||||
|
||||
return (
|
||||
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
|
||||
<div ref={trackRef} className="overflow-hidden">
|
||||
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
|
||||
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||
{text}
|
||||
</span>
|
||||
{overflow && (
|
||||
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetsHeader({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
theme,
|
||||
setTheme,
|
||||
lastUpdate,
|
||||
loading,
|
||||
selectedSubject,
|
||||
setSelectedSubject,
|
||||
subjects,
|
||||
subjectDropdownOpen,
|
||||
setSubjectDropdownOpen,
|
||||
subjectSearch,
|
||||
setSubjectSearch,
|
||||
subjectDropdownRef,
|
||||
}: AssetsHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Compact Header Bar */}
|
||||
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
|
||||
{/* Title row */}
|
||||
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||||
{/* Right: status + theme */}
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||
<span>{lastUpdate}</span>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<Loader2 className="animate-spin" size={10} />
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
||||
{(['soft','minimal','vibrant'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTheme(t)}
|
||||
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 归属公司作用域筛选 (Scope Chip) */}
|
||||
<div className="flex items-center justify-center px-4 pt-1">
|
||||
<div className="relative" ref={subjectDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSubjectDropdownOpen((o) => !o);
|
||||
setSubjectSearch('');
|
||||
}}
|
||||
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
|
||||
selectedSubject
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
title={selectedSubject || '全部公司'}
|
||||
>
|
||||
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
|
||||
<span className="max-w-[180px] truncate">
|
||||
所属公司:{selectedSubject || '全部公司'}
|
||||
</span>
|
||||
{selectedSubject ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedSubject(null);
|
||||
}}
|
||||
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
|
||||
aria-label="清除归属公司筛选"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
) : (
|
||||
<ChevronDown size={11} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{subjectDropdownOpen && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
autoFocus
|
||||
value={subjectSearch}
|
||||
onChange={(e) => setSubjectSearch(e.target.value)}
|
||||
placeholder="搜索公司名"
|
||||
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSubject(null);
|
||||
setSubjectDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||
全部公司
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{subjects.reduce((s, x) => s + x.total, 0)} 台
|
||||
</span>
|
||||
</button>
|
||||
<div className="my-1 mx-3 border-t border-gray-100" />
|
||||
{subjects
|
||||
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
|
||||
.map((s) => {
|
||||
const active = selectedSubject === s.name;
|
||||
return (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSubject(s.name);
|
||||
setSubjectDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
|
||||
}`}
|
||||
title={s.name}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||
{s.total} 台
|
||||
<span className="mx-1 text-gray-200">·</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab row */}
|
||||
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'text-blue-600 font-medium'
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Status row */}
|
||||
<div className="flex items-center justify-center gap-4 py-1.5 text-[10px] text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||||
最后更新: {lastUpdate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OneOS 迁移提示滚动条 */}
|
||||
<MarqueeBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowDetailItem, FlowType } from '../api';
|
||||
|
||||
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
|
||||
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
|
||||
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
|
||||
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||
};
|
||||
|
||||
interface FlowDetailModalProps {
|
||||
selectedFlow: { date: string; type: FlowType } | null;
|
||||
selectedFlowDetails: FlowDetailItem[];
|
||||
setSelectedFlow: React.Dispatch<React.SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
}
|
||||
export function FlowDetailModal({
|
||||
selectedFlow,
|
||||
selectedFlowDetails,
|
||||
setSelectedFlow,
|
||||
exportFlowDetails,
|
||||
}: FlowDetailModalProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Flow Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedFlow && (
|
||||
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<motion.div
|
||||
data-testid="asset-flow-detail-modal"
|
||||
initial={{ opacity: 0, y: 28, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
|
||||
>
|
||||
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">提交时间口径</div>
|
||||
<h3 className="mt-1 text-lg font-black">
|
||||
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}明细
|
||||
</h3>
|
||||
<div className="mt-1 text-[12px] font-bold text-slate-300">
|
||||
共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFlow(null)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||
disabled={selectedFlowDetails.length === 0}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Download size={13} />
|
||||
导出当前明细
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
|
||||
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
|
||||
<table className="w-full table-fixed text-left">
|
||||
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
|
||||
<tr>
|
||||
<th className="w-28 px-3 py-3">车牌</th>
|
||||
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}时间</th>
|
||||
<th className="w-40 px-3 py-3">提交时间</th>
|
||||
<th className="w-36 px-3 py-3">部门</th>
|
||||
<th className="w-28 px-3 py-3">业务负责人</th>
|
||||
<th className="px-3 py-3">客户</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
|
||||
{selectedFlowDetails.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-blue-50/40">
|
||||
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
|
||||
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
|
||||
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
|
||||
<td className="px-3 py-3">{item.department || '-'}</td>
|
||||
<td className="px-3 py-3">{item.manager || '-'}</td>
|
||||
<td className="px-3 py-3">{item.customerName || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 lg:hidden">
|
||||
{selectedFlowDetails.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
|
||||
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
|
||||
{item.typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] font-bold text-slate-400">
|
||||
<div>提交</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">{item.typeLabel}时间</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">负责人</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">部门</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">客户</div>
|
||||
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedFlowDetails.length === 0 && (
|
||||
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400">当前日期暂无明细</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||
import type { OverviewViewProps } from './overview/types';
|
||||
|
||||
export function OverviewView(props: OverviewViewProps) {
|
||||
return (
|
||||
<>
|
||||
<SummaryMetrics
|
||||
summary={props.summary}
|
||||
operatingRate={props.operatingRate}
|
||||
inventoryRate={props.inventoryRate}
|
||||
pendingRate={props.pendingRate}
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<FlowStatisticsCard
|
||||
flowRange={props.flowRange}
|
||||
setFlowRange={props.setFlowRange}
|
||||
flowLoading={props.flowLoading}
|
||||
flowStats={props.flowStats}
|
||||
exportFlowDetails={props.exportFlowDetails}
|
||||
flowDailyExpanded={props.flowDailyExpanded}
|
||||
setFlowDailyExpanded={props.setFlowDailyExpanded}
|
||||
setSelectedFlow={props.setSelectedFlow}
|
||||
/>
|
||||
|
||||
<AssetSummarySection
|
||||
processedData={props.processedData}
|
||||
theme={props.theme}
|
||||
expandedAssetTypes={props.expandedAssetTypes}
|
||||
toggleAssetType={props.toggleAssetType}
|
||||
expandedModels={props.expandedModels}
|
||||
toggleModel={props.toggleModel}
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<InventoryStatisticsSection
|
||||
inventoryFilters={props.inventoryFilters}
|
||||
setInventoryFilters={props.setInventoryFilters}
|
||||
draftInventoryFilters={props.draftInventoryFilters}
|
||||
setDraftInventoryFilters={props.setDraftInventoryFilters}
|
||||
isInventoryFilterOpen={props.isInventoryFilterOpen}
|
||||
setIsInventoryFilterOpen={props.setIsInventoryFilterOpen}
|
||||
uniqueInventoryRegions={props.uniqueInventoryRegions}
|
||||
uniqueInventoryCities={props.uniqueInventoryCities}
|
||||
uniqueInventoryBrands={props.uniqueInventoryBrands}
|
||||
uniqueInventoryTypes={props.uniqueInventoryTypes}
|
||||
uniqueInventoryModelsForType={props.uniqueInventoryModelsForType}
|
||||
filteredInventoryStats={props.filteredInventoryStats}
|
||||
inventoryTab={props.inventoryTab}
|
||||
setInventoryTab={props.setInventoryTab}
|
||||
inventoryByRegion={props.inventoryByRegion}
|
||||
expandedInventoryRegions={props.expandedInventoryRegions}
|
||||
toggleInventoryRegion={props.toggleInventoryRegion}
|
||||
inventoryByModel={props.inventoryByModel}
|
||||
expandedInventoryTypes={props.expandedInventoryTypes}
|
||||
toggleInventoryType={props.toggleInventoryType}
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import { SearchSelect } from '../../../components/SearchSelect';
|
||||
import type { WeeklyDetailItem } from '../api';
|
||||
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
|
||||
import type { VehicleListItem } from '../types';
|
||||
|
||||
interface VehicleDetailModalProps {
|
||||
selection: VehicleModalSelection | null;
|
||||
setSelection: React.Dispatch<React.SetStateAction<VehicleModalSelection | null>>;
|
||||
isFilterExpanded: boolean;
|
||||
setIsFilterExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
filters: ModalVehicleFilters;
|
||||
setFilters: React.Dispatch<React.SetStateAction<ModalVehicleFilters>>;
|
||||
plates: string[];
|
||||
models: string[];
|
||||
brands: string[];
|
||||
locations: string[];
|
||||
loading: boolean;
|
||||
weeklyDetails: WeeklyDetailItem[];
|
||||
filteredWeeklyDetails: WeeklyDetailItem[];
|
||||
filteredVehicles: VehicleListItem[];
|
||||
}
|
||||
export function VehicleDetailModal({
|
||||
selection: showPlateNumbers,
|
||||
setSelection: setShowPlateNumbers,
|
||||
isFilterExpanded: isModalFilterExpanded,
|
||||
setIsFilterExpanded: setIsModalFilterExpanded,
|
||||
filters: modalFilters,
|
||||
setFilters: setModalFilters,
|
||||
plates: uniqueModalPlates,
|
||||
models: uniqueModalModels,
|
||||
brands: uniqueModalBrands,
|
||||
locations: uniqueModalLocations,
|
||||
loading: modalLoading,
|
||||
weeklyDetails: modalWeeklyDetail,
|
||||
filteredWeeklyDetails: filteredModalWeeklyDetail,
|
||||
filteredVehicles: filteredModalVehicles,
|
||||
}: VehicleDetailModalProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Vehicle Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{showPlateNumbers && (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
|
||||
<div>
|
||||
<h3 className="font-bold text-base flex items-center gap-2">
|
||||
<Truck size={18} className="text-blue-400" />
|
||||
{showPlateNumbers.title || (
|
||||
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
||||
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
|
||||
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-[10px] opacity-60 mt-0.5">
|
||||
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
|
||||
{showPlateNumbers.category === 'Pending' ? '待交车' :
|
||||
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
|
||||
showPlateNumbers.category === 'Returned' ? '已还车' :
|
||||
showPlateNumbers.category === 'Replaced' ? '已替换' :
|
||||
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
|
||||
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/10 p-2 rounded-full transition-colors">
|
||||
<PlusCircle className="rotate-45" size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Filters */}
|
||||
<div className="px-4 py-2 bg-slate-50 border-b border-gray-200 shrink-0">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer py-1"
|
||||
onClick={() => setIsModalFilterExpanded(!isModalFilterExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<Filter size={14} />
|
||||
<span className="text-xs font-bold">筛选条件</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Quick Search always visible when collapsed */}
|
||||
{!isModalFilterExpanded && (
|
||||
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
|
||||
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
animate={{ rotate: isModalFilterExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown size={16} className="text-slate-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isModalFilterExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
|
||||
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
||||
<select value={modalFilters.model} onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部车型</option>
|
||||
{uniqueModalModels.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">品牌名称</label>
|
||||
<select value={modalFilters.brand} onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部品牌</option>
|
||||
{uniqueModalBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[10px] text-gray-500 font-medium">运营所在地</label>
|
||||
<select value={modalFilters.location} onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部地区</option>
|
||||
{uniqueModalLocations.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pb-2">
|
||||
<button
|
||||
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
|
||||
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
|
||||
>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
|
||||
{modalLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||||
</div>
|
||||
) : modalWeeklyDetail.length > 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider sticky top-0 z-20 shadow-sm">
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||
<th className="p-2 font-semibold text-center">日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-[11px]">
|
||||
{filteredModalWeeklyDetail.map((v, i) => (
|
||||
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
|
||||
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full">
|
||||
<table className="min-w-full w-max text-left border-collapse">
|
||||
<thead className="sticky top-0 z-20 shadow-sm">
|
||||
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务部门</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务负责人</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-16">品牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-16">车型</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-48">资产归属</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-48">客户名称</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">车牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-16 text-center">状态</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">提车时间</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">合同到期时间</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-24">运营区域</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 w-20 text-center">离到期</th>
|
||||
<th className="p-2 font-semibold w-48">签约公司</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||
)}
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">品牌</th>
|
||||
<th className="p-2 font-semibold border-r border-slate-600 text-center">车型</th>
|
||||
<th className="p-2 font-semibold text-center">所在地</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-[11px]">
|
||||
{filteredModalVehicles.map((v, idx) => (
|
||||
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors whitespace-nowrap ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
|
||||
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
|
||||
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
|
||||
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
)}
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
|
||||
<td className="p-2 text-gray-600 text-center">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{filteredModalVehicles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={showPlateNumbers.source === 'customer' ? 13 : ((showPlateNumbers.source === 'asset' || showPlateNumbers.category === 'Inventory') ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
|
||||
暂无符合条件的车辆数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
|
||||
<div className="text-xs text-gray-500">
|
||||
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers(null)}
|
||||
className="px-6 py-2 bg-slate-800 text-white rounded-lg text-xs font-bold hover:bg-slate-700 transition-colors shadow-sm"
|
||||
>
|
||||
确认并关闭
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummaryDesktopTable({
|
||||
processedData,
|
||||
theme,
|
||||
expandedAssetTypes,
|
||||
toggleAssetType,
|
||||
expandedModels,
|
||||
toggleModel,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-48">品牌型号</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24">车辆总资产</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存总数</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-江浙沪</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-广东</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
|
||||
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24">本周替换</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{processedData.map((typeGroup) => (
|
||||
<React.Fragment key={typeGroup.type}>
|
||||
{/* Category Header Row */}
|
||||
<tr className={`border-b border-gray-100 cursor-pointer transition-all ${
|
||||
theme === 'vibrant' ? 'bg-blue-600 text-white hover:bg-blue-700' :
|
||||
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
|
||||
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||
}`}
|
||||
onClick={() => toggleAssetType(typeGroup.type)}>
|
||||
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||
}
|
||||
<span>{typeGroup.type}</span>
|
||||
</div>
|
||||
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => (
|
||||
<React.Fragment key={model.model}>
|
||||
<motion.tr
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
|
||||
onClick={() => toggleModel(model.model)}
|
||||
>
|
||||
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
|
||||
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[9px] font-normal text-gray-400">
|
||||
<span>资产 <span className="font-bold text-gray-600">{model.total}</span></span>
|
||||
<span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span>
|
||||
<span>运营 <span className="font-bold text-green-500">{model.operating}</span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.total}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.inventory}
|
||||
</button>
|
||||
</td>
|
||||
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||
{model.inventoryRegions[reg] > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.inventoryRegions[reg]}
|
||||
</button>
|
||||
) : ''}
|
||||
</td>
|
||||
))}
|
||||
<td className="p-3 text-center border-r border-gray-100">
|
||||
{model.pending > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.pending}
|
||||
</button>
|
||||
) : model.pending}
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` });
|
||||
}}
|
||||
className="text-green-600 hover:underline font-bold"
|
||||
>
|
||||
{model.operating}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
|
||||
{model.weeklyDelivered > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyDelivered}
|
||||
</button>
|
||||
) : model.weeklyDelivered}
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
|
||||
{model.weeklyReturned > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyReturned}
|
||||
</button>
|
||||
) : model.weeklyReturned}
|
||||
</td>
|
||||
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
|
||||
{model.weeklyReplaced > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyReplaced}
|
||||
</button>
|
||||
) : model.weeklyReplaced}
|
||||
</td>
|
||||
</motion.tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummaryMobileCards({
|
||||
processedData,
|
||||
theme,
|
||||
expandedAssetTypes,
|
||||
toggleAssetType,
|
||||
expandedModels,
|
||||
toggleModel,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="lg:hidden p-4 space-y-4">
|
||||
{processedData.map((typeGroup) => (
|
||||
<div key={typeGroup.type} className="space-y-3">
|
||||
<div
|
||||
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
|
||||
theme === 'vibrant' ? 'bg-blue-600 text-white active:bg-blue-700' :
|
||||
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
|
||||
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
|
||||
}`}
|
||||
onClick={() => toggleAssetType(typeGroup.type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
|
||||
}
|
||||
<span className="text-xs font-bold">{typeGroup.type}</span>
|
||||
</div>
|
||||
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedAssetTypes.has(typeGroup.type) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="space-y-3 overflow-hidden"
|
||||
>
|
||||
{typeGroup.models.map((model) => (
|
||||
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div
|
||||
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
|
||||
onClick={() => toggleModel(model.model)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className="text-xs font-bold text-gray-700">{model.model}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
|
||||
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
|
||||
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedModels.has(model.model) && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
|
||||
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}>
|
||||
<span className="text-[10px] text-gray-400">总库存</span>
|
||||
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
|
||||
<span className="text-[10px] text-gray-400">待交车</span>
|
||||
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
|
||||
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||
<div key={reg} className="text-center">
|
||||
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
|
||||
{model.inventoryRegions[reg] > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||
}}
|
||||
className="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
>
|
||||
{model.inventoryRegions[reg]}
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-[10px] font-bold text-gray-300">-</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
|
||||
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span>
|
||||
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
|
||||
</div>
|
||||
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">已还车</span>
|
||||
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
|
||||
</div>
|
||||
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">已替换</span>
|
||||
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable';
|
||||
import { AssetSummaryMobileCards } from './AssetSummaryMobileCards';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummarySection(props: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
|
||||
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
||||
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
|
||||
<Info size={10} />
|
||||
点击型号展开详情
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop View Table */}
|
||||
<AssetSummaryDesktopTable {...props} />
|
||||
|
||||
{/* Mobile View Cards for Asset Summary */}
|
||||
<AssetSummaryMobileCards {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowType } from '../../api';
|
||||
import { FLOW_META } from '../FlowDetailModal';
|
||||
import type { FlowStatisticsCardProps } from './types';
|
||||
|
||||
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
|
||||
|
||||
export function FlowStatisticsCard({
|
||||
flowRange,
|
||||
setFlowRange,
|
||||
flowLoading,
|
||||
flowStats,
|
||||
exportFlowDetails,
|
||||
flowDailyExpanded,
|
||||
setFlowDailyExpanded,
|
||||
setSelectedFlow,
|
||||
}: FlowStatisticsCardProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
|
||||
<CalendarDays size={14} className="text-blue-500" />
|
||||
<span>交还车统计</span>
|
||||
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails()}
|
||||
disabled={!flowStats?.details.length}
|
||||
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Download size={13} />
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">开始</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.start}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400">至</div>
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">结束</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.end}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||
<div className="grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
|
||||
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||
</div>
|
||||
{FLOW_TYPES.map((type) => (
|
||||
<div key={type} className="border-l border-slate-200/70 px-2">
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
|
||||
{flowStats?.totals[type] ?? 0}
|
||||
</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="asset-flow-daily-toggle"
|
||||
onClick={() => setFlowDailyExpanded((prev) => !prev)}
|
||||
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
|
||||
>
|
||||
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
|
||||
<span className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||
{flowStats?.daily.length ?? 0} 天
|
||||
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{flowDailyExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
|
||||
{flowLoading && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">正在加载交还车统计...</div>
|
||||
)}
|
||||
{!flowLoading && flowStats?.daily.map((day) => (
|
||||
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
|
||||
<div className="text-[10px] font-bold italic text-slate-300">提交时间</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
|
||||
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||
</div>
|
||||
{FLOW_TYPES.map((type) => {
|
||||
const count = day[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
data-testid={`asset-flow-cell-${day.date}-${type}`}
|
||||
disabled={count === 0}
|
||||
onClick={() => setSelectedFlow({ date: day.date, type })}
|
||||
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!flowLoading && !flowStats?.daily.length && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无交换车数据</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
type InventoryDesktopTableProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
export function InventoryDesktopTable({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryDesktopTableProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
|
||||
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
||||
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<React.Fragment key={region}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{region}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
|
||||
}}>
|
||||
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
||||
<React.Fragment key={city}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${city}-${stat.model}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<React.Fragment key={type}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{type}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` });
|
||||
}}>
|
||||
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
|
||||
<React.Fragment key={model}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${model}-${stat.region}-${stat.city}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
type InventoryMobileListProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
export function InventoryMobileList({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryMobileListProps) {
|
||||
return (
|
||||
<div className="lg:hidden p-3 space-y-3">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{region}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryRegions.has(region) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(cities).map(([city, stats]) => (
|
||||
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.model}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<div key={type} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{type}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryTypes.has(type) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(models).map(([model, stats]) => (
|
||||
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Filter } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { InventoryDesktopTable } from './InventoryDesktopTable';
|
||||
import { InventoryMobileList } from './InventoryMobileList';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
export function InventoryStatisticsSection({
|
||||
inventoryFilters,
|
||||
setInventoryFilters,
|
||||
draftInventoryFilters,
|
||||
setDraftInventoryFilters,
|
||||
isInventoryFilterOpen,
|
||||
setIsInventoryFilterOpen,
|
||||
uniqueInventoryRegions,
|
||||
uniqueInventoryCities,
|
||||
uniqueInventoryBrands,
|
||||
uniqueInventoryTypes,
|
||||
uniqueInventoryModelsForType,
|
||||
filteredInventoryStats,
|
||||
inventoryTab,
|
||||
setInventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryStatisticsSectionProps) {
|
||||
const listProps = {
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
|
||||
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 ml-auto pr-2">
|
||||
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
||||
<span className="text-2xl font-black text-gray-900 leading-none">
|
||||
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
||||
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<span>筛选</span>
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isInventoryFilterOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
|
||||
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-left">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
|
||||
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部区域</option>
|
||||
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
|
||||
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部城市</option>
|
||||
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
|
||||
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部品牌</option>
|
||||
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
|
||||
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部车型</option>
|
||||
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
|
||||
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部批次</option>
|
||||
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
||||
<button
|
||||
onClick={() => setInventoryTab('region')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInventoryTab('model')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按车型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Bar */}
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
||||
{inventoryFilters.region && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
区域: {inventoryFilters.region}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.city && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
城市: {inventoryFilters.city}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.brand && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
品牌: {inventoryFilters.brand}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.type && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
车型: {inventoryFilters.type}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.model && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
批次: {inventoryFilters.model}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop View Table */}
|
||||
<InventoryDesktopTable {...listProps} />
|
||||
|
||||
{/* Mobile View */}
|
||||
<InventoryMobileList {...listProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||
import type { SummaryMetricsProps } from './types';
|
||||
|
||||
export function SummaryMetrics({
|
||||
summary: SUMMARY,
|
||||
operatingRate,
|
||||
inventoryRate,
|
||||
pendingRate,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: SummaryMetricsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header Summary - Ultra Compact */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||
{/* Total Assets */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Truck size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">资产总数</div>
|
||||
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operating */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<Activity size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 && ` 挂${SUMMARY.operating.hanging}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Warehouse size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总库存</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">库{SUMMARY.inventory.inStock} 异{SUMMARY.inventory.abnormal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<PlusCircle size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5">待交车</div>
|
||||
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
|
||||
import type {
|
||||
DateRange,
|
||||
InventoryFilters,
|
||||
VehicleModalSelection,
|
||||
} from '../../model';
|
||||
import type { RegionalInventoryStats, SummaryData, TypeSummary } from '../../types';
|
||||
import type { AssetsTheme } from '../AssetsHeader';
|
||||
|
||||
export interface SummaryMetricsProps {
|
||||
summary: SummaryData;
|
||||
operatingRate: number;
|
||||
inventoryRate: number;
|
||||
pendingRate: number;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export interface FlowStatisticsCardProps {
|
||||
flowRange: DateRange;
|
||||
setFlowRange: Dispatch<SetStateAction<DateRange>>;
|
||||
flowLoading: boolean;
|
||||
flowStats: FlowStatsResponse | null;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
flowDailyExpanded: boolean;
|
||||
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
}
|
||||
|
||||
export interface AssetSummarySectionProps {
|
||||
processedData: TypeSummary[];
|
||||
theme: AssetsTheme;
|
||||
expandedAssetTypes: Set<string>;
|
||||
toggleAssetType: (type: string) => void;
|
||||
expandedModels: Set<string>;
|
||||
toggleModel: (model: string) => void;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export interface InventoryStatisticsSectionProps {
|
||||
inventoryFilters: InventoryFilters;
|
||||
setInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||
draftInventoryFilters: InventoryFilters;
|
||||
setDraftInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||
isInventoryFilterOpen: boolean;
|
||||
setIsInventoryFilterOpen: Dispatch<SetStateAction<boolean>>;
|
||||
uniqueInventoryRegions: string[];
|
||||
uniqueInventoryCities: string[];
|
||||
uniqueInventoryBrands: string[];
|
||||
uniqueInventoryTypes: string[];
|
||||
uniqueInventoryModelsForType: string[];
|
||||
filteredInventoryStats: RegionalInventoryStats[];
|
||||
inventoryTab: 'region' | 'model';
|
||||
setInventoryTab: Dispatch<SetStateAction<'region' | 'model'>>;
|
||||
inventoryByRegion: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||
expandedInventoryRegions: Set<string>;
|
||||
toggleInventoryRegion: (region: string) => void;
|
||||
inventoryByModel: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||
expandedInventoryTypes: Set<string>;
|
||||
toggleInventoryType: (type: string) => void;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export type OverviewViewProps = SummaryMetricsProps
|
||||
& FlowStatisticsCardProps
|
||||
& AssetSummarySectionProps
|
||||
& InventoryStatisticsSectionProps
|
||||
& {
|
||||
allTypesExpanded: boolean;
|
||||
toggleAllAssetTypes: () => void;
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { FlowStatsResponse, WeeklyDetailItem } from './api';
|
||||
import type {
|
||||
CustomerStats,
|
||||
DeptGroup,
|
||||
ManagerStats,
|
||||
RegionalInventoryStats,
|
||||
VehicleListItem,
|
||||
} from './types';
|
||||
import {
|
||||
buildCustomerPieData,
|
||||
buildVehicleModalRequest,
|
||||
deriveCustomerView,
|
||||
deriveDepartmentView,
|
||||
deriveInventoryView,
|
||||
deriveModalVehicleView,
|
||||
formatLocalDate,
|
||||
formatLocalDateTime,
|
||||
getWeeklyFlowRange,
|
||||
groupInventoryByModel,
|
||||
selectFlowDetails,
|
||||
} from './model';
|
||||
|
||||
const emptyInventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
|
||||
const emptyCustomerFilters = { customer: [], brand: '', department: '', manager: '', region: '' };
|
||||
const emptyModalFilters = { plateNumber: '', model: '', brand: '', location: '' };
|
||||
|
||||
function inventory(overrides: Partial<RegionalInventoryStats>): RegionalInventoryStats {
|
||||
return {
|
||||
region: '广东',
|
||||
city: '广州',
|
||||
brand: '现代',
|
||||
type: '18T',
|
||||
model: 'M1',
|
||||
batch: 'B1',
|
||||
quantity: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function manager(overrides: Partial<ManagerStats>): ManagerStats {
|
||||
return {
|
||||
manager: '负责人甲',
|
||||
department: '业务一部',
|
||||
t4_5: 0,
|
||||
t4_5c: 0,
|
||||
t18: 0,
|
||||
t49: 0,
|
||||
trailer: 0,
|
||||
other: 0,
|
||||
total: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function department(name: string, managers: ManagerStats[]): DeptGroup {
|
||||
return {
|
||||
department: name,
|
||||
totalAssets: managers.reduce((sum, item) => sum + item.total, 0),
|
||||
operatingCount: 0,
|
||||
idleCount: 0,
|
||||
attendanceRate: 0,
|
||||
avgMileage: 0,
|
||||
managers,
|
||||
};
|
||||
}
|
||||
|
||||
function customer(overrides: Partial<CustomerStats>): CustomerStats {
|
||||
return {
|
||||
customer: '客户甲',
|
||||
manager: '负责人甲',
|
||||
brand: '现代',
|
||||
department: '业务一部',
|
||||
region: '广东',
|
||||
city: '广州',
|
||||
t4_5: 0,
|
||||
t4_5c: 0,
|
||||
t18: 0,
|
||||
t49: 0,
|
||||
trailer: 0,
|
||||
other: 0,
|
||||
total: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vehicle(overrides: Partial<VehicleListItem>): VehicleListItem {
|
||||
return {
|
||||
id: 1,
|
||||
plateNumber: '粤A00001',
|
||||
vin: 'VIN-1',
|
||||
type: '18T',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
province: '广东',
|
||||
city: '广州',
|
||||
status: '运营',
|
||||
ownership: '自营',
|
||||
contractNo: null,
|
||||
customerName: null,
|
||||
subjectOrg: null,
|
||||
departmentName: null,
|
||||
customerManager: null,
|
||||
brandLabel: '现代',
|
||||
orgName: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('本地日期格式不引入 UTC 偏移', () => {
|
||||
const date = new Date(2026, 7, 3, 9, 7, 5);
|
||||
assert.equal(formatLocalDate(date), '2026-08-03');
|
||||
assert.equal(formatLocalDateTime(date), '2026-08-03 09:07:05');
|
||||
});
|
||||
|
||||
test('周流转区间按周六至周五计算', () => {
|
||||
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 12)), {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
});
|
||||
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 15)), {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
});
|
||||
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 16)), {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
});
|
||||
});
|
||||
|
||||
test('交还替明细走周接口且原样保留周筛选参数', () => {
|
||||
assert.deepEqual(
|
||||
buildVehicleModalRequest({
|
||||
batch: 'All',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
category: 'Delivered',
|
||||
source: 'asset',
|
||||
}, '主体甲'),
|
||||
{
|
||||
kind: 'weekly',
|
||||
type: 'delivered',
|
||||
filters: { model: 'M1', batch: 'All', location: '广州', source: 'asset' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('待交付与车型映射继续走车辆列表参数', () => {
|
||||
assert.deepEqual(
|
||||
buildVehicleModalRequest({
|
||||
batch: 'B1',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
category: 'Pending',
|
||||
type: '4.5T',
|
||||
isColdChain: true,
|
||||
isTrailer: false,
|
||||
manager: '负责人甲',
|
||||
}, '主体甲'),
|
||||
{
|
||||
kind: 'vehicles',
|
||||
params: {
|
||||
batch: 'B1',
|
||||
model: 'M1',
|
||||
location: '广州',
|
||||
category: 'Pending',
|
||||
manager: '负责人甲',
|
||||
vehicleType: '4.5T冷链',
|
||||
subject: '主体甲',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildVehicleModalRequest({
|
||||
batch: 'All',
|
||||
model: 'All',
|
||||
location: 'All',
|
||||
isColdChain: false,
|
||||
isTrailer: true,
|
||||
}, null),
|
||||
{
|
||||
kind: 'vehicles',
|
||||
params: { isColdChain: 'false', isTrailer: 'true', subject: null },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('库存筛选与区域、车型分组保持既有顺序', () => {
|
||||
const rows = [
|
||||
inventory({ region: '广东', city: '广州', type: '18T', model: 'M1', quantity: 2 }),
|
||||
inventory({ region: '浙江', city: '嘉兴', type: '4.5T普货', model: 'M2', quantity: 3 }),
|
||||
inventory({ region: '广东', city: '佛山', type: '18T', model: 'M3', quantity: 4 }),
|
||||
inventory({ region: '广东', city: '广州', type: '新增车型', model: 'M4', quantity: 5 }),
|
||||
];
|
||||
const view = deriveInventoryView(
|
||||
rows,
|
||||
{ ...emptyInventoryFilters, region: '广东', type: '18T' },
|
||||
'18T',
|
||||
);
|
||||
|
||||
assert.deepEqual(view.filtered.map((item) => item.model), ['M1', 'M3']);
|
||||
assert.deepEqual(view.modelsForType, ['M1', 'M3']);
|
||||
assert.deepEqual(Object.keys(view.byRegion), ['广东']);
|
||||
assert.deepEqual(Object.keys(view.byRegion['广东']), ['广州', '佛山']);
|
||||
assert.deepEqual(Object.keys(view.byModel), ['18T']);
|
||||
assert.deepEqual(
|
||||
Object.keys(groupInventoryByModel(rows)),
|
||||
['4.5T普货', '18T', '新增车型'],
|
||||
);
|
||||
});
|
||||
|
||||
test('部门负责人去重、分组并按车辆数倒序', () => {
|
||||
const departments = [
|
||||
department('业务二部', [
|
||||
manager({ manager: '负责人乙', department: '业务二部', total: 3 }),
|
||||
manager({ manager: '负责人甲', department: '业务二部', total: 8 }),
|
||||
]),
|
||||
department('业务一部', [manager({ manager: '负责人甲', total: 5 })]),
|
||||
];
|
||||
const view = deriveDepartmentView(departments, 'All');
|
||||
|
||||
assert.deepEqual(view.managers, ['负责人乙', '负责人甲']);
|
||||
assert.deepEqual(view.groupedManagers, [
|
||||
{ department: '业务二部', managers: ['负责人乙', '负责人甲'] },
|
||||
{ department: '业务一部', managers: ['负责人甲'] },
|
||||
]);
|
||||
assert.deepEqual(view.managerStats.map((item) => item.total), [8, 5, 3]);
|
||||
});
|
||||
|
||||
test('客户筛选与负责人分组合并部门和客户来源', () => {
|
||||
const departments = [
|
||||
department('业务二部', [manager({ manager: '负责人乙', department: '业务二部' })]),
|
||||
department('业务一部', [manager({ manager: '负责人甲' })]),
|
||||
];
|
||||
const customers = [
|
||||
customer({ customer: '客户甲', department: '业务一部', manager: '负责人甲', total: 2 }),
|
||||
customer({ customer: '客户乙', department: '业务二部', manager: '负责人丙', region: '浙江', city: '嘉兴', total: 3 }),
|
||||
customer({ customer: '公务车', department: '公务车', manager: '负责人丁', total: 1 }),
|
||||
];
|
||||
const view = deriveCustomerView(
|
||||
customers,
|
||||
departments,
|
||||
{ ...emptyCustomerFilters, customer: ['客户乙'], region: '浙江' },
|
||||
);
|
||||
|
||||
assert.deepEqual(view.filtered.map((item) => item.customer), ['客户乙']);
|
||||
assert.deepEqual(view.departments, ['业务一部', '业务二部', '公务车']);
|
||||
assert.deepEqual(view.managersByDepartment, [
|
||||
{ department: '业务一部', managers: ['负责人甲'] },
|
||||
{ department: '业务二部', managers: ['负责人乙', '负责人丙'] },
|
||||
{ department: '公务车', managers: ['负责人丁'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('弹窗筛选沿用车牌为空时回退 VIN 的规则', () => {
|
||||
const vehicles = [
|
||||
vehicle({ id: 1 }),
|
||||
vehicle({ id: 2, plateNumber: '', vin: 'VIN-2', model: 'M2', brandLabel: '福田', location: '嘉兴' }),
|
||||
];
|
||||
const weeklyDetails: WeeklyDetailItem[] = [
|
||||
{ truck_id: 1, plate_number: '粤A00001', handover_date: null, contract_type: null, customer_name: null },
|
||||
{ truck_id: 2, plate_number: 'VIN-2', handover_date: null, contract_type: null, customer_name: null },
|
||||
];
|
||||
const view = deriveModalVehicleView(
|
||||
vehicles,
|
||||
weeklyDetails,
|
||||
{ ...emptyModalFilters, plateNumber: 'VIN-2' },
|
||||
);
|
||||
|
||||
assert.deepEqual(view.plates, ['粤A00001', 'VIN-2']);
|
||||
assert.deepEqual(view.filteredVehicles.map((item) => item.id), [2]);
|
||||
assert.deepEqual(view.filteredWeeklyDetails.map((item) => item.truck_id), [2]);
|
||||
});
|
||||
|
||||
test('流转明细按日期和类型同时筛选,客户饼图按区域汇总', () => {
|
||||
const flowStats: FlowStatsResponse = {
|
||||
start: '2026-08-08',
|
||||
end: '2026-08-14',
|
||||
daily: [],
|
||||
totals: { delivered: 1, returned: 1, replaced: 0, total: 2 },
|
||||
details: [
|
||||
{
|
||||
id: '1', type: 'delivered', typeLabel: '交车', date: '2026-08-12', truckId: '1',
|
||||
plateNumber: '粤A00001', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||
},
|
||||
{
|
||||
id: '2', type: 'returned', typeLabel: '还车', date: '2026-08-12', truckId: '2',
|
||||
plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(
|
||||
selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map((item) => item.id),
|
||||
['1'],
|
||||
);
|
||||
|
||||
const customers = [
|
||||
customer({ region: '广东', total: 2 }),
|
||||
customer({ customer: '客户乙', region: '浙江', total: 5 }),
|
||||
customer({ customer: '客户丙', region: '广东', total: 3 }),
|
||||
];
|
||||
assert.deepEqual(buildCustomerPieData(customers, 'region', []), [
|
||||
{ name: '广东', value: 5 },
|
||||
{ name: '浙江', value: 5 },
|
||||
]);
|
||||
const provinceData = [{ name: '广东省', value: 10 }];
|
||||
assert.equal(buildCustomerPieData(customers, 'province', provinceData), provinceData);
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import type { FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||
import type {
|
||||
CustomerStats,
|
||||
DeptGroup,
|
||||
RegionalInventoryStats,
|
||||
VehicleListItem,
|
||||
} from './types';
|
||||
|
||||
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他'];
|
||||
const CHINESE_NUMBER_ORDER: Record<string, number> = {
|
||||
一: 1,
|
||||
二: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
十: 10,
|
||||
};
|
||||
|
||||
export interface DateRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface InventoryFilters {
|
||||
region: string;
|
||||
city: string;
|
||||
brand: string;
|
||||
type: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface CustomerFilters {
|
||||
customer: string[];
|
||||
brand: string;
|
||||
department: string;
|
||||
manager: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface ModalVehicleFilters {
|
||||
plateNumber: string;
|
||||
model: string;
|
||||
brand: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export type VehicleModalCategory =
|
||||
| 'Inventory'
|
||||
| 'Pending'
|
||||
| 'Delivered'
|
||||
| 'Returned'
|
||||
| 'Replaced'
|
||||
| 'Operating';
|
||||
|
||||
export interface VehicleModalSelection {
|
||||
batch: string;
|
||||
model: string;
|
||||
location: string;
|
||||
category?: VehicleModalCategory;
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
department?: string;
|
||||
attendance?: 'active' | 'idle';
|
||||
isColdChain?: boolean;
|
||||
isTrailer?: boolean;
|
||||
type?: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface VehicleListRequestParams {
|
||||
batch?: string;
|
||||
model?: string;
|
||||
location?: string;
|
||||
category?: 'Inventory' | 'Operating' | 'Pending';
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
isColdChain?: string;
|
||||
isTrailer?: string;
|
||||
department?: string;
|
||||
attendance?: string;
|
||||
subject?: string | null;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type VehicleModalRequest =
|
||||
| {
|
||||
kind: 'weekly';
|
||||
type: FlowType;
|
||||
filters: { model: string; batch: string; location: string; source?: string };
|
||||
}
|
||||
| { kind: 'vehicles'; params: VehicleListRequestParams };
|
||||
|
||||
export interface FlowSelection {
|
||||
date: string;
|
||||
type: FlowType;
|
||||
}
|
||||
|
||||
export function formatLocalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function formatLocalDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
// 资产流转周报按周六至周五统计;周末查看时仍停留在刚结束的周五。
|
||||
export function getWeeklyFlowRange(referenceDate = new Date()): DateRange {
|
||||
const day = referenceDate.getDay();
|
||||
const end = day === 6
|
||||
? addDays(referenceDate, -1)
|
||||
: day === 0
|
||||
? addDays(referenceDate, -2)
|
||||
: addDays(referenceDate, 5 - day);
|
||||
return {
|
||||
start: formatLocalDate(addDays(end, -6)),
|
||||
end: formatLocalDate(end),
|
||||
};
|
||||
}
|
||||
|
||||
const WEEKLY_FLOW_TYPE_BY_CATEGORY: Partial<Record<VehicleModalCategory, FlowType>> = {
|
||||
Delivered: 'delivered',
|
||||
Returned: 'returned',
|
||||
Replaced: 'replaced',
|
||||
};
|
||||
|
||||
// Pending 不是周流转事件,必须继续走车辆列表接口以保留型号、批次和区域筛选。
|
||||
export function buildVehicleModalRequest(
|
||||
selection: VehicleModalSelection,
|
||||
subject: string | null,
|
||||
): VehicleModalRequest {
|
||||
const weeklyType = selection.category
|
||||
? WEEKLY_FLOW_TYPE_BY_CATEGORY[selection.category]
|
||||
: undefined;
|
||||
if (weeklyType) {
|
||||
return {
|
||||
kind: 'weekly',
|
||||
type: weeklyType,
|
||||
filters: {
|
||||
model: selection.model,
|
||||
batch: selection.batch,
|
||||
location: selection.location,
|
||||
source: selection.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const params: VehicleListRequestParams = {};
|
||||
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
|
||||
if (selection.batch !== 'All') params.batch = selection.batch;
|
||||
if (selection.model !== 'All') params.model = selection.model;
|
||||
if (selection.location !== 'All') params.location = selection.location;
|
||||
if (selection.source) params.source = selection.source;
|
||||
if (selection.category === 'Inventory') params.category = 'Inventory';
|
||||
if (selection.category === 'Operating') params.category = 'Operating';
|
||||
if (selection.category === 'Pending') params.category = 'Pending';
|
||||
if (selection.manager) params.manager = selection.manager;
|
||||
if (selection.customer) params.customer = selection.customer;
|
||||
if (selection.department) params.department = selection.department;
|
||||
if (selection.attendance) params.attendance = selection.attendance;
|
||||
|
||||
if (!selection.type) {
|
||||
if (selection.isColdChain !== undefined) params.isColdChain = String(selection.isColdChain);
|
||||
if (selection.isTrailer !== undefined) params.isTrailer = String(selection.isTrailer);
|
||||
}
|
||||
|
||||
// 页面车型分组与列表接口的 vehicleType 取值并不完全一致,这里集中保留既有映射。
|
||||
if (selection.type === '4.5T') {
|
||||
if (selection.isColdChain === true) params.vehicleType = '4.5T冷链';
|
||||
if (selection.isColdChain === false) params.vehicleType = '4.5T普货';
|
||||
} else if (
|
||||
selection.type === '4.5T普货'
|
||||
|| selection.type === '4.5T冷链'
|
||||
|| selection.type === '18T'
|
||||
|| selection.type === '49T'
|
||||
|| selection.type === '挂车'
|
||||
|| selection.type === '其他'
|
||||
) {
|
||||
params.vehicleType = selection.type;
|
||||
} else if (selection.type === '其他车型') {
|
||||
if (selection.isTrailer === true) params.isTrailer = 'true';
|
||||
if (selection.isTrailer === false) params.vehicleType = '其他';
|
||||
}
|
||||
|
||||
return { kind: 'vehicles', params: { ...params, subject } };
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
|
||||
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
function getDepartmentOrder(name: string): number {
|
||||
const match = name.match(/[一二三四五六七八九十]/);
|
||||
return match ? (CHINESE_NUMBER_ORDER[match[0]] || 99) : 99;
|
||||
}
|
||||
|
||||
export function filterInventoryStats(
|
||||
inventory: RegionalInventoryStats[],
|
||||
filters: InventoryFilters,
|
||||
): RegionalInventoryStats[] {
|
||||
return inventory.filter((item) => (
|
||||
(!filters.region || item.region === filters.region)
|
||||
&& (!filters.city || item.city === filters.city)
|
||||
&& (!filters.brand || item.brand === filters.brand)
|
||||
&& (!filters.type || item.type === filters.type)
|
||||
&& (!filters.model || item.model === filters.model)
|
||||
));
|
||||
}
|
||||
|
||||
export function groupInventoryByRegion(
|
||||
inventory: RegionalInventoryStats[],
|
||||
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const item of inventory) {
|
||||
if (!result[item.region]) result[item.region] = {};
|
||||
if (!result[item.region][item.city]) result[item.region][item.city] = [];
|
||||
result[item.region][item.city].push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function groupInventoryByModel(
|
||||
inventory: RegionalInventoryStats[],
|
||||
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||
const raw: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const item of inventory) {
|
||||
if (!raw[item.type]) raw[item.type] = {};
|
||||
if (!raw[item.type][item.model]) raw[item.type][item.model] = [];
|
||||
raw[item.type][item.model].push(item);
|
||||
}
|
||||
|
||||
// 已知车型按报表约定排序,新增的未知车型保留接口返回时的首次出现顺序。
|
||||
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||
for (const type of INVENTORY_TYPE_ORDER) {
|
||||
if (raw[type]) result[type] = raw[type];
|
||||
}
|
||||
for (const type of Object.keys(raw)) {
|
||||
if (!result[type]) result[type] = raw[type];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function deriveInventoryView(
|
||||
inventory: RegionalInventoryStats[],
|
||||
filters: InventoryFilters,
|
||||
modelTypeFilter: string,
|
||||
) {
|
||||
const filtered = filterInventoryStats(inventory, filters);
|
||||
const modelSource = modelTypeFilter
|
||||
? inventory.filter((item) => item.type === modelTypeFilter)
|
||||
: inventory;
|
||||
const types = uniqueNonEmpty(inventory.map((item) => item.type));
|
||||
|
||||
return {
|
||||
filtered,
|
||||
brands: uniqueNonEmpty(inventory.map((item) => item.brand)),
|
||||
regions: Array.from(new Set(inventory.map((item) => item.region))),
|
||||
cities: uniqueNonEmpty(inventory.map((item) => item.city)),
|
||||
types: types.sort(
|
||||
(left, right) => INVENTORY_TYPE_ORDER.indexOf(left) - INVENTORY_TYPE_ORDER.indexOf(right),
|
||||
),
|
||||
modelsForType: uniqueNonEmpty(modelSource.map((item) => item.model)),
|
||||
byRegion: groupInventoryByRegion(filtered),
|
||||
byModel: groupInventoryByModel(filtered),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveDepartmentView(departments: DeptGroup[], selectedManager: string) {
|
||||
const managers = departments
|
||||
.flatMap((department) => department.managers.map((manager) => manager.manager))
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
.sort();
|
||||
const groupedManagers = departments.map((department) => ({
|
||||
department: department.department,
|
||||
managers: department.managers.map((manager) => manager.manager),
|
||||
}));
|
||||
const managerStats = departments
|
||||
.flatMap((department) => department.managers)
|
||||
.filter((manager) => selectedManager === 'All' || manager.manager === selectedManager)
|
||||
.sort((left, right) => right.total - left.total);
|
||||
|
||||
return { managers, groupedManagers, managerStats };
|
||||
}
|
||||
|
||||
export function filterCustomerStats(
|
||||
customers: CustomerStats[],
|
||||
filters: CustomerFilters,
|
||||
): CustomerStats[] {
|
||||
return customers.filter((customer) => (
|
||||
(filters.customer.length === 0 || filters.customer.includes(customer.customer))
|
||||
&& (!filters.brand || customer.brand === filters.brand)
|
||||
&& (!filters.department || customer.department === filters.department)
|
||||
&& (!filters.manager || customer.manager === filters.manager)
|
||||
&& (!filters.region || customer.region === filters.region)
|
||||
));
|
||||
}
|
||||
|
||||
function groupCustomerManagers(
|
||||
customers: CustomerStats[],
|
||||
departments: DeptGroup[],
|
||||
): Array<{ department: string; managers: string[] }> {
|
||||
const departmentManagers = new Map<string, Set<string>>();
|
||||
for (const department of departments) {
|
||||
if (!departmentManagers.has(department.department)) {
|
||||
departmentManagers.set(department.department, new Set());
|
||||
}
|
||||
for (const manager of department.managers) {
|
||||
departmentManagers.get(department.department)!.add(manager.manager);
|
||||
}
|
||||
}
|
||||
for (const customer of customers) {
|
||||
if (!customer.manager || !customer.department) continue;
|
||||
if (!departmentManagers.has(customer.department)) {
|
||||
departmentManagers.set(customer.department, new Set());
|
||||
}
|
||||
departmentManagers.get(customer.department)!.add(customer.manager);
|
||||
}
|
||||
|
||||
return Array.from(departmentManagers.entries())
|
||||
.sort((left, right) => {
|
||||
const leftOrder = left[0] === '公务车' ? 100 : getDepartmentOrder(left[0]);
|
||||
const rightOrder = right[0] === '公务车' ? 100 : getDepartmentOrder(right[0]);
|
||||
return leftOrder - rightOrder;
|
||||
})
|
||||
.map(([department, managers]) => ({ department, managers: Array.from(managers) }));
|
||||
}
|
||||
|
||||
export function deriveCustomerView(
|
||||
customers: CustomerStats[],
|
||||
departments: DeptGroup[],
|
||||
filters: CustomerFilters,
|
||||
) {
|
||||
return {
|
||||
filtered: filterCustomerStats(customers, filters),
|
||||
brands: uniqueNonEmpty(customers.map((customer) => customer.brand)),
|
||||
departments: uniqueNonEmpty(customers.map((customer) => customer.department))
|
||||
.sort((left, right) => getDepartmentOrder(left) - getDepartmentOrder(right)),
|
||||
regions: Array.from(new Set(customers.map((customer) => customer.region))),
|
||||
cities: uniqueNonEmpty(customers.map((customer) => customer.city)),
|
||||
customerNames: uniqueNonEmpty(customers.map((customer) => customer.customer)),
|
||||
managersByDepartment: groupCustomerManagers(customers, departments),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveModalVehicleView(
|
||||
vehicles: VehicleListItem[],
|
||||
weeklyDetails: WeeklyDetailItem[],
|
||||
filters: ModalVehicleFilters,
|
||||
) {
|
||||
return {
|
||||
plates: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.plateNumber || vehicle.vin)),
|
||||
models: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.model)),
|
||||
brands: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.brandLabel)),
|
||||
locations: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.location)),
|
||||
filteredVehicles: vehicles.filter((vehicle) => (
|
||||
(!filters.plateNumber || (vehicle.plateNumber || vehicle.vin) === filters.plateNumber)
|
||||
&& (!filters.model || vehicle.model === filters.model)
|
||||
&& (!filters.brand || vehicle.brandLabel === filters.brand)
|
||||
&& (!filters.location || vehicle.location === filters.location)
|
||||
)),
|
||||
filteredWeeklyDetails: weeklyDetails.filter((detail) => (
|
||||
!filters.plateNumber || detail.plate_number === filters.plateNumber
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectFlowDetails(
|
||||
flowStats: FlowStatsResponse | null,
|
||||
selection: FlowSelection | null,
|
||||
) {
|
||||
if (!flowStats || !selection) return [];
|
||||
return flowStats.details.filter((detail) => (
|
||||
detail.date === selection.date && detail.type === selection.type
|
||||
));
|
||||
}
|
||||
|
||||
export function buildCustomerPieData(
|
||||
customers: CustomerStats[],
|
||||
view: 'region' | 'province',
|
||||
provinceData: Array<{ name: string; value: number }>,
|
||||
): Array<{ name: string; value: number }> {
|
||||
if (view === 'province') return provinceData;
|
||||
|
||||
const totals: Record<string, number> = {};
|
||||
for (const customer of customers) {
|
||||
totals[customer.region] = (totals[customer.region] || 0) + customer.total;
|
||||
}
|
||||
return Object.entries(totals)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
}
|
||||
@@ -1,64 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
||||
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { ArrowLeft, Upload, Zap } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import FeedbackFab from '../../components/FeedbackFab';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import { BatchTable } from './import-page/BatchTable';
|
||||
import { RecordFilters } from './import-page/RecordFilters';
|
||||
import { RecordTable } from './import-page/RecordTable';
|
||||
import { SummaryKpis } from './import-page/SummaryKpis';
|
||||
import { UploadDropzone } from './import-page/UploadDropzone';
|
||||
import { UploadFeedback } from './import-page/UploadFeedback';
|
||||
import type {
|
||||
BatchRow,
|
||||
ListItem,
|
||||
OverallRow,
|
||||
UploadResult,
|
||||
VehicleKindFilter,
|
||||
} from './import-page/types';
|
||||
|
||||
function getJwt(): string | null {
|
||||
return sessionStorage.getItem('bi_jwt');
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
ok: boolean;
|
||||
filename: string;
|
||||
batchId: string;
|
||||
parsed: number;
|
||||
fileDuplicates: number;
|
||||
inserted: number;
|
||||
dbDuplicates: number;
|
||||
breakdown: { internal: number; external: number };
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
station_name: string | null;
|
||||
terminal_name: string | null;
|
||||
region: string | null;
|
||||
city: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
duration_min: number | null;
|
||||
kwh: number | null;
|
||||
fee: number | null;
|
||||
e_fee: number | null;
|
||||
service_fee: number | null;
|
||||
plate: string | null;
|
||||
judged_plate: string | null;
|
||||
customer_name: string | null;
|
||||
vehicle_kind: 'internal' | 'external' | 'unknown';
|
||||
batch_id: string;
|
||||
imported_at: string;
|
||||
}
|
||||
|
||||
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
|
||||
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
internal: '内部',
|
||||
external: '外部',
|
||||
};
|
||||
const KIND_STYLE: Record<string, string> = {
|
||||
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
||||
};
|
||||
|
||||
async function uploadFile(file: File): Promise<UploadResult> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
@@ -83,7 +47,7 @@ export default function EleImportPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||
const [filter, setFilter] = useState<VehicleKindFilter>('');
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
@@ -110,13 +74,13 @@ export default function EleImportPage() {
|
||||
reload().catch(e => console.error(e));
|
||||
}, [reload]);
|
||||
|
||||
const handleUpload = async (f: File) => {
|
||||
const handleUpload = async (selectedFile: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await uploadFile(f);
|
||||
setResult(r);
|
||||
const nextResult = await uploadFile(selectedFile);
|
||||
setResult(nextResult);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -125,16 +89,11 @@ export default function EleImportPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onPick = (f: File | null) => {
|
||||
setFile(f);
|
||||
if (f) handleUpload(f);
|
||||
const onPick = (selectedFile: File | null) => {
|
||||
setFile(selectedFile);
|
||||
if (selectedFile) handleUpload(selectedFile);
|
||||
};
|
||||
|
||||
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
|
||||
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
|
||||
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
|
||||
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title="充电记录导入"
|
||||
@@ -144,16 +103,16 @@ export default function EleImportPage() {
|
||||
meta={user?.userName || '导入工作台'}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
else { window.location.hash = '#mileage'; }
|
||||
}}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
|
||||
@@ -164,233 +123,53 @@ export default function EleImportPage() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<UploadDropzone
|
||||
inputRef={inputRef}
|
||||
file={file}
|
||||
uploading={uploading}
|
||||
dragOver={dragOver}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) onPick(droppedFile);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onInputChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
{/* 上传区 */}
|
||||
<section
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPick(f);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
||||
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||||
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">
|
||||
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||||
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<UploadFeedback
|
||||
result={result}
|
||||
error={error}
|
||||
onCloseResult={() => setResult(null)}
|
||||
onCloseError={() => setError(null)}
|
||||
/>
|
||||
|
||||
{/* 上传结果提示 */}
|
||||
<AnimatePresence>
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||||
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||||
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<SummaryKpis overall={overall} />
|
||||
|
||||
{/* 聚合卡 */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||
</section>
|
||||
<BatchTable
|
||||
batches={batches}
|
||||
batchFilter={batchFilter}
|
||||
onClearFilter={() => setBatchFilter('')}
|
||||
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)}
|
||||
/>
|
||||
|
||||
{/* 批次 */}
|
||||
{batches.length > 0 && (
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||||
{batchFilter && (
|
||||
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">导入时间</th>
|
||||
<th className="px-3 py-2 text-right">总数</th>
|
||||
<th className="px-3 py-2 text-right">内部</th>
|
||||
<th className="px-3 py-2 text-right">外部</th>
|
||||
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right">批次</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map(b => (
|
||||
<tr
|
||||
key={b.batch_id}
|
||||
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
|
||||
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<RecordFilters
|
||||
total={total}
|
||||
searchInput={searchInput}
|
||||
filter={filter}
|
||||
onSearchInputChange={(e) => setSearchInput(e.target.value)}
|
||||
onSearchKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
<RecordTable items={items} />
|
||||
</section>
|
||||
|
||||
{/* 列表 */}
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||||
<div className="flex-1" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||
placeholder="搜索订单/车牌/电站"
|
||||
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
||||
/>
|
||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k as typeof filter)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||||
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||||
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(it => (
|
||||
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
|
||||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
|
||||
{KIND_LABEL[it.vehicle_kind]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||||
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||||
尚无记录,先上传一份 xlsx 试试
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
<FeedbackFab module="ele" />
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||||
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||||
const accentMap: Record<string, string> = {
|
||||
slate: 'text-slate-700',
|
||||
blue: 'text-blue-600',
|
||||
amber: 'text-amber-600',
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||
<span className={accentMap[accent]}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { formatCount, formatDateTime, formatDecimal } from './model';
|
||||
import type { BatchRow } from './types';
|
||||
|
||||
interface BatchTableProps {
|
||||
batches: BatchRow[];
|
||||
batchFilter: string;
|
||||
onClearFilter: () => void;
|
||||
onSelectBatch: (batchId: string) => void;
|
||||
}
|
||||
|
||||
export function BatchTable({ batches, batchFilter, onClearFilter, onSelectBatch }: BatchTableProps) {
|
||||
if (batches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||||
{batchFilter && (
|
||||
<button onClick={onClearFilter} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">导入时间</th>
|
||||
<th className="px-3 py-2 text-right">总数</th>
|
||||
<th className="px-3 py-2 text-right">内部</th>
|
||||
<th className="px-3 py-2 text-right">外部</th>
|
||||
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right">批次</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map(batch => (
|
||||
<tr
|
||||
key={batch.batch_id}
|
||||
onClick={() => onSelectBatch(batch.batch_id)}
|
||||
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === batch.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{formatDateTime(batch.imported_at, 19)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{formatCount(batch.records)}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{formatCount(batch.internal_count)}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{formatCount(batch.external_count)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{formatDecimal(batch.total_kwh, 1)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{formatDecimal(batch.total_fee, 2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{batch.batch_id.slice(0, 12)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import type { VehicleKindFilter } from './types';
|
||||
|
||||
interface RecordFiltersProps {
|
||||
total: number;
|
||||
searchInput: string;
|
||||
filter: VehicleKindFilter;
|
||||
onSearchInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
onFilterChange: (filter: VehicleKindFilter) => void;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: ReadonlyArray<readonly [VehicleKindFilter, string]> = [
|
||||
['', '全部'],
|
||||
['internal', '内部'],
|
||||
['external', '外部'],
|
||||
];
|
||||
|
||||
export function RecordFilters({
|
||||
total,
|
||||
searchInput,
|
||||
filter,
|
||||
onSearchInputChange,
|
||||
onSearchKeyDown,
|
||||
onFilterChange,
|
||||
}: RecordFiltersProps) {
|
||||
return (
|
||||
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||||
<div className="flex-1" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={onSearchInputChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder="搜索订单/车牌/电站"
|
||||
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
||||
/>
|
||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||
{FILTER_OPTIONS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onFilterChange(key)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === key ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FileSpreadsheet } from 'lucide-react';
|
||||
import { formatDateTime, formatFixed, KIND_LABEL, KIND_STYLE } from './model';
|
||||
import type { ListItem } from './types';
|
||||
|
||||
interface RecordTableProps {
|
||||
items: ListItem[];
|
||||
}
|
||||
|
||||
export function RecordTable({ items }: RecordTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||||
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||||
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||||
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{formatDateTime(item.start_time, 16)}</td>
|
||||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{item.plate || item.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[item.vehicle_kind]}`}>
|
||||
{KIND_LABEL[item.vehicle_kind]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{item.station_name || '—'}{item.terminal_name ? ` · ${item.terminal_name}` : ''}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.kwh, 2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.fee, 2)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{item.duration_min ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{item.order_no}</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||||
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||||
尚无记录,先上传一份 xlsx 试试
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ExternalLink, Layers, Truck, Zap } from 'lucide-react';
|
||||
import { summarizeOverall } from './model';
|
||||
import type { OverallRow } from './types';
|
||||
|
||||
interface SummaryKpisProps {
|
||||
overall: OverallRow[];
|
||||
}
|
||||
|
||||
export function SummaryKpis({ overall }: SummaryKpisProps) {
|
||||
const summary = summarizeOverall(overall);
|
||||
|
||||
return (
|
||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={summary.totalRecords.toLocaleString()} />
|
||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={summary.internalRecords.toLocaleString()} accent="blue" />
|
||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={summary.externalRecords.toLocaleString()} accent="amber" />
|
||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${summary.totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${summary.internalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${summary.externalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||||
const accentMap: Record<string, string> = {
|
||||
slate: 'text-slate-700',
|
||||
blue: 'text-blue-600',
|
||||
amber: 'text-amber-600',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||
<span className={accentMap[accent]}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
ChangeEventHandler,
|
||||
DragEventHandler,
|
||||
MouseEventHandler,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import { RotateCcw, Upload } from 'lucide-react';
|
||||
|
||||
interface UploadDropzoneProps {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
file: File | null;
|
||||
uploading: boolean;
|
||||
dragOver: boolean;
|
||||
onClick: MouseEventHandler<HTMLElement>;
|
||||
onDragOver: DragEventHandler<HTMLElement>;
|
||||
onDragLeave: DragEventHandler<HTMLElement>;
|
||||
onDrop: DragEventHandler<HTMLElement>;
|
||||
onInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function UploadDropzone({
|
||||
inputRef,
|
||||
file,
|
||||
uploading,
|
||||
dragOver,
|
||||
onClick,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onInputChange,
|
||||
}: UploadDropzoneProps) {
|
||||
return (
|
||||
<section
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
||||
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||||
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">
|
||||
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||||
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import type { UploadResult } from './types';
|
||||
|
||||
interface UploadFeedbackProps {
|
||||
result: UploadResult | null;
|
||||
error: string | null;
|
||||
onCloseResult: () => void;
|
||||
onCloseError: () => void;
|
||||
}
|
||||
|
||||
export function UploadFeedback({ result, error, onCloseResult, onCloseError }: UploadFeedbackProps) {
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||||
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onCloseResult} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||||
<button onClick={onCloseError} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||||
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
formatCount,
|
||||
formatDateTime,
|
||||
formatDecimal,
|
||||
formatFixed,
|
||||
summarizeOverall,
|
||||
} from './model';
|
||||
|
||||
test('summarizeOverall preserves aggregate totals and per-kind values', () => {
|
||||
assert.deepEqual(summarizeOverall([
|
||||
{ vehicle_kind: 'internal', records: 3, total_kwh: 12.34, total_fee: 20.5 },
|
||||
{ vehicle_kind: 'external', records: 2, total_kwh: 5.66, total_fee: 9.5 },
|
||||
]), {
|
||||
totalRecords: 5,
|
||||
totalKwh: 18,
|
||||
totalFee: 30,
|
||||
internalRecords: 3,
|
||||
externalRecords: 2,
|
||||
internalKwh: 12.34,
|
||||
externalKwh: 5.66,
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeOverall preserves empty fallbacks', () => {
|
||||
assert.deepEqual(summarizeOverall([]), {
|
||||
totalRecords: 0,
|
||||
totalKwh: 0,
|
||||
totalFee: 0,
|
||||
internalRecords: 0,
|
||||
externalRecords: 0,
|
||||
internalKwh: 0,
|
||||
externalKwh: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('formatters preserve the import page display rules', () => {
|
||||
assert.equal(formatDateTime('2026-08-13T09:08:07.000Z', 19), '2026-08-13 09:08:07');
|
||||
assert.equal(formatDateTime(null, 16), '');
|
||||
assert.equal(formatCount(1234), (1234).toLocaleString());
|
||||
assert.equal(formatDecimal(1234.56, 1), (1234.56).toLocaleString('zh-CN', { maximumFractionDigits: 1 }));
|
||||
assert.equal(formatFixed(null, 2), '0.00');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { OverallRow } from './types';
|
||||
|
||||
export const KIND_LABEL: Record<string, string> = {
|
||||
internal: '内部',
|
||||
external: '外部',
|
||||
};
|
||||
|
||||
export const KIND_STYLE: Record<string, string> = {
|
||||
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
||||
};
|
||||
|
||||
export function summarizeOverall(rows: OverallRow[]) {
|
||||
const byKind = new Map(rows.map(row => [row.vehicle_kind, row]));
|
||||
|
||||
return {
|
||||
totalRecords: rows.reduce((sum, row) => sum + Number(row.records || 0), 0),
|
||||
totalKwh: rows.reduce((sum, row) => sum + Number(row.total_kwh || 0), 0),
|
||||
totalFee: rows.reduce((sum, row) => sum + Number(row.total_fee || 0), 0),
|
||||
internalRecords: byKind.get('internal')?.records ?? 0,
|
||||
externalRecords: byKind.get('external')?.records ?? 0,
|
||||
internalKwh: byKind.get('internal')?.total_kwh ?? 0,
|
||||
externalKwh: byKind.get('external')?.total_kwh ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined, length: number) {
|
||||
return (value || '').replace('T', ' ').slice(0, length);
|
||||
}
|
||||
|
||||
export function formatCount(value: number) {
|
||||
return Number(value).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatDecimal(value: number | null | undefined, maximumFractionDigits: number) {
|
||||
return Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits });
|
||||
}
|
||||
|
||||
export function formatFixed(value: number | null | undefined, fractionDigits: number) {
|
||||
return Number(value ?? 0).toFixed(fractionDigits);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface UploadResult {
|
||||
ok: boolean;
|
||||
filename: string;
|
||||
batchId: string;
|
||||
parsed: number;
|
||||
fileDuplicates: number;
|
||||
inserted: number;
|
||||
dbDuplicates: number;
|
||||
breakdown: { internal: number; external: number };
|
||||
}
|
||||
|
||||
export type VehicleKind = 'internal' | 'external' | 'unknown';
|
||||
export type VehicleKindFilter = '' | 'internal' | 'external';
|
||||
|
||||
export interface ListItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
station_name: string | null;
|
||||
terminal_name: string | null;
|
||||
region: string | null;
|
||||
city: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
duration_min: number | null;
|
||||
kwh: number | null;
|
||||
fee: number | null;
|
||||
e_fee: number | null;
|
||||
service_fee: number | null;
|
||||
plate: string | null;
|
||||
judged_plate: string | null;
|
||||
customer_name: string | null;
|
||||
vehicle_kind: VehicleKind;
|
||||
batch_id: string;
|
||||
imported_at: string;
|
||||
}
|
||||
|
||||
export interface OverallRow {
|
||||
vehicle_kind: 'internal' | 'external';
|
||||
records: number;
|
||||
total_kwh: number;
|
||||
total_fee: number;
|
||||
}
|
||||
|
||||
export interface BatchRow {
|
||||
batch_id: string;
|
||||
imported_at: string;
|
||||
records: number;
|
||||
internal_count: number;
|
||||
external_count: number;
|
||||
total_kwh: number;
|
||||
total_fee: number;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
import type { DateQuickPick } from './types';
|
||||
import { QUICK_DATE_OPTIONS, type DateRangeMode } from './date-range';
|
||||
|
||||
export function DateRangeInputs({
|
||||
idPrefix,
|
||||
startDate,
|
||||
endDate,
|
||||
onChange,
|
||||
ariaPrefix = '',
|
||||
className,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onChange: (field: 'start' | 'end', value: string) => void;
|
||||
ariaPrefix?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const prefix = ariaPrefix ? `${ariaPrefix} ` : '';
|
||||
return (
|
||||
<div className={cn('grid grid-cols-2 gap-2', className)}>
|
||||
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||
<input
|
||||
id={`${idPrefix}-start-date`}
|
||||
name={`${idPrefix}StartDate`}
|
||||
type="date"
|
||||
aria-label={`${prefix}开始日期`}
|
||||
value={startDate}
|
||||
onInput={event => onChange('start', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||
<input
|
||||
id={`${idPrefix}-end-date`}
|
||||
name={`${idPrefix}EndDate`}
|
||||
type="date"
|
||||
aria-label={`${prefix}结束日期`}
|
||||
value={endDate}
|
||||
onInput={event => onChange('end', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalysisDateFilters({
|
||||
idPrefix,
|
||||
mode,
|
||||
startDate,
|
||||
endDate,
|
||||
onQuickPick,
|
||||
onCustom,
|
||||
onDateChange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
mode: DateRangeMode;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onQuickPick: (pick: DateQuickPick) => void;
|
||||
onCustom: () => void;
|
||||
onDateChange: (field: 'start' | 'end', value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_DATE_OPTIONS.map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onQuickPick(option.id)}
|
||||
aria-pressed={mode === option.id}
|
||||
className={cn(
|
||||
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
|
||||
mode === option.id
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCustom}
|
||||
aria-pressed={mode === 'custom'}
|
||||
className={cn(
|
||||
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
|
||||
mode === 'custom'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<DateRangeInputs
|
||||
idPrefix={idPrefix}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={onDateChange}
|
||||
className="mt-2"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface AnalysisScopeOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function AnalysisScopeSwitch<T extends string>({
|
||||
ariaLabel,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
icon: Icon,
|
||||
className,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
value: T;
|
||||
options: readonly AnalysisScopeOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
icon?: ComponentType<{ size?: number; className?: string }>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('grid gap-1 rounded-lg bg-slate-100 p-1', className)}
|
||||
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
aria-pressed={value === option.id}
|
||||
className={cn(
|
||||
'flex min-h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-black transition-colors',
|
||||
value === option.id
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="shrink-0" /> : null}
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, Route, Search, TriangleAlert } from 'lucide-react';
|
||||
import Blur from '../../components/Blur';
|
||||
import { ErrorState, LoadingState } from '../../components/ui/surface';
|
||||
import { fetchEtcBills, fetchEtcRecords } from './api';
|
||||
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
|
||||
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
|
||||
import { DateRangeInputs } from './AnalysisDateFilters';
|
||||
import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation';
|
||||
|
||||
function fmtMoney(value: number): string {
|
||||
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function formatSigned(value: number, fractionDigits = 0): string {
|
||||
const formatted = Math.abs(value).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
return `${value >= 0 ? '+' : '-'}${formatted}`;
|
||||
}
|
||||
|
||||
function costTypeLabel(value: number): string {
|
||||
if (value === 1) return '客户承担';
|
||||
if (value === 2) return '我方承担';
|
||||
return '承担方待确认';
|
||||
}
|
||||
|
||||
function paymentLabel(receivable: number, paid: number): string {
|
||||
if (receivable > 0 && paid >= receivable) return '已收';
|
||||
if (paid > 0) return '部分收款';
|
||||
return '未收';
|
||||
}
|
||||
|
||||
export default function ETCDetails({
|
||||
context,
|
||||
overviewKpi,
|
||||
onContextChange,
|
||||
}: {
|
||||
context: EtcDrillContext;
|
||||
overviewKpi: EtcOverviewResponse['kpi'];
|
||||
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
|
||||
}) {
|
||||
const { view, startDate, endDate, search, page } = context;
|
||||
const [draftSearch, setDraftSearch] = useState(search);
|
||||
const [records, setRecords] = useState<EtcTollRecordResponse | null>(null);
|
||||
const [bills, setBills] = useState<EtcBillResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setDraftSearch(search), [search]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (view === 'records') setRecords(null);
|
||||
else setBills(null);
|
||||
const query = {
|
||||
page,
|
||||
limit: 20,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
search: search || undefined,
|
||||
};
|
||||
const request = view === 'records' ? fetchEtcRecords(query) : fetchEtcBills(query);
|
||||
request
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
if (page > result.totalPages) {
|
||||
onContextChange({ ...context, page: result.totalPages }, 'replace');
|
||||
return;
|
||||
}
|
||||
if (view === 'records') setRecords(result as EtcTollRecordResponse);
|
||||
else setBills(result as EtcBillResponse);
|
||||
})
|
||||
.catch(loadError => {
|
||||
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [context, endDate, onContextChange, page, search, startDate, view]);
|
||||
|
||||
const activeData = view === 'records' ? records : bills;
|
||||
const totalPages = activeData?.totalPages || 1;
|
||||
const isFullDataset = !startDate && !endDate && !search;
|
||||
const reconciliation = isFullDataset
|
||||
? view === 'records' && records
|
||||
? reconcileEtcRecords(overviewKpi, records)
|
||||
: view === 'bills' && bills
|
||||
? reconcileEtcBills(overviewKpi, bills)
|
||||
: null
|
||||
: null;
|
||||
const reconciliationDifferences = reconciliation && !reconciliation.matches
|
||||
? 'passageCountDifference' in reconciliation
|
||||
? [
|
||||
reconciliation.passageCountDifference !== 0 && `通行次数 ${formatSigned(reconciliation.passageCountDifference)}`,
|
||||
reconciliation.vehicleCountDifference !== 0 && `车辆数 ${formatSigned(reconciliation.vehicleCountDifference)}`,
|
||||
reconciliation.tollAmountDifference !== 0 && `通行费 ${formatSigned(reconciliation.tollAmountDifference, 2)} 元`,
|
||||
reconciliation.serviceFeeDifference !== 0 && `服务费 ${formatSigned(reconciliation.serviceFeeDifference, 2)} 元`,
|
||||
reconciliation.totalAmountDifference !== 0 && `总费用 ${formatSigned(reconciliation.totalAmountDifference, 2)} 元`,
|
||||
].filter(Boolean).join(' · ')
|
||||
: [
|
||||
reconciliation.billCountDifference !== 0 && `账单数 ${formatSigned(reconciliation.billCountDifference)}`,
|
||||
reconciliation.receivableAmountDifference !== 0 && `应收 ${formatSigned(reconciliation.receivableAmountDifference, 2)} 元`,
|
||||
reconciliation.paidAmountDifference !== 0 && `已收 ${formatSigned(reconciliation.paidAmountDifference, 2)} 元`,
|
||||
].filter(Boolean).join(' · ')
|
||||
: '';
|
||||
const applySearch = () => {
|
||||
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
|
||||
};
|
||||
const changeView = (next: EtcDetailView) => {
|
||||
onContextChange(
|
||||
{ ...context, view: next, page: 1 },
|
||||
next === view ? 'replace' : 'push',
|
||||
);
|
||||
};
|
||||
const updateDate = (field: 'start' | 'end', value: string) => {
|
||||
let nextStart = field === 'start' ? value : startDate;
|
||||
let nextEnd = field === 'end' ? value : endDate;
|
||||
if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart];
|
||||
onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace');
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="etc-details"
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-3 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-black text-slate-900">ETC 明细下钻</h2>
|
||||
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
||||
{view === 'records' ? '通行流水与费用承担' : '客户账期、应收与收款'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex rounded-lg bg-slate-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeView('records')}
|
||||
aria-pressed={view === 'records'}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'records' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
<Route size={13} />通行记录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeView('bills')}
|
||||
aria-pressed={view === 'bills'}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'bills' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
<ReceiptText size={13} />结算账单
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_40px] gap-2 sm:grid-cols-[minmax(280px,1fr)_minmax(0,1fr)_40px]">
|
||||
<DateRangeInputs
|
||||
idPrefix="etc"
|
||||
ariaPrefix="ETC"
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={updateDate}
|
||||
className="col-span-2 sm:col-span-1"
|
||||
/>
|
||||
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">搜索</span>
|
||||
<input
|
||||
type="search"
|
||||
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
|
||||
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
|
||||
value={draftSearch}
|
||||
maxLength={128}
|
||||
onChange={event => setDraftSearch(event.target.value)}
|
||||
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
|
||||
className="mt-1 h-6 w-full min-w-0 bg-transparent text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySearch}
|
||||
className="flex h-full min-h-[58px] w-10 items-center justify-center rounded-lg bg-slate-900 text-white hover:bg-blue-600"
|
||||
aria-label="应用 ETC 明细搜索"
|
||||
title="搜索"
|
||||
>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{activeData ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2 text-[10px] font-bold text-slate-500 md:px-4">
|
||||
<span>{activeData.total} 条结果</span>
|
||||
{view === 'records' && records ? (
|
||||
<span>通行费 {fmtMoney(records.summary.tollAmount)} · 服务费 {fmtMoney(records.summary.serviceFee)} · 合计 {fmtMoney(records.summary.totalAmount)}</span>
|
||||
) : view === 'bills' && bills ? (
|
||||
<span>应收 {fmtMoney(bills.summary.receivableAmount)} · 已收 {fmtMoney(bills.summary.paidAmount)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{reconciliation && (
|
||||
<div
|
||||
role={reconciliation.matches ? 'status' : 'alert'}
|
||||
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
|
||||
reconciliation.matches
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'
|
||||
}`}
|
||||
>
|
||||
{reconciliation.matches
|
||||
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
|
||||
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
|
||||
<span>
|
||||
{reconciliation.matches
|
||||
? `口径核对通过:总览与 ${activeData?.total ?? 0} 条${view === 'records' ? '通行记录' : '结算账单'}全量汇总一致`
|
||||
: `口径存在差异:${reconciliationDifferences}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<ErrorState message={error} variant="inline" />
|
||||
) : loading && !activeData ? (
|
||||
<LoadingState label="正在加载 ETC 明细" variant="inline" />
|
||||
) : view === 'records' && records ? (
|
||||
records.items.length === 0 ? (
|
||||
<div className="py-14 text-center">
|
||||
<Route size={22} className="mx-auto text-slate-300" />
|
||||
<div className="mt-3 text-xs font-black text-slate-500">暂无匹配的通行记录</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-300">首次同步后将按通行时间倒序展示</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full min-w-[820px] text-left text-[11px]">
|
||||
<thead className="bg-white text-[10px] font-black text-slate-400">
|
||||
<tr><th className="px-4 py-2">通行时间</th><th className="px-3 py-2">车牌 / 客户</th><th className="px-3 py-2">入口 → 出口</th><th className="px-3 py-2">承担</th><th className="px-4 py-2 text-right">费用</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.items.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3 font-bold text-slate-600">{item.transTime}</td>
|
||||
<td className="px-3 py-3"><div className="font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur></div></td>
|
||||
<td className="px-3 py-3 font-bold text-slate-600">{item.entryStation || '-'} → {item.exitStation || '-'}</td>
|
||||
<td className="px-3 py-3 font-bold text-slate-600">{costTypeLabel(item.costType)}</td>
|
||||
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.totalAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">通行 {fmtMoney(item.tollAmount)} · 服务 {fmtMoney(item.serviceFee)}</div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 md:hidden">
|
||||
{records.items.map(item => (
|
||||
<article key={item.id} className="px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3"><div><div className="font-mono text-xs font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{item.transTime}</div></div><div className="text-right text-sm font-black text-slate-800">{fmtMoney(item.totalAmount)}</div></div>
|
||||
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.entryStation || '-'} → {item.exitStation || '-'}</div>
|
||||
<div className="mt-1 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur><span>{costTypeLabel(item.costType)}</span></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : view === 'bills' && bills ? (
|
||||
bills.items.length === 0 ? (
|
||||
<div className="py-14 text-center">
|
||||
<ReceiptText size={22} className="mx-auto text-slate-300" />
|
||||
<div className="mt-3 text-xs font-black text-slate-500">暂无匹配的结算账单</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-300">生成账单后将按账期结束日倒序展示</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full min-w-[760px] text-left text-[11px]">
|
||||
<thead className="text-[10px] font-black text-slate-400"><tr><th className="px-4 py-2">账单 / 客户</th><th className="px-3 py-2">账期</th><th className="px-3 py-2">通行</th><th className="px-3 py-2">收款状态</th><th className="px-4 py-2 text-right">应收 / 已收</th></tr></thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{bills.items.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3"><div className="font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></td>
|
||||
<td className="px-3 py-3 font-bold text-slate-600">{item.periodStart} → {item.periodEnd}</td>
|
||||
<td className="px-3 py-3 font-bold text-slate-600">{item.tollCount} 次 · {fmtMoney(item.tollAmount)}</td>
|
||||
<td className="px-3 py-3 font-bold text-slate-600">{paymentLabel(item.receivableAmount, item.paidAmount)}</td>
|
||||
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">已收 {fmtMoney(item.paidAmount)}</div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 md:hidden">
|
||||
{bills.items.map(item => (
|
||||
<article key={item.id} className="px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3"><div><div className="text-xs font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></div><div className="text-right"><div className="text-sm font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">已收 {fmtMoney(item.paidAmount)}</div></div></div>
|
||||
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.periodStart} → {item.periodEnd}</div>
|
||||
<div className="mt-1 flex justify-between text-[9px] font-bold text-slate-400"><span>{item.tollCount} 次通行</span><span>{paymentLabel(item.receivableAmount, item.paidAmount)}</span></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{activeData ? (
|
||||
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
|
||||
<span className="text-[10px] font-bold text-slate-400">第 {page} / {totalPages} 页</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => onContextChange({ ...context, page: Math.max(1, page - 1) }, 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页" title="上一页"><ChevronLeft size={14} /></button>
|
||||
<button type="button" onClick={() => onContextChange({ ...context, page: Math.min(totalPages, page + 1) }, 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页" title="下一页"><ChevronRight size={14} /></button>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+68
-173
@@ -1,184 +1,79 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
|
||||
import { fetchEtcOverview } from './api';
|
||||
import type { EtcOverviewResponse } from './types';
|
||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import ETCDetails from './ETCDetails';
|
||||
import {
|
||||
buildEtcDrillUrl,
|
||||
parseEtcDrillContext,
|
||||
type EtcDetailView,
|
||||
type EtcDrillContext,
|
||||
} from './etc-drill-context';
|
||||
import { motion } from 'motion/react';
|
||||
import { Construction, Hammer } from 'lucide-react';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
function fmtMoney(value: number): string {
|
||||
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function syncStatusLabel(status: EtcOverviewResponse['integration']['lastSyncStatus']): string {
|
||||
if (status === 'success') return '同步成功';
|
||||
if (status === 'failed') return '同步失败';
|
||||
if (status === 'partial') return '部分成功';
|
||||
if (status === 'never') return '尚未同步';
|
||||
return '状态未知';
|
||||
}
|
||||
|
||||
function DrilldownMetricButton({
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-controls="etc-details"
|
||||
aria-label={label}
|
||||
className="min-w-0 rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const ETC_HINTS = [
|
||||
'ETC 通行费数据正在与发卡方系统打通…',
|
||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||
'马上能看到每月通行费明细啦',
|
||||
'想看哪个维度的 ETC?反馈一下嘛',
|
||||
'上线时机:等数据接通的那一天',
|
||||
];
|
||||
|
||||
export default function ETCView() {
|
||||
const [data, setData] = useState<EtcOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [detailContext, setDetailContext] = useState<EtcDrillContext>(() => (
|
||||
parseEtcDrillContext(window.location.search)
|
||||
));
|
||||
|
||||
const commitDetailContext = useCallback((next: EtcDrillContext, mode: 'push' | 'replace') => {
|
||||
window.history[mode === 'push' ? 'pushState' : 'replaceState'](
|
||||
null,
|
||||
'',
|
||||
buildEtcDrillUrl(window.location, next),
|
||||
);
|
||||
setDetailContext(next);
|
||||
}, []);
|
||||
|
||||
const selectDetailView = useCallback((next: EtcDetailView) => {
|
||||
commitDetailContext(
|
||||
{ ...detailContext, view: next, page: 1 },
|
||||
next === detailContext.view ? 'replace' : 'push',
|
||||
);
|
||||
}, [commitDetailContext, detailContext]);
|
||||
|
||||
const openDetailView = useCallback((next: EtcDetailView) => {
|
||||
selectDetailView(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
const details = document.getElementById('etc-details');
|
||||
details?.focus({ preventScroll: true });
|
||||
details?.scrollIntoView({
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
});
|
||||
}, [selectDetailView]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search));
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const load = async (force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await fetchEtcOverview(force));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
if (error && !data) return <ErrorState message={error} />;
|
||||
if (!data) return <LoadingState label="正在检查 ETC 接入状态" />;
|
||||
|
||||
const { integration, kpi } = data;
|
||||
const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="flex items-center gap-3 p-3">
|
||||
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${integration.providerConfigured ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
|
||||
{integration.providerConfigured ? <CircleCheck size={18} /> : <CircleX size={18} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-black text-slate-800">
|
||||
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[10px] font-bold text-slate-400">
|
||||
{integration.providerConfigured
|
||||
? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}`
|
||||
: '数据库结构已准备,等待配置同步账户并完成首次同步'}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<motion.div
|
||||
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
||||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
||||
>
|
||||
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
|
||||
>
|
||||
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load(true)}
|
||||
disabled={refreshing}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-blue-600 disabled:opacity-50"
|
||||
aria-label="刷新 ETC 状态"
|
||||
title="刷新 ETC 状态"
|
||||
>
|
||||
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</SurfaceCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<DrilldownMetricButton label={`查看 ETC 通行记录,总费用 ${fmtMoney(kpi.totalAmount)}`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||
<MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} />
|
||||
</DrilldownMetricButton>
|
||||
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.passageCount} 次`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" />
|
||||
</DrilldownMetricButton>
|
||||
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.vehicleCount} 辆车`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" />
|
||||
</DrilldownMetricButton>
|
||||
<DrilldownMetricButton label={`查看 ETC 结算账单,应收 ${fmtMoney(kpi.receivableAmount)}`} title="查看结算账单" onClick={() => openDetailView('bills')}>
|
||||
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
|
||||
</DrilldownMetricButton>
|
||||
</div>
|
||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
||||
通行费明细、按车按月统计、运营成本拆分
|
||||
<br />
|
||||
这些数据都在路上啦
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[10px] font-black text-slate-400">提供方配置</div>
|
||||
<div className={`mt-1 text-sm font-black ${integration.providerConfigured ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||
{integration.providerConfigured ? '已配置' : '未配置'}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[10px] font-black text-slate-400">自动同步</div>
|
||||
<div className={`mt-1 text-sm font-black ${integration.syncEnabled ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{integration.syncEnabled ? '已启用' : '未启用'}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400"><Clock3 size={11} />最近同步</div>
|
||||
<div className="mt-1 text-sm font-black text-slate-700">{syncStatusLabel(integration.lastSyncStatus)}</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">{integration.lastSyncAt || '无同步记录'}</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
{/* 简单的里程碑进度感 */}
|
||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
||||
{[
|
||||
{ label: '需求评审', done: true },
|
||||
{ label: '数据对接', done: true },
|
||||
{ label: '页面开发', done: false, current: true },
|
||||
{ label: '正式上线', done: false },
|
||||
].map((m, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
||||
className="flex items-center gap-2.5 text-[11px]"
|
||||
>
|
||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||
m.done ? 'bg-emerald-400'
|
||||
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
||||
: 'bg-slate-200'
|
||||
}`} />
|
||||
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
||||
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} />
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
<RotatingFooterHint hints={ETC_HINTS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,29 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, CheckCircle2, ChevronRight, MapPin, Plug, TrendingUp, TriangleAlert, Truck, Wallet, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly, fetchElectricOrders } from './api';
|
||||
import type { DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup, ElectricVehicleScope } from './types';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import {
|
||||
buildElectricDrillUrl,
|
||||
parseElectricDrillContext,
|
||||
type ElectricDrillContext,
|
||||
} from './electric-drill-context';
|
||||
import AnalysisDateFilters from './AnalysisDateFilters';
|
||||
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
|
||||
import {
|
||||
dateRangeModeLabel,
|
||||
getQuickDateRange,
|
||||
inferDateRangeMode,
|
||||
normalizeDateRange,
|
||||
type DateRangeMode,
|
||||
} from './date-range';
|
||||
import { reconcileElectricDaily } from './electric-reconciliation';
|
||||
|
||||
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
|
||||
{ id: 'all', label: '全部车辆' },
|
||||
{ id: 'lingniu', label: '羚牛车辆' },
|
||||
{ id: 'external', label: '外部车辆' },
|
||||
];
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
type RangeMode,
|
||||
} from './daily-range/model';
|
||||
import { summarizeElectricMonths } from './electric-daily/model';
|
||||
|
||||
export default function ElectricDaily() {
|
||||
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
|
||||
parseElectricDrillContext(window.location.search)
|
||||
));
|
||||
const [vehicleScope, setVehicleScope] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
|
||||
const [pick, setPick] = useState<DateRangeMode>(() => {
|
||||
if (drillContext.startDate && drillContext.endDate) {
|
||||
return inferDateRangeMode(drillContext.startDate, drillContext.endDate);
|
||||
}
|
||||
return 'last15';
|
||||
});
|
||||
const [dateRange, setDateRange] = useState(() => {
|
||||
if (drillContext.startDate && drillContext.endDate) {
|
||||
return normalizeDateRange(drillContext.startDate, drillContext.endDate);
|
||||
}
|
||||
if (drillContext.selectedDate) {
|
||||
return { start: drillContext.selectedDate, end: drillContext.selectedDate };
|
||||
}
|
||||
return getQuickDateRange('last15');
|
||||
});
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orders, setOrders] = useState<ElectricChargeOrderResponse | null>(null);
|
||||
const [ordersError, setOrdersError] = useState<string | null>(null);
|
||||
const orderTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeDateRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
const selectedDate = drillContext.selectedDate;
|
||||
const selectedDateRef = useRef(selectedDate);
|
||||
selectedDateRef.current = selectedDate;
|
||||
|
||||
const commitDrillContext = useCallback((next: ElectricDrillContext, mode: 'push' | 'replace' = 'replace') => {
|
||||
const url = buildElectricDrillUrl(window.location, next);
|
||||
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
|
||||
setDrillContext(next);
|
||||
}, []);
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -72,32 +31,16 @@ export default function ElectricDaily() {
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchElectricMonthly(vehicleScope, query)
|
||||
fetchElectricMonthly(customer, query)
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
setMonths(m);
|
||||
if (m.length > 0) {
|
||||
setOpenMonths(new Set([selectedDateRef.current?.slice(0, 7) || m[0].month]));
|
||||
}
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [vehicleScope, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate) {
|
||||
setOrders(null);
|
||||
setOrdersError(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setOrders(null);
|
||||
setOrdersError(null);
|
||||
fetchElectricOrders(selectedDate, vehicleScope)
|
||||
.then(result => { if (!cancelled) setOrders(result); })
|
||||
.catch(e => { if (!cancelled) setOrdersError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedDate, vehicleScope]);
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -105,126 +48,50 @@ export default function ElectricDaily() {
|
||||
return next;
|
||||
});
|
||||
|
||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
||||
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
|
||||
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
|
||||
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
|
||||
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
|
||||
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
||||
const scopeLabel = dateRangeModeLabel(pick);
|
||||
const {
|
||||
totalKwh,
|
||||
totalFee,
|
||||
activeDays,
|
||||
abnormalDays,
|
||||
avgKwh,
|
||||
avgPrice,
|
||||
hasFeeDetail,
|
||||
} = useMemo(() => summarizeElectricMonths(months), [months]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const showExternalEmpty = vehicleScope === 'external' && months !== null && totalKwh === 0;
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
const nextRange = getQuickDateRange(nextPick);
|
||||
setPick(nextPick);
|
||||
setDateRange(nextRange);
|
||||
commitDrillContext({
|
||||
vehicleScope,
|
||||
startDate: nextRange.start,
|
||||
endDate: nextRange.end,
|
||||
});
|
||||
setDateRange(getQuickRange(nextPick));
|
||||
};
|
||||
|
||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||
if (!value) return;
|
||||
const nextRange = { ...dateRange, [field]: value };
|
||||
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
|
||||
setPick('custom');
|
||||
setDateRange(normalized);
|
||||
commitDrillContext({
|
||||
vehicleScope,
|
||||
startDate: normalized.start,
|
||||
endDate: normalized.end,
|
||||
});
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updateVehicleScope = (next: ElectricVehicleScope) => {
|
||||
setVehicleScope(next);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: next,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDate = (date: string) => {
|
||||
const opening = selectedDate !== date;
|
||||
if (opening && document.activeElement instanceof HTMLButtonElement) {
|
||||
orderTriggerRef.current = document.activeElement;
|
||||
}
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
selectedDate: opening ? date : undefined,
|
||||
}, opening ? 'push' : 'replace');
|
||||
window.requestAnimationFrame(() => {
|
||||
if (opening) {
|
||||
const details = document.getElementById(`electric-orders-${date}`);
|
||||
details?.focus({ preventScroll: true });
|
||||
details?.scrollIntoView({
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
} else {
|
||||
const trigger = orderTriggerRef.current ?? document.getElementById(`electric-date-trigger-${date}`);
|
||||
if (trigger instanceof HTMLElement) trigger.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const next = parseElectricDrillContext(window.location.search);
|
||||
setDrillContext(next);
|
||||
setVehicleScope(next.vehicleScope);
|
||||
if (next.startDate && next.endDate) {
|
||||
const normalized = normalizeDateRange(next.startDate, next.endDate);
|
||||
setPick(inferDateRangeMode(normalized.start, normalized.end));
|
||||
setDateRange(normalized);
|
||||
} else if (next.selectedDate) {
|
||||
setPick('custom');
|
||||
setDateRange({ start: next.selectedDate, end: next.selectedDate });
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<AnalysisDateFilters
|
||||
idPrefix="electric"
|
||||
mode={pick}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustom={() => setPick('custom')}
|
||||
onDateChange={updateDateRange}
|
||||
/>
|
||||
|
||||
<AnalysisScopeSwitch
|
||||
ariaLabel="电能车辆范围"
|
||||
value={vehicleScope}
|
||||
options={VEHICLE_SCOPE_OPTIONS}
|
||||
onChange={updateVehicleScope}
|
||||
icon={Truck}
|
||||
className="mt-2"
|
||||
/>
|
||||
</SurfaceCard>
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
|
||||
<MetricTile
|
||||
icon={Wallet}
|
||||
label="充电费用"
|
||||
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
helper={`综合费用强度 ${avgPrice.toFixed(2)} 元/度`}
|
||||
tone="emerald"
|
||||
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
|
||||
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
|
||||
tone={hasFeeDetail ? 'emerald' : 'slate'}
|
||||
/>
|
||||
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
|
||||
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
|
||||
@@ -260,23 +127,20 @@ export default function ElectricDaily() {
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<ErrorState message={error} variant="inline" />
|
||||
<div className="p-3"><ErrorState message={error} /></div>
|
||||
) : months === null ? (
|
||||
<LoadingState label="正在加载充电明细" variant="inline" />
|
||||
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
|
||||
) : months.length === 0 ? (
|
||||
<EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" variant="inline" />
|
||||
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
|
||||
) : months.map(m => {
|
||||
const open = openMonths.has(m.month);
|
||||
return (
|
||||
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMonth(m.month)}
|
||||
className={`w-full grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2.5 text-left transition-colors ${
|
||||
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `electric-month-${m.month}` : undefined}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
@@ -290,7 +154,6 @@ export default function ElectricDaily() {
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
id={`electric-month-${m.month}`}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
@@ -302,121 +165,16 @@ export default function ElectricDaily() {
|
||||
const abnormalBg = isAbnormal
|
||||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: 'bg-slate-50/50';
|
||||
const reconciliation = orders?.date === d.date
|
||||
? reconcileElectricDaily(d, orders, vehicleScope)
|
||||
: null;
|
||||
return (
|
||||
<div key={d.date} className="border-t border-slate-100">
|
||||
<button
|
||||
id={`electric-date-trigger-${d.date}`}
|
||||
type="button"
|
||||
onClick={() => toggleDate(d.date)}
|
||||
className={`grid w-full grid-cols-[minmax(0,1fr)_120px_88px] gap-3 px-3 py-2 pl-9 text-left md:grid-cols-[minmax(0,1fr)_160px_120px] ${abnormalBg}`}
|
||||
aria-expanded={selectedDate === d.date}
|
||||
aria-controls={`electric-orders-${d.date}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] font-bold text-slate-600">
|
||||
<ChevronRight size={12} className={`text-slate-400 transition-transform ${selectedDate === d.date ? 'rotate-90' : ''}`} />
|
||||
{d.date.slice(5)}
|
||||
</span>
|
||||
<span className="text-right text-[12px] font-bold tabular-nums text-slate-700">
|
||||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||||
</button>
|
||||
{selectedDate === d.date && (
|
||||
<div
|
||||
id={`electric-orders-${d.date}`}
|
||||
tabIndex={-1}
|
||||
aria-label={`${d.date} 充电订单明细`}
|
||||
className="scroll-mt-3 border-t border-blue-100 bg-blue-50/40 px-3 py-3 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:pl-9"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-black text-blue-800">
|
||||
<Plug size={13} className="shrink-0" />
|
||||
<span className="truncate">{d.date} 充电订单</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDate(d.date)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 ring-1 ring-blue-100 hover:text-blue-700"
|
||||
aria-label={`关闭 ${d.date} 充电订单`}
|
||||
title="关闭订单明细"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{orders && reconciliation && (
|
||||
<div
|
||||
role={reconciliation.matches ? 'status' : 'alert'}
|
||||
className={`mb-2 flex items-start gap-2 rounded-lg border px-3 py-2 text-[10px] font-bold ${
|
||||
reconciliation.matches
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'
|
||||
}`}
|
||||
>
|
||||
{reconciliation.matches
|
||||
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
|
||||
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
|
||||
<span>
|
||||
{reconciliation.matches
|
||||
? `口径核对通过:日汇总与 ${orders.recordCount} 笔订单全量合计一致`
|
||||
: `口径存在差异:电量 ${reconciliation.kwhDifference >= 0 ? '+' : ''}${reconciliation.kwhDifference.toFixed(2)} 度 · 费用 ${reconciliation.feeDifference >= 0 ? '+' : ''}${reconciliation.feeDifference.toFixed(2)} 元${!reconciliation.dateMatches || !reconciliation.scopeMatches ? ' · 日期或车辆范围不一致' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ordersError ? (
|
||||
<ErrorState message={ordersError} variant="inline" />
|
||||
) : !orders || orders.date !== d.date ? (
|
||||
<LoadingState label="正在加载充电订单" variant="inline" />
|
||||
) : orders.items.length === 0 ? (
|
||||
<EmptyState title="当日无充电订单" description="当前车辆归属下没有订单明细" variant="inline" />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-blue-100 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-blue-100 bg-blue-50 px-3 py-2 text-[10px] font-bold text-blue-700">
|
||||
<span>{orders.recordCount} 笔 · {orders.totalKwh.toLocaleString('zh-CN')} 度</span>
|
||||
<span>合计 ¥{orders.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
{orders.truncated && (
|
||||
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[10px] font-bold text-amber-700">
|
||||
明细超过 500 笔,当前仅展示前 500 笔;顶部合计仍为全量口径。
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 bg-slate-50 px-3 py-1.5 text-[10px] font-bold text-slate-400 md:grid-cols-[minmax(0,1fr)_100px_120px]">
|
||||
<span>时间 / 站点 / 车辆</span>
|
||||
<span className="text-right">电量</span>
|
||||
<span className="text-right">费用</span>
|
||||
</div>
|
||||
{orders.items.map(order => (
|
||||
<div key={order.id} className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 px-3 py-2 last:border-b-0 md:grid-cols-[minmax(0,1fr)_100px_120px]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[11px] font-black text-slate-700">
|
||||
<span>{order.startTime.slice(11, 16)}</span>
|
||||
<span className="truncate text-blue-600">{order.plate}</span>
|
||||
<span className="shrink-0 rounded bg-slate-100 px-1 py-0.5 text-[9px] text-slate-500">{order.orderStatus}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1 text-[10px] font-bold text-slate-400">
|
||||
<MapPin size={10} className="shrink-0" />
|
||||
<span className="truncate">{order.stationName}</span>
|
||||
<span className="hidden shrink-0 md:inline">· {order.orderNo}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[11px] font-black tabular-nums text-slate-700">
|
||||
{order.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
<div className="mt-1 text-[9px] font-bold text-slate-400">度</div>
|
||||
</div>
|
||||
<div className="text-right text-[11px] font-black tabular-nums text-emerald-600">
|
||||
¥{order.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
<div className="mt-1 text-[9px] font-bold text-slate-400">
|
||||
电 {order.electricityFee.toLocaleString('zh-CN')} · 服 {order.serviceFee.toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={d.date}
|
||||
className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
|
||||
>
|
||||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,7 +4,6 @@ import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||
import SubTabs from './SubTabs';
|
||||
import { useHashSubTab } from './useHashSubTab';
|
||||
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||
import BiHeaderActions from '../../components/BiHeaderActions';
|
||||
|
||||
const SUB_TABS = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
@@ -22,18 +21,11 @@ export default function ElectricModule() {
|
||||
icon={CalendarDays}
|
||||
eyebrow="ELECTRIC BI"
|
||||
meta="时间单位清晰标注 · 支持日/总览切换"
|
||||
actions={<BiHeaderActions domain="electric" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="electric-view" ariaLabel="电能分析视图" />
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={sub}>
|
||||
<div
|
||||
id={`electric-view-panel-${sub}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`electric-view-tab-${sub}`}
|
||||
>
|
||||
<ElectricView sub={sub} />
|
||||
</div>
|
||||
<ElectricView sub={sub} />
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
</PageFrame>
|
||||
|
||||
@@ -4,8 +4,6 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, Refere
|
||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import { buildElectricDrillUrl } from './electric-drill-context';
|
||||
import { activateDrillOnKeyDown } from './drill-interaction';
|
||||
|
||||
function fmtYuan(yuan: number) {
|
||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
@@ -13,11 +11,6 @@ function fmtYuan(yuan: number) {
|
||||
function fmtKwh(kwh: number) {
|
||||
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
|
||||
}
|
||||
function fmtMonth(month: string | undefined) {
|
||||
if (!month) return '当前月';
|
||||
const [year, monthNumber] = month.split('-');
|
||||
return `${year}年${Number(monthNumber)}月`;
|
||||
}
|
||||
|
||||
export default function ElectricOverview() {
|
||||
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
|
||||
@@ -41,50 +34,33 @@ export default function ElectricOverview() {
|
||||
const trendData = data.trend;
|
||||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
||||
const trendMonthText = fmtMonth(trendMonthLabel);
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||
? `${trendMonthLabel} 每日充电`
|
||||
: '本月每日充电';
|
||||
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
||||
const trendKwh = trendData.reduce((sum, item) => sum + item.kwh, 0);
|
||||
const trendFee = trendData.reduce((sum, item) => sum + item.fee, 0);
|
||||
const avgDailyKwh = activeDays > 0 ? trendKwh / activeDays : 0;
|
||||
const avgDailyFee = activeDays > 0 ? trendFee / activeDays : 0;
|
||||
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
|
||||
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
|
||||
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
|
||||
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
|
||||
const trendPrice = trendKwh > 0 ? trendFee / trendKwh : 0;
|
||||
const openDay = (date: string) => {
|
||||
const url = buildElectricDrillUrl(
|
||||
{ pathname: window.location.pathname, search: window.location.search, hash: '#electric' },
|
||||
{
|
||||
vehicleScope: 'all',
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
selectedDate: date,
|
||||
},
|
||||
);
|
||||
window.history.pushState(null, '', url);
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
};
|
||||
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||
数据截至 {data.latestRecordAt ?? '暂无记录'} · 龙王路停车场充电站 · 手工导入
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
</div>
|
||||
{/* 横向 mini KPI 头 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||||
<MetricTile icon={CalendarClock} label={`${trendMonthText}充电费`} value={fmtYuan(trendFee)} helper={fmtKwh(trendKwh)} tone="emerald" />
|
||||
<MetricTile icon={Gauge} label="综合费用强度" value={avgPrice.toFixed(2)} unit="元/度" helper={`${trendMonthText} ${trendPrice.toFixed(2)} 元/度 · 含零费用订单`} tone="amber" />
|
||||
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
|
||||
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[11px] font-black text-slate-400">{trendMonthText}有效充电日</div>
|
||||
<div className="text-[11px] font-black text-slate-400">有效充电日</div>
|
||||
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400">天</span></div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||||
</SurfaceCard>
|
||||
@@ -95,12 +71,12 @@ export default function ElectricOverview() {
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[11px] font-black text-slate-400">月度占比</div>
|
||||
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (trendFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">{trendMonthText}费用 / 累计费用</div>
|
||||
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">本月费用 / 累计费用</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
|
||||
{/* 当前月无数据时展示最近一个有数据的自然月 */}
|
||||
{/* 本月每日充电柱图 */}
|
||||
<SurfaceCard className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||
@@ -133,17 +109,8 @@ export default function ElectricOverview() {
|
||||
/>
|
||||
)}
|
||||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map(item => (
|
||||
<Cell
|
||||
key={item.date}
|
||||
fill="url(#electricBarGrad)"
|
||||
className="cursor-pointer outline-none focus:stroke-blue-700 focus:stroke-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${item.date},充电费用 ${fmtYuan(item.fee)},查看全部车辆订单`}
|
||||
onClick={() => openDay(item.date)}
|
||||
onKeyDown={event => activateDrillOnKeyDown(event, () => openDay(item.date))}
|
||||
/>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Receipt } from 'lucide-react';
|
||||
import ETCView from './ETCView';
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import BiHeaderActions from '../../components/BiHeaderActions';
|
||||
|
||||
export default function EtcModule() {
|
||||
return (
|
||||
<PageFrame
|
||||
title="ETC 通行费看板"
|
||||
subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
|
||||
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
|
||||
icon={Receipt}
|
||||
eyebrow="ETC BI"
|
||||
meta="真实接入状态 · 指标口径已发布"
|
||||
actions={<BiHeaderActions domain="etc" />}
|
||||
meta="数据对接中 · 页面能力预留"
|
||||
>
|
||||
<ETCView />
|
||||
</PageFrame>
|
||||
|
||||
@@ -1,108 +1,55 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Building2, ChevronRight, Fuel, Plug, ReceiptText, TrendingUp, Truck, UserRound, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import {
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
summarizeHydrogenRows,
|
||||
type RangeMode,
|
||||
} from './hydrogen-daily/model';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import {
|
||||
buildHydrogenDrillUrl,
|
||||
parseHydrogenDrillContext,
|
||||
type HydrogenDrillContext,
|
||||
} from './hydrogen-drill-context';
|
||||
import HydrogenOrders from './HydrogenOrders';
|
||||
import AnalysisDateFilters from './AnalysisDateFilters';
|
||||
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
|
||||
import { activateDrillOnKeyDown } from './drill-interaction';
|
||||
import {
|
||||
dateRangeModeLabel,
|
||||
getQuickDateRange,
|
||||
inferDateRangeMode,
|
||||
normalizeDateRange,
|
||||
type DateRangeMode,
|
||||
} from './date-range';
|
||||
|
||||
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [
|
||||
{ id: 'lingniu', label: '羚牛车辆' },
|
||||
{ id: 'external', label: '外部车辆' },
|
||||
];
|
||||
|
||||
const HYDROGEN_ORDERS_ID = 'hydrogen-orders';
|
||||
|
||||
export default function HydrogenDaily() {
|
||||
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
|
||||
parseHydrogenDrillContext(window.location.search)
|
||||
));
|
||||
const [pick, setPick] = useState<DateRangeMode>(() => (
|
||||
drillContext.startDate && drillContext.endDate
|
||||
? inferDateRangeMode(drillContext.startDate, drillContext.endDate)
|
||||
: 'last15'
|
||||
));
|
||||
const [dateRange, setDateRange] = useState(() => (
|
||||
drillContext.startDate && drillContext.endDate
|
||||
? normalizeDateRange(drillContext.startDate, drillContext.endDate)
|
||||
: getQuickDateRange('last15')
|
||||
));
|
||||
const [vehicleScope, setVehicleScope] = useState<CustomerType>(drillContext.vehicleScope);
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const orderTriggerRef = useRef<HTMLElement | SVGElement | null>(null);
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeDateRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined;
|
||||
const selectedStationName = drillContext.level === 'station'
|
||||
? drillContext.stationName || `站点 #${drillContext.stationId}`
|
||||
: undefined;
|
||||
const selectedCustomerName = drillContext.level === 'customer'
|
||||
? drillContext.customerName
|
||||
: undefined;
|
||||
const selectedDate = drillContext.selectedDate;
|
||||
const selectedDailyRow = rows?.find(row => row.date === selectedDate);
|
||||
|
||||
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
|
||||
const url = buildHydrogenDrillUrl(window.location, next);
|
||||
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
|
||||
setDrillContext(next);
|
||||
}, []);
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
|
||||
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
|
||||
fetchHydrogenDaily(query, vehicleScope)
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchHydrogenDaily(query, customer)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, vehicleScope, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]);
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
// 柱图:按日期升序,用于"从左到右时间流"
|
||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
||||
const stationCount = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
(rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId)));
|
||||
return ids.size;
|
||||
}, [rows]);
|
||||
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
||||
const scopeLabel = dateRangeModeLabel(pick);
|
||||
const {
|
||||
trendData,
|
||||
totalKg,
|
||||
activeDays,
|
||||
stationCount,
|
||||
avgKg,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays,
|
||||
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const orderScopeLabel = selectedCustomerName
|
||||
? `客户 ${selectedCustomerName}`
|
||||
: selectedStationName
|
||||
? `站点 ${selectedStationName}`
|
||||
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
|
||||
const lowDay = trendData
|
||||
.filter(item => item.totalKg > 0)
|
||||
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
|
||||
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
|
||||
|
||||
const toggle = (date: string) => setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -111,197 +58,37 @@ export default function HydrogenDaily() {
|
||||
});
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
const nextRange = getQuickDateRange(nextPick);
|
||||
setPick(nextPick);
|
||||
setDateRange(nextRange);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope,
|
||||
startDate: nextRange.start,
|
||||
endDate: nextRange.end,
|
||||
selectedDate: undefined,
|
||||
orderPage: undefined,
|
||||
}, 'replace');
|
||||
setDateRange(getQuickRange(nextPick));
|
||||
};
|
||||
|
||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||
if (!value) return;
|
||||
const nextRange = { ...dateRange, [field]: value };
|
||||
setPick('custom');
|
||||
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
|
||||
setDateRange(normalized);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope,
|
||||
startDate: normalized.start,
|
||||
endDate: normalized.end,
|
||||
selectedDate: undefined,
|
||||
orderPage: undefined,
|
||||
}, 'replace');
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updateVehicleScope = (next: CustomerType) => {
|
||||
setVehicleScope(next);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: next,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
orderPage: undefined,
|
||||
}, 'replace');
|
||||
};
|
||||
|
||||
const clearEntity = () => {
|
||||
commitDrillContext({
|
||||
level: 'overview',
|
||||
year: drillContext.year,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
}, 'replace');
|
||||
};
|
||||
|
||||
const openOrders = (date: string) => {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLElement || activeElement instanceof SVGElement) {
|
||||
orderTriggerRef.current = activeElement;
|
||||
}
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
selectedDate: date,
|
||||
orderPage: undefined,
|
||||
}, selectedDate === date ? 'replace' : 'push');
|
||||
window.requestAnimationFrame(() => {
|
||||
const orders = document.getElementById(HYDROGEN_ORDERS_ID);
|
||||
orders?.focus({ preventScroll: true });
|
||||
orders?.scrollIntoView({
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const closeOrders = () => {
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
selectedDate: undefined,
|
||||
orderPage: undefined,
|
||||
}, 'replace');
|
||||
window.requestAnimationFrame(() => orderTriggerRef.current?.focus());
|
||||
};
|
||||
|
||||
const changeOrderPage = useCallback((page: number, mode: 'push' | 'replace') => {
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
orderPage: page > 1 ? page : undefined,
|
||||
}, mode);
|
||||
}, [commitDrillContext, drillContext]);
|
||||
|
||||
const retryLoad = () => {
|
||||
setRows(null);
|
||||
setError(null);
|
||||
setRetryKey(key => key + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const next = parseHydrogenDrillContext(window.location.search);
|
||||
setDrillContext(next);
|
||||
setVehicleScope(next.vehicleScope);
|
||||
if (next.startDate && next.endDate) {
|
||||
const normalized = normalizeDateRange(next.startDate, next.endDate);
|
||||
setPick(inferDateRangeMode(normalized.start, normalized.end));
|
||||
setDateRange(normalized);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<AnalysisDateFilters
|
||||
idPrefix="hydrogen"
|
||||
mode={pick}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustom={() => setPick('custom')}
|
||||
onDateChange={updateDateRange}
|
||||
/>
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
|
||||
<AnalysisScopeSwitch
|
||||
ariaLabel="氢能车辆范围"
|
||||
value={vehicleScope}
|
||||
options={VEHICLE_SCOPE_OPTIONS}
|
||||
onChange={updateVehicleScope}
|
||||
icon={Truck}
|
||||
className="mt-2"
|
||||
/>
|
||||
</SurfaceCard>
|
||||
|
||||
{selectedStationName && (
|
||||
<section className="flex items-center gap-3 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-blue-700 shadow-sm">
|
||||
<Building2 size={16} className="shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-black">{selectedStationName}</div>
|
||||
<div className="mt-0.5 text-[10px] font-bold text-blue-500">站点每日明细 · {rangeText}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearEntity}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 shadow-sm ring-1 ring-blue-100 hover:text-blue-700"
|
||||
aria-label="清除站点筛选"
|
||||
title="清除站点筛选"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedCustomerName && (
|
||||
<section className="flex items-center gap-3 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-emerald-700 shadow-sm">
|
||||
<UserRound size={16} className="shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-black">{selectedCustomerName}</div>
|
||||
<div className="mt-0.5 text-[10px] font-bold text-emerald-600">客户每日明细 · {rangeText}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearEntity}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-emerald-600 shadow-sm ring-1 ring-emerald-100 hover:text-emerald-800"
|
||||
aria-label="清除客户筛选"
|
||||
title="清除客户筛选"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ErrorState
|
||||
title="氢能数据源暂不可用"
|
||||
message={error}
|
||||
onRetry={retryLoad}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={vehicleScope === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{!error && vehicleScope === 'external' && rows !== null && totalKg === 0 && (
|
||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -322,7 +109,7 @@ export default function HydrogenDaily() {
|
||||
)}
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!error && !(vehicleScope === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
<SurfaceCard>
|
||||
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||
@@ -382,23 +169,8 @@ export default function HydrogenDaily() {
|
||||
/>
|
||||
)}
|
||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map(item => (
|
||||
<Cell
|
||||
key={item.date}
|
||||
fill="url(#hydrogenBarGrad)"
|
||||
stroke={selectedDate === item.date ? '#1d4ed8' : undefined}
|
||||
strokeWidth={selectedDate === item.date ? 2 : undefined}
|
||||
className={item.totalKg > 0 ? 'cursor-pointer outline-none focus:stroke-blue-700 focus:stroke-2' : undefined}
|
||||
role={item.totalKg > 0 ? 'button' : undefined}
|
||||
tabIndex={item.totalKg > 0 ? 0 : -1}
|
||||
aria-expanded={item.totalKg > 0 ? selectedDate === item.date : undefined}
|
||||
aria-controls={item.totalKg > 0 ? HYDROGEN_ORDERS_ID : undefined}
|
||||
aria-label={`${item.date},加氢量 ${item.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg${item.totalKg > 0 ? `,查看${orderScopeLabel}加氢订单` : ',无可下钻订单'}`}
|
||||
onClick={item.totalKg > 0 ? () => openOrders(item.date) : undefined}
|
||||
onKeyDown={item.totalKg > 0
|
||||
? event => activateDrillOnKeyDown(event, () => openOrders(item.date))
|
||||
: undefined}
|
||||
/>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
@@ -413,41 +185,30 @@ export default function HydrogenDaily() {
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{selectedDate && (
|
||||
<HydrogenOrders
|
||||
date={selectedDate}
|
||||
dailyRow={selectedDailyRow}
|
||||
page={drillContext.orderPage ?? 1}
|
||||
vehicleScope={vehicleScope}
|
||||
stationId={selectedStationId}
|
||||
customerName={selectedCustomerName}
|
||||
onPageChange={changeOrderPage}
|
||||
onClose={closeOrders}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
{!error && !(vehicleScope === 'external' && rows !== null && totalKg === 0) && (
|
||||
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<span>日期 / 加氢站</span>
|
||||
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
||||
<span className="text-right">加氢量 (Kg)</span>
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{/* 合计行 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
||||
<span>合计</span>
|
||||
<span className="hidden md:block" />
|
||||
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||
<span />
|
||||
</div>
|
||||
{/* 主行 + 子行 */}
|
||||
{rows === null ? (
|
||||
<LoadingState label="正在加载加氢明细" variant="inline" />
|
||||
{error ? (
|
||||
<div className="p-3"><ErrorState message={error} /></div>
|
||||
) : rows === null ? (
|
||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" />
|
||||
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
||||
) : rows.map(r => {
|
||||
const open = expanded.has(r.date);
|
||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||
@@ -456,37 +217,20 @@ export default function HydrogenDaily() {
|
||||
: '';
|
||||
return (
|
||||
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
||||
<div className={`grid w-full grid-cols-[minmax(0,1fr)_72px_64px] gap-2 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60 md:grid-cols-[minmax(0,1fr)_140px_120px_104px] md:gap-3 ${selectedDate === r.date ? 'bg-blue-50/70' : ''}`}>
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(r.date)}
|
||||
className="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700"
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
|
||||
>
|
||||
<ChevronRight size={14} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
<span className="truncate">{r.date}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openOrders(r.date)}
|
||||
disabled={r.totalKg <= 0}
|
||||
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-blue-100 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-25"
|
||||
aria-label={`查看 ${r.date} 加氢订单`}
|
||||
aria-expanded={selectedDate === r.date}
|
||||
aria-controls={HYDROGEN_ORDERS_ID}
|
||||
title="查看当日订单"
|
||||
>
|
||||
<ReceiptText size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggle(r.date)}
|
||||
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
{r.date}
|
||||
</span>
|
||||
<span className="hidden md:block text-right text-[12px] text-slate-300">—</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
@@ -499,7 +243,7 @@ export default function HydrogenDaily() {
|
||||
{r.stations.map(s => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
||||
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
||||
@@ -528,7 +272,7 @@ export default function HydrogenDaily() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!error && <RotatingFooterHint />}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||
import SubTabs from './SubTabs';
|
||||
import { useHashSubTab } from './useHashSubTab';
|
||||
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||
import BiHeaderActions from '../../components/BiHeaderActions';
|
||||
|
||||
const SUB_TABS = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
@@ -22,18 +21,11 @@ export default function HydrogenModule() {
|
||||
icon={CalendarDays}
|
||||
eyebrow="ENERGY BI"
|
||||
meta="数据单位清晰标注 · 支持日/总览切换"
|
||||
actions={<BiHeaderActions domain="hydrogen" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="hydrogen-view" ariaLabel="氢能分析视图" />
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={sub}>
|
||||
<div
|
||||
id={`hydrogen-view-panel-${sub}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`hydrogen-view-tab-${sub}`}
|
||||
>
|
||||
<HydrogenView sub={sub} />
|
||||
</div>
|
||||
<HydrogenView sub={sub} />
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
</PageFrame>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, TriangleAlert, X } from 'lucide-react';
|
||||
import Blur from '../../components/Blur';
|
||||
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
|
||||
import { fetchHydrogenOrders } from './api';
|
||||
import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
|
||||
import { reconcileHydrogenDaily } from './hydrogen-reconciliation';
|
||||
|
||||
function fmtMoney(value: number): string {
|
||||
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export default function HydrogenOrders({
|
||||
date,
|
||||
dailyRow,
|
||||
page,
|
||||
vehicleScope,
|
||||
stationId,
|
||||
customerName,
|
||||
onPageChange,
|
||||
onClose,
|
||||
}: {
|
||||
date: string;
|
||||
dailyRow?: HydrogenDailyRow;
|
||||
page: number;
|
||||
vehicleScope: CustomerType;
|
||||
stationId?: number;
|
||||
customerName?: string;
|
||||
onPageChange: (page: number, mode: 'push' | 'replace') => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [data, setData] = useState<HydrogenOrderResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
fetchHydrogenOrders({ date, customer: vehicleScope, stationId, customerName, page, limit: 20 })
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
if (page > result.totalPages) {
|
||||
onPageChange(result.totalPages, 'replace');
|
||||
return;
|
||||
}
|
||||
setData(result);
|
||||
})
|
||||
.catch(loadError => {
|
||||
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [vehicleScope, customerName, date, onPageChange, page, retryKey, stationId]);
|
||||
|
||||
const totalPages = data?.totalPages ?? 1;
|
||||
const scope = customerName
|
||||
? `客户:${customerName}`
|
||||
: stationId !== undefined
|
||||
? `站点 #${stationId}`
|
||||
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
|
||||
const reconciliation = data && dailyRow
|
||||
? reconcileHydrogenDaily(dailyRow, data, vehicleScope, stationId, customerName)
|
||||
: null;
|
||||
const grainDifferences = reconciliation
|
||||
? [
|
||||
!reconciliation.dateMatches && '日期',
|
||||
!reconciliation.scopeMatches && '车辆范围',
|
||||
!reconciliation.entityMatches && '站点/客户范围',
|
||||
].filter(Boolean).join('、')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<section
|
||||
id="hydrogen-orders"
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-3 overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-blue-100 bg-blue-50/60 px-3 py-3 md:px-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs font-black text-blue-800">
|
||||
<ReceiptText size={15} />{date} 加氢订单
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[10px] font-bold text-blue-500">{scope} · 按加氢时间倒序</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 ring-1 ring-blue-100 hover:text-blue-700"
|
||||
aria-label="关闭加氢订单明细"
|
||||
title="关闭订单明细"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[10px] font-bold md:px-4">
|
||||
<div className="min-w-0"><span className="text-slate-400">订单 / 加氢量</span><div className="mt-1 break-words text-slate-700">{data.recordCount} 笔 · {data.totalKg.toLocaleString('zh-CN')} Kg</div></div>
|
||||
<div className="min-w-0"><span className="text-slate-400">采购成本</span><div className="mt-1 break-all text-slate-700">{fmtMoney(data.totalCost)}</div></div>
|
||||
<div className="min-w-0"><span className="text-slate-400">客户收入</span><div className="mt-1 break-all text-emerald-700">{fmtMoney(data.totalRevenue)}</div></div>
|
||||
</div>
|
||||
{reconciliation && (
|
||||
<div
|
||||
role={reconciliation.matches ? 'status' : 'alert'}
|
||||
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
|
||||
reconciliation.matches
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'
|
||||
}`}
|
||||
>
|
||||
{reconciliation.matches
|
||||
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
|
||||
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
|
||||
<span>
|
||||
{reconciliation.matches
|
||||
? `口径核对通过:日汇总与 ${data.recordCount} 笔订单全量加氢量一致`
|
||||
: `口径存在差异:加氢量 ${reconciliation.kgDifference >= 0 ? '+' : ''}${reconciliation.kgDifference.toFixed(2)} Kg${grainDifferences ? ` · ${grainDifferences}不一致` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<ErrorState
|
||||
title="订单数据暂不可用"
|
||||
message={error}
|
||||
onRetry={() => setRetryKey(key => key + 1)}
|
||||
variant="inline"
|
||||
/>
|
||||
) : loading && !data ? (
|
||||
<LoadingState label="正在加载加氢订单" variant="inline" />
|
||||
) : data?.items.length === 0 ? (
|
||||
<EmptyState title="当前筛选下无加氢订单" description="请切换日期、车辆归属或实体范围" variant="inline" />
|
||||
) : data ? (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full min-w-[820px] text-left text-[11px]">
|
||||
<thead className="text-[10px] font-black text-slate-400">
|
||||
<tr><th className="px-4 py-2">时间 / 车辆</th><th className="px-3 py-2">客户 / 站点</th><th className="px-3 py-2 text-right">加氢量</th><th className="px-3 py-2 text-right">采购成本</th><th className="px-4 py-2 text-right">客户收入</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.items.map(order => (
|
||||
<tr key={order.rowNumber}>
|
||||
<td className="px-4 py-3"><div className="font-bold text-slate-600">{order.refuelTime}</div><div className="mt-1 font-mono text-[10px] font-black text-slate-800"><Blur>{order.plate}</Blur></div></td>
|
||||
<td className="px-3 py-3"><div className="font-bold text-slate-700"><Blur>{order.customerName}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.stationName}</div></td>
|
||||
<td className="px-3 py-3 text-right font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</td>
|
||||
<td className="px-3 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(order.costTotal)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">{fmtMoney(order.costPrice)} / Kg</div></td>
|
||||
<td className="px-4 py-3 text-right"><div className="font-black text-emerald-700">{fmtMoney(order.revenue)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">单价 {fmtMoney(order.customerPrice)}</div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 md:hidden">
|
||||
{data.items.map(order => (
|
||||
<article key={order.rowNumber} className="px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0"><div className="font-mono text-xs font-black text-slate-800"><Blur>{order.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.refuelTime}</div></div>
|
||||
<div className="shrink-0 text-right text-sm font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</div>
|
||||
</div>
|
||||
<div className="mt-2 truncate text-[10px] font-bold text-slate-600"><Blur>{order.customerName}</Blur> · {order.stationName}</div>
|
||||
<div className="mt-2 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><span>成本 {fmtMoney(order.costTotal)}</span><span className="text-emerald-700">收入 {fmtMoney(order.revenue)}</span></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
|
||||
<span className="text-[10px] font-bold text-slate-400">第 {page} / {totalPages} 页</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => onPageChange(Math.max(1, page - 1), 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页加氢订单" title="上一页"><ChevronLeft size={14} /></button>
|
||||
<button type="button" onClick={() => onPageChange(Math.min(totalPages, page + 1), 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页加氢订单" title="下一页"><ChevronRight size={14} /></button>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||
} from 'recharts';
|
||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2, ChevronRight, UserRound } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { buildHydrogenDrillUrl } from './hydrogen-drill-context';
|
||||
import { ErrorState } from '../../components/ui/surface';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
|
||||
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
|
||||
import { InsightCards } from './hydrogen-overview/components/InsightCards';
|
||||
import { KpiSection } from './hydrogen-overview/components/KpiSection';
|
||||
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
|
||||
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
|
||||
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
|
||||
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
|
||||
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 数字格式化 ----------
|
||||
function fmtKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
||||
const abs = Math.abs(yuan);
|
||||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
if (abs >= 10_000) {
|
||||
const w = yuan / 10_000;
|
||||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
||||
}
|
||||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
||||
}
|
||||
|
||||
// ---------- KPI 卡 ----------
|
||||
interface KpiCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
}
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||
<span className="text-slate-400">{r.label}</span>
|
||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export default function HydrogenOverview() {
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -121,663 +46,61 @@ export default function HydrogenOverview() {
|
||||
}, [year, load]);
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="氢能数据源暂不可用"
|
||||
message={error}
|
||||
onRetry={() => void load(year, true)}
|
||||
retrying={refreshing}
|
||||
/>
|
||||
);
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <HydrogenOverviewSkeleton />;
|
||||
}
|
||||
const k = data.kpi;
|
||||
const top5 = data.top5;
|
||||
const regions = data.regions;
|
||||
const monthly = data.monthly;
|
||||
const customers = data.customers;
|
||||
const stations = data.stations;
|
||||
const availableYears = data.availableYears;
|
||||
const activeYear = data.year;
|
||||
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||||
const monthKgFmt = fmtKg(k.monthKg);
|
||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
||||
const todayKgFmt = fmtKg(k.todayKg);
|
||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
|
||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||
const monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
|
||||
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
|
||||
const latestMonth = monthly[monthly.length - 1];
|
||||
const prevMonth = monthly[monthly.length - 2];
|
||||
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
|
||||
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
|
||||
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
|
||||
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
|
||||
const openStation = (station: typeof stations[number]) => {
|
||||
const now = new Date();
|
||||
const endDate = activeYear === now.getFullYear()
|
||||
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
: `${activeYear}-12-31`;
|
||||
const url = buildHydrogenDrillUrl(
|
||||
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
|
||||
{
|
||||
level: 'station',
|
||||
year: activeYear,
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
vehicleScope: 'lingniu',
|
||||
startDate: `${activeYear}-01-01`,
|
||||
endDate,
|
||||
},
|
||||
);
|
||||
window.history.pushState(null, '', url);
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
};
|
||||
const openCustomer = (customer: typeof customers[number]) => {
|
||||
const now = new Date();
|
||||
const endDate = activeYear === now.getFullYear()
|
||||
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
: `${activeYear}-12-31`;
|
||||
const url = buildHydrogenDrillUrl(
|
||||
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
|
||||
{
|
||||
level: 'customer',
|
||||
year: activeYear,
|
||||
customerName: customer.name,
|
||||
vehicleScope: 'lingniu',
|
||||
startDate: `${activeYear}-01-01`,
|
||||
endDate,
|
||||
},
|
||||
);
|
||||
window.history.pushState(null, '', url);
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
};
|
||||
|
||||
// 月度收支组合数据(推算"年内每月"图)
|
||||
const monthlyDual = monthly.map(m => ({
|
||||
...m,
|
||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
||||
}));
|
||||
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
|
||||
const {
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
stationAvgKg,
|
||||
monthlyDual,
|
||||
} = deriveOverviewMetrics(data);
|
||||
const yearProfitFmt = fmtYuan(kpi.yearProfit);
|
||||
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => setYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load(year, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewHeader
|
||||
activeYear={activeYear}
|
||||
availableYears={availableYears}
|
||||
lastRefreshAt={lastRefreshAt}
|
||||
refreshing={refreshing}
|
||||
onSelectYear={setYear}
|
||||
onRefresh={() => void load(year, true)}
|
||||
/>
|
||||
|
||||
{/* KPI 5 卡 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
<KpiCard
|
||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-cyan-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢量"
|
||||
hero={yearKgFmt}
|
||||
rows={[
|
||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-blue-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢成本"
|
||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||
rows={[
|
||||
{ label: '公司单成本', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||
{ label: '客户单成本', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-emerald-50"
|
||||
accentClass={profitColor}
|
||||
label="客户单毛利"
|
||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||
rows={[
|
||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||
{ label: '客户单成本', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-amber-50"
|
||||
accentClass="text-amber-600"
|
||||
label="本月加氢"
|
||||
hero={monthKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-violet-50"
|
||||
accentClass="text-violet-600"
|
||||
label="本日加氢"
|
||||
hero={todayKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<KpiSection kpi={kpi} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Gauge size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||
<Building2 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
共 {stations.length} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">客户单毛利率</div>
|
||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{profitYield.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
客户单毛利 {yearProfitFmt.value}{yearProfitFmt.unit} · 客户收入 {yearRevenueFmt.value}{yearRevenueFmt.unit}
|
||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InsightCards
|
||||
monthAvgKg={monthAvgKg}
|
||||
bestMonth={bestMonth}
|
||||
latestMonth={latestMonth}
|
||||
monthMomentum={monthMomentum}
|
||||
top5Share={top5Share}
|
||||
profitYield={profitYield}
|
||||
stationAvgKg={stationAvgKg}
|
||||
stationCount={stations.length}
|
||||
yearProfitValue={yearProfitFmt.value}
|
||||
yearProfitUnit={yearProfitFmt.unit}
|
||||
yearRevenueValue={yearRevenueFmt.value}
|
||||
yearRevenueUnit={yearRevenueFmt.unit}
|
||||
/>
|
||||
|
||||
{/* 月度趋势:年内每月加氢量 */}
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||||
{monthlyDual.map((_, i) => (
|
||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 月度收支对比 */}
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
}}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top5 + 区域占比 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Top5 加氢站 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 区域占比 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加氢站加氢汇总(全量) */}
|
||||
{stations.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">加氢站</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stations.map((s, i) => {
|
||||
const kgFmt = fmtKg(s.kg);
|
||||
const revFmt = fmtYuan(s.revenue);
|
||||
return (
|
||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 max-w-[180px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openStation(s)}
|
||||
className="flex max-w-full items-center gap-1 text-left font-bold text-slate-700 hover:text-blue-600"
|
||||
title={`查看 ${s.name} 每日明细`}
|
||||
>
|
||||
<span className="truncate">{s.name}</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-slate-300" />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 客户账单汇总 Top */}
|
||||
{customers.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">客户</th>
|
||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((c2, i) => {
|
||||
const kgFmt = fmtKg(c2.kg);
|
||||
const costFmt = fmtYuan(c2.cost);
|
||||
const revFmt = fmtYuan(c2.revenue);
|
||||
return (
|
||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 max-w-[200px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCustomer(c2)}
|
||||
className="flex max-w-full items-center gap-1 text-left font-bold text-slate-700 hover:text-blue-600"
|
||||
title={`查看 ${c2.name} 每日明细`}
|
||||
>
|
||||
<UserRound size={12} className="shrink-0 text-slate-300" />
|
||||
<span className="truncate">{c2.name}</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-slate-300" />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||
{c2.payer === 'lingniu' ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
|
||||
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
|
||||
<StationSummaryTable stations={stations} />
|
||||
<CustomerSummaryTable customers={customers} />
|
||||
|
||||
<RotatingFooterHint />
|
||||
|
||||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
||||
<AnimatePresence>
|
||||
{refreshing && data && (
|
||||
<motion.div
|
||||
key="refresh-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
style={{ width: '40%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
if (s < 5) return '刚刚';
|
||||
if (s < 60) return `${s} 秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m} 分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} 小时前`;
|
||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatRefreshTime(ts: number): string {
|
||||
const exactTime = new Date(ts).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
|
||||
}
|
||||
|
||||
function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||
</div>
|
||||
|
||||
{/* 5 卡占位 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 月度柱图占位 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-[120px]">
|
||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
正在加载氢能总览…
|
||||
</div>
|
||||
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,14 +11,12 @@ interface Props<T extends string> {
|
||||
tabs: readonly SubTab<T>[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
idPrefix: string;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export default function SubTabs<T extends string>({ tabs, active, onChange, idPrefix, ariaLabel }: Props<T>) {
|
||||
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||
return (
|
||||
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||
<SegmentedNav tabs={tabs} active={active} onChange={onChange} idPrefix={idPrefix} ariaLabel={ariaLabel} />
|
||||
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type {
|
||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
||||
HydrogenOrderResponse,
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
ElectricChargeOrderResponse,
|
||||
EtcOverviewResponse,
|
||||
EtcBillResponse, EtcTollRecordResponse,
|
||||
CustomerType, DateQuickPick, ElectricVehicleScope,
|
||||
CustomerType, DateQuickPick,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy';
|
||||
@@ -35,92 +31,29 @@ export interface HydrogenDailyQuery {
|
||||
range?: DateQuickPick;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
stationId?: number;
|
||||
customerName?: string;
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, vehicleScope: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ customer: vehicleScope });
|
||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
if (query.stationId !== undefined) q.set('stationId', String(query.stationId));
|
||||
if (query.customerName) q.set('customerName', query.customerName);
|
||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||
}
|
||||
|
||||
export interface HydrogenOrderQuery {
|
||||
date: string;
|
||||
customer: CustomerType;
|
||||
stationId?: number;
|
||||
customerName?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function fetchHydrogenOrders(query: HydrogenOrderQuery): Promise<HydrogenOrderResponse> {
|
||||
const params = new URLSearchParams({
|
||||
date: query.date,
|
||||
customer: query.customer,
|
||||
page: String(query.page ?? 1),
|
||||
limit: String(query.limit ?? 20),
|
||||
});
|
||||
if (query.stationId !== undefined) params.set('stationId', String(query.stationId));
|
||||
if (query.customerName) params.set('customerName', query.customerName);
|
||||
return fetchJson<HydrogenOrderResponse>(`${BASE}/hydrogen/orders?${params}`);
|
||||
}
|
||||
|
||||
export interface ElectricOverviewResponse {
|
||||
kpi: ElectricKpi;
|
||||
trend: ElectricDailyRow[];
|
||||
latestRecordAt: string | null;
|
||||
}
|
||||
|
||||
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(vehicleScope: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer: vehicleScope });
|
||||
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchElectricOrders(date: string, vehicleScope: ElectricVehicleScope): Promise<ElectricChargeOrderResponse> {
|
||||
const q = new URLSearchParams({ date, customer: vehicleScope });
|
||||
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
|
||||
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
|
||||
}
|
||||
|
||||
export interface EtcListQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
function etcListParams(query: EtcListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query.page) params.set('page', String(query.page));
|
||||
if (query.limit) params.set('limit', String(query.limit));
|
||||
if (query.startDate) params.set('startDate', query.startDate);
|
||||
if (query.endDate) params.set('endDate', query.endDate);
|
||||
if (query.search) params.set('search', query.search);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function fetchEtcRecords(query: EtcListQuery): Promise<EtcTollRecordResponse> {
|
||||
const params = etcListParams(query);
|
||||
return fetchJson<EtcTollRecordResponse>(`${BASE}/etc/records${params ? `?${params}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchEtcBills(query: EtcListQuery): Promise<EtcBillResponse> {
|
||||
const params = etcListParams(query);
|
||||
return fetchJson<EtcBillResponse>(`${BASE}/etc/bills${params ? `?${params}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Truck } from 'lucide-react';
|
||||
import { SurfaceCard } from '../../../components/ui/surface';
|
||||
import type { CustomerType, DateQuickPick } from '../types';
|
||||
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
|
||||
|
||||
interface DailyRangeControlsProps {
|
||||
pick: RangeMode;
|
||||
dateRange: { start: string; end: string };
|
||||
customer: CustomerType;
|
||||
onQuickPick: (pick: DateQuickPick) => void;
|
||||
onCustomPick: () => void;
|
||||
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
|
||||
onCustomerChange: (customer: CustomerType) => void;
|
||||
}
|
||||
|
||||
export default function DailyRangeControls({
|
||||
pick,
|
||||
dateRange,
|
||||
customer,
|
||||
onQuickPick,
|
||||
onCustomPick,
|
||||
onDateRangeChange,
|
||||
onCustomerChange,
|
||||
}: DailyRangeControlsProps) {
|
||||
return (
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => onQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={onCustomPick}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === 'custom'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={event => onDateRangeChange('start', event.target.value)}
|
||||
onInput={event => onDateRangeChange('start', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={event => onDateRangeChange('end', event.target.value)}
|
||||
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['lingniu', 'external'] as const).map(option => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => onCustomerChange(option)}
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{option === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DateQuickPick } from '../types';
|
||||
|
||||
export const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
export type RangeMode = DateQuickPick | 'custom';
|
||||
|
||||
export function formatYmd(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Energy quick ranges follow the browser's local calendar day, not UTC. */
|
||||
export function getQuickRange(
|
||||
pick: DateQuickPick,
|
||||
now: Date = new Date(),
|
||||
): { start: string; end: string } {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (pick === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: formatYmd(addDays(today, -(day - 1))), end: formatYmd(today) };
|
||||
}
|
||||
if (pick === 'thisMonth') {
|
||||
return {
|
||||
start: formatYmd(new Date(today.getFullYear(), today.getMonth(), 1)),
|
||||
end: formatYmd(today),
|
||||
};
|
||||
}
|
||||
return { start: formatYmd(addDays(today, -14)), end: formatYmd(today) };
|
||||
}
|
||||
|
||||
export function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||
return start <= end ? { start, end } : { start: end, end: start };
|
||||
}
|
||||
|
||||
export function getRangeModeLabel(mode: RangeMode): string {
|
||||
if (mode === 'custom') return '自定义区间';
|
||||
return QUICK_PICK_OPTIONS.find(item => item.id === mode)?.label ?? '当前时段';
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
dateRangeModeLabel,
|
||||
getQuickDateRange,
|
||||
inferDateRangeMode,
|
||||
normalizeDateRange,
|
||||
} from './date-range.js';
|
||||
|
||||
const FRIDAY = new Date(2026, 7, 7, 16, 30, 0);
|
||||
|
||||
test('builds shared quick date ranges from local calendar days', () => {
|
||||
assert.deepEqual(getQuickDateRange('thisWeek', FRIDAY), { start: '2026-08-03', end: '2026-08-07' });
|
||||
assert.deepEqual(getQuickDateRange('thisMonth', FRIDAY), { start: '2026-08-01', end: '2026-08-07' });
|
||||
assert.deepEqual(getQuickDateRange('last15', FRIDAY), { start: '2026-07-24', end: '2026-08-07' });
|
||||
});
|
||||
|
||||
test('normalizes date order and publishes consistent labels', () => {
|
||||
assert.deepEqual(normalizeDateRange('2026-08-07', '2026-08-01'), { start: '2026-08-01', end: '2026-08-07' });
|
||||
assert.equal(dateRangeModeLabel('thisMonth'), '本月');
|
||||
assert.equal(dateRangeModeLabel('custom'), '自定义区间');
|
||||
});
|
||||
|
||||
test('restores a quick mode only while URL dates still match that calendar range', () => {
|
||||
assert.equal(inferDateRangeMode('2026-08-03', '2026-08-07', FRIDAY), 'thisWeek');
|
||||
assert.equal(inferDateRangeMode('2026-08-01', '2026-08-07', FRIDAY), 'thisMonth');
|
||||
assert.equal(inferDateRangeMode('2026-08-07', '2026-07-24', FRIDAY), 'last15');
|
||||
assert.equal(inferDateRangeMode('2026-08-01', '2026-08-06', FRIDAY), 'custom');
|
||||
assert.equal(
|
||||
inferDateRangeMode('2026-08-01', '2026-08-07', new Date(2026, 7, 8, 9, 0, 0)),
|
||||
'custom',
|
||||
);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { DateQuickPick } from './types';
|
||||
|
||||
export type DateRangeMode = DateQuickPick | 'custom';
|
||||
|
||||
export interface DateRangeValue {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
const LABELS: Record<DateQuickPick, string> = {
|
||||
thisWeek: '本周',
|
||||
thisMonth: '本月',
|
||||
last15: '近 15 天',
|
||||
};
|
||||
|
||||
function fmtYmd(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getQuickDateRange(pick: DateQuickPick, now = new Date()): DateRangeValue {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (pick === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
|
||||
}
|
||||
if (pick === 'thisMonth') {
|
||||
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
|
||||
}
|
||||
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
|
||||
}
|
||||
|
||||
export function normalizeDateRange(start: string, end: string): DateRangeValue {
|
||||
return start <= end ? { start, end } : { start: end, end: start };
|
||||
}
|
||||
|
||||
export function inferDateRangeMode(start: string, end: string, now = new Date()): DateRangeMode {
|
||||
const range = normalizeDateRange(start, end);
|
||||
const match = QUICK_DATE_OPTIONS.find(({ id }) => {
|
||||
const quickRange = getQuickDateRange(id, now);
|
||||
return quickRange.start === range.start && quickRange.end === range.end;
|
||||
});
|
||||
return match?.id ?? 'custom';
|
||||
}
|
||||
|
||||
export function dateRangeModeLabel(mode: DateRangeMode): string {
|
||||
return mode === 'custom' ? '自定义区间' : LABELS[mode];
|
||||
}
|
||||
|
||||
export const QUICK_DATE_OPTIONS = (Object.keys(LABELS) as DateQuickPick[]).map(id => ({
|
||||
id,
|
||||
label: LABELS[id],
|
||||
}));
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { activateDrillOnKeyDown } from './drill-interaction.js';
|
||||
|
||||
test('activates drilldown with Enter and Space only', () => {
|
||||
for (const key of ['Enter', ' ']) {
|
||||
let prevented = false;
|
||||
let activations = 0;
|
||||
const handled = activateDrillOnKeyDown(
|
||||
{ key, preventDefault: () => { prevented = true; } },
|
||||
() => { activations += 1; },
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(activations, 1);
|
||||
}
|
||||
|
||||
let activations = 0;
|
||||
const handled = activateDrillOnKeyDown(
|
||||
{ key: 'ArrowRight', preventDefault: () => assert.fail('must not prevent navigation keys') },
|
||||
() => { activations += 1; },
|
||||
);
|
||||
assert.equal(handled, false);
|
||||
assert.equal(activations, 0);
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface DrillKeyEvent {
|
||||
key: string;
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
export function activateDrillOnKeyDown(event: DrillKeyEvent, activate: () => void): boolean {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return false;
|
||||
event.preventDefault();
|
||||
activate();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ElectricMonthGroup } from '../types';
|
||||
import { summarizeElectricMonths } from './model';
|
||||
|
||||
test('electric daily summary preserves totals, active-day and 30 percent boundaries', () => {
|
||||
const months: ElectricMonthGroup[] = [
|
||||
{
|
||||
month: '2026-08',
|
||||
kwh: 150,
|
||||
fee: 75,
|
||||
rows: [
|
||||
{ date: '2026-08-01', kwh: 100, fee: 50, chainPct: 0.3 },
|
||||
{ date: '2026-08-02', kwh: 50, fee: 25, chainPct: -0.29 },
|
||||
{ date: '2026-08-03', kwh: 0, fee: 0, chainPct: -1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(summarizeElectricMonths(months), {
|
||||
totalKwh: 150,
|
||||
totalFee: 75,
|
||||
activeDays: 2,
|
||||
abnormalDays: 2,
|
||||
avgKwh: 75,
|
||||
avgPrice: 0.5,
|
||||
hasFeeDetail: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('electric daily summary preserves empty and missing-fee fallbacks', () => {
|
||||
assert.deepEqual(summarizeElectricMonths(null), {
|
||||
totalKwh: 0,
|
||||
totalFee: 0,
|
||||
activeDays: 0,
|
||||
abnormalDays: 0,
|
||||
avgKwh: 0,
|
||||
avgPrice: 0,
|
||||
hasFeeDetail: false,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ElectricMonthGroup } from '../types';
|
||||
|
||||
export function summarizeElectricMonths(months: ElectricMonthGroup[] | null) {
|
||||
const source = months ?? [];
|
||||
const totalKwh = source.reduce((sum, month) => sum + (month.kwh || 0), 0);
|
||||
const totalFee = source.reduce((sum, month) => sum + (month.fee || 0), 0);
|
||||
const activeDays = source.reduce(
|
||||
(sum, month) => sum + month.rows.filter(row => row.kwh > 0).length,
|
||||
0,
|
||||
);
|
||||
const abnormalDays = source.reduce(
|
||||
(sum, month) => sum + month.rows.filter(row => Math.abs(row.chainPct) >= 0.3).length,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalKwh,
|
||||
totalFee,
|
||||
activeDays,
|
||||
abnormalDays,
|
||||
avgKwh: activeDays > 0 ? totalKwh / activeDays : 0,
|
||||
avgPrice: totalKwh > 0 ? totalFee / totalKwh : 0,
|
||||
hasFeeDetail: totalFee > 0,
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildElectricDrillUrl,
|
||||
parseElectricDrillContext,
|
||||
} from './electric-drill-context.js';
|
||||
|
||||
test('parses electric scope, range, and selected day', () => {
|
||||
assert.deepEqual(
|
||||
parseElectricDrillContext('?electricScope=external&electricStart=2026-07-01&electricEnd=2026-07-31&electricDate=2026-07-24'),
|
||||
{
|
||||
vehicleScope: 'external',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
selectedDate: '2026-07-24',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('drops invalid electric dates and defaults scope', () => {
|
||||
assert.deepEqual(
|
||||
parseElectricDrillContext('?electricScope=unknown&electricDate=2026-02-31'),
|
||||
{ vehicleScope: 'lingniu' },
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves the all-vehicle scope for overview drilldown', () => {
|
||||
assert.deepEqual(
|
||||
parseElectricDrillContext('?electricScope=all&electricDate=2026-07-28'),
|
||||
{ vehicleScope: 'all', selectedDate: '2026-07-28' },
|
||||
);
|
||||
});
|
||||
|
||||
test('builds namespaced electric drill URL and preserves unrelated filters', () => {
|
||||
assert.equal(
|
||||
buildElectricDrillUrl(
|
||||
{ pathname: '/energy', search: '?company=5&electricDate=2026-01-01', hash: '#electric' },
|
||||
{
|
||||
vehicleScope: 'lingniu',
|
||||
startDate: '2026-07-24',
|
||||
endDate: '2026-08-07',
|
||||
selectedDate: '2026-07-24',
|
||||
},
|
||||
),
|
||||
'/energy?company=5&electricScope=lingniu&electricStart=2026-07-24&electricEnd=2026-08-07&electricDate=2026-07-24#electric',
|
||||
);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { ElectricVehicleScope } from './types';
|
||||
|
||||
export interface ElectricDrillContext {
|
||||
vehicleScope: ElectricVehicleScope;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
selectedDate?: string;
|
||||
}
|
||||
|
||||
interface LocationParts {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function validDate(value: string | null): string | undefined {
|
||||
if (!value || !DATE_PATTERN.test(value)) return undefined;
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.getUTCFullYear() === year
|
||||
&& date.getUTCMonth() === month - 1
|
||||
&& date.getUTCDate() === day
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function parseElectricDrillContext(search: string): ElectricDrillContext {
|
||||
const params = new URLSearchParams(search);
|
||||
const scope = params.get('electricScope');
|
||||
const context: ElectricDrillContext = {
|
||||
vehicleScope: scope === 'external' || scope === 'all' ? scope : 'lingniu',
|
||||
};
|
||||
const startDate = validDate(params.get('electricStart'));
|
||||
const endDate = validDate(params.get('electricEnd'));
|
||||
const selectedDate = validDate(params.get('electricDate'));
|
||||
if (startDate) context.startDate = startDate;
|
||||
if (endDate) context.endDate = endDate;
|
||||
if (selectedDate) context.selectedDate = selectedDate;
|
||||
return context;
|
||||
}
|
||||
|
||||
export function buildElectricDrillUrl(
|
||||
location: LocationParts,
|
||||
context: ElectricDrillContext,
|
||||
): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
for (const key of ['electricScope', 'electricStart', 'electricEnd', 'electricDate']) {
|
||||
params.delete(key);
|
||||
}
|
||||
params.set('electricScope', context.vehicleScope);
|
||||
if (context.startDate) params.set('electricStart', context.startDate);
|
||||
if (context.endDate) params.set('electricEnd', context.endDate);
|
||||
if (context.selectedDate) params.set('electricDate', context.selectedDate);
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reconcileElectricDaily } from './electric-reconciliation.js';
|
||||
import type { ElectricChargeOrderResponse, ElectricDailyRow } from './types.js';
|
||||
|
||||
const daily: ElectricDailyRow = {
|
||||
date: '2026-07-01',
|
||||
kwh: 2736.67,
|
||||
fee: 552.92,
|
||||
chainPct: 0,
|
||||
};
|
||||
|
||||
const orders: ElectricChargeOrderResponse = {
|
||||
date: '2026-07-01',
|
||||
vehicleScope: 'all',
|
||||
recordCount: 29,
|
||||
totalKwh: 2736.67,
|
||||
totalFee: 552.92,
|
||||
truncated: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
test('matches a daily aggregate to the full order summary at hundredth precision', () => {
|
||||
assert.deepEqual(reconcileElectricDaily(daily, orders, 'all'), {
|
||||
matches: true,
|
||||
dateMatches: true,
|
||||
scopeMatches: true,
|
||||
kwhDifference: 0,
|
||||
feeDifference: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports amount, date, and scope differences without averaging detail rows', () => {
|
||||
const result = reconcileElectricDaily(
|
||||
daily,
|
||||
{ ...orders, date: '2026-07-02', vehicleScope: 'lingniu', totalKwh: 2736.68, totalFee: 550 },
|
||||
'all',
|
||||
);
|
||||
assert.equal(result.matches, false);
|
||||
assert.equal(result.dateMatches, false);
|
||||
assert.equal(result.scopeMatches, false);
|
||||
assert.equal(result.kwhDifference, 0.01);
|
||||
assert.equal(result.feeDifference, -2.92);
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ElectricChargeOrderResponse, ElectricDailyRow, ElectricVehicleScope } from './types';
|
||||
|
||||
export interface ElectricDailyReconciliation {
|
||||
matches: boolean;
|
||||
dateMatches: boolean;
|
||||
scopeMatches: boolean;
|
||||
kwhDifference: number;
|
||||
feeDifference: number;
|
||||
}
|
||||
|
||||
function hundredths(value: number): number {
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
export function reconcileElectricDaily(
|
||||
daily: ElectricDailyRow,
|
||||
orders: ElectricChargeOrderResponse,
|
||||
expectedScope: ElectricVehicleScope,
|
||||
): ElectricDailyReconciliation {
|
||||
const dateMatches = daily.date === orders.date;
|
||||
const scopeMatches = expectedScope === orders.vehicleScope;
|
||||
const kwhDifferenceInHundredths = hundredths(orders.totalKwh) - hundredths(daily.kwh);
|
||||
const feeDifferenceInHundredths = hundredths(orders.totalFee) - hundredths(daily.fee);
|
||||
|
||||
return {
|
||||
matches: dateMatches
|
||||
&& scopeMatches
|
||||
&& kwhDifferenceInHundredths === 0
|
||||
&& feeDifferenceInHundredths === 0,
|
||||
dateMatches,
|
||||
scopeMatches,
|
||||
kwhDifference: kwhDifferenceInHundredths / 100,
|
||||
feeDifference: feeDifferenceInHundredths / 100,
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildEtcDrillUrl, parseEtcDrillContext } from './etc-drill-context.js';
|
||||
|
||||
test('parses and normalizes the ETC analysis context', () => {
|
||||
assert.deepEqual(
|
||||
parseEtcDrillContext('?etcView=bills&etcStart=2026-08-07&etcEnd=2026-08-01&etcSearch=%20%E5%AE%A2%E6%88%B7A%20&etcPage=3'),
|
||||
{
|
||||
view: 'bills',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-07',
|
||||
search: '客户A',
|
||||
page: 3,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid ETC URL state', () => {
|
||||
assert.deepEqual(
|
||||
parseEtcDrillContext(`?etcView=unknown&etcStart=2026-02-31&etcSearch=${'x'.repeat(129)}&etcPage=-2`),
|
||||
{ view: 'records', startDate: '', endDate: '', search: '', page: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('builds ETC state without dropping unrelated URL context', () => {
|
||||
assert.equal(
|
||||
buildEtcDrillUrl({
|
||||
pathname: '/energy',
|
||||
search: '?electricScope=all&etcView=records',
|
||||
hash: '#etc',
|
||||
}, {
|
||||
view: 'bills',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-07',
|
||||
search: '客户A',
|
||||
page: 2,
|
||||
}),
|
||||
'/energy?electricScope=all&etcView=bills&etcStart=2026-08-01&etcEnd=2026-08-07&etcSearch=%E5%AE%A2%E6%88%B7A&etcPage=2#etc',
|
||||
);
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
export type EtcDetailView = 'records' | 'bills';
|
||||
|
||||
export interface EtcDrillContext {
|
||||
view: EtcDetailView;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
search: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
interface LocationParts {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function validDate(value: string | null): string {
|
||||
if (!value || !DATE_PATTERN.test(value)) return '';
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.getUTCFullYear() === year
|
||||
&& date.getUTCMonth() === month - 1
|
||||
&& date.getUTCDate() === day
|
||||
? value
|
||||
: '';
|
||||
}
|
||||
|
||||
function validPage(value: string | null): number {
|
||||
if (!value || !/^\d+$/.test(value)) return 1;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
|
||||
}
|
||||
|
||||
export function parseEtcDrillContext(search: string): EtcDrillContext {
|
||||
const params = new URLSearchParams(search);
|
||||
const rawSearch = params.get('etcSearch')?.trim() ?? '';
|
||||
const firstDate = validDate(params.get('etcStart'));
|
||||
const secondDate = validDate(params.get('etcEnd'));
|
||||
const startDate = firstDate && secondDate && firstDate > secondDate ? secondDate : firstDate;
|
||||
const endDate = firstDate && secondDate && firstDate > secondDate ? firstDate : secondDate;
|
||||
return {
|
||||
view: params.get('etcView') === 'bills' ? 'bills' : 'records',
|
||||
startDate,
|
||||
endDate,
|
||||
search: rawSearch.length <= 128 ? rawSearch : '',
|
||||
page: validPage(params.get('etcPage')),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEtcDrillUrl(location: LocationParts, context: EtcDrillContext): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
for (const key of ['etcView', 'etcStart', 'etcEnd', 'etcSearch', 'etcPage']) params.delete(key);
|
||||
params.set('etcView', context.view);
|
||||
if (context.startDate) params.set('etcStart', context.startDate);
|
||||
if (context.endDate) params.set('etcEnd', context.endDate);
|
||||
const search = context.search.trim();
|
||||
if (search && search.length <= 128) params.set('etcSearch', search);
|
||||
if (context.page > 1) params.set('etcPage', String(context.page));
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation.js';
|
||||
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types.js';
|
||||
|
||||
const kpi: EtcOverviewResponse['kpi'] = {
|
||||
passageCount: 12,
|
||||
vehicleCount: 4,
|
||||
tollAmount: 100.01,
|
||||
serviceFee: 5.02,
|
||||
totalAmount: 105.03,
|
||||
billCount: 2,
|
||||
receivableAmount: 105.03,
|
||||
paidAmount: 80,
|
||||
latestTransactionTime: '2026-07-01 12:00:00',
|
||||
};
|
||||
|
||||
const records: EtcTollRecordResponse = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 12,
|
||||
totalPages: 1,
|
||||
filters: { startDate: null, endDate: null, search: '' },
|
||||
summary: { vehicleCount: 4, tollAmount: 100.01, serviceFee: 5.02, totalAmount: 105.03 },
|
||||
items: [],
|
||||
};
|
||||
|
||||
const bills: EtcBillResponse = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
filters: { startDate: null, endDate: null, search: '' },
|
||||
summary: { receivableAmount: 105.03, paidAmount: 80 },
|
||||
items: [],
|
||||
};
|
||||
|
||||
test('matches ETC overview against full record and bill summaries', () => {
|
||||
assert.equal(reconcileEtcRecords(kpi, records).matches, true);
|
||||
assert.equal(reconcileEtcBills(kpi, bills).matches, true);
|
||||
});
|
||||
|
||||
test('reports record count, vehicle, and fee differences at hundredth precision', () => {
|
||||
assert.deepEqual(reconcileEtcRecords(kpi, {
|
||||
...records,
|
||||
total: 13,
|
||||
summary: { vehicleCount: 3, tollAmount: 100, serviceFee: 5.04, totalAmount: 105.04 },
|
||||
}), {
|
||||
matches: false,
|
||||
passageCountDifference: 1,
|
||||
vehicleCountDifference: -1,
|
||||
tollAmountDifference: -0.01,
|
||||
serviceFeeDifference: 0.02,
|
||||
totalAmountDifference: 0.01,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports bill count and settlement differences at hundredth precision', () => {
|
||||
assert.deepEqual(reconcileEtcBills(kpi, {
|
||||
...bills,
|
||||
total: 1,
|
||||
summary: { receivableAmount: 100, paidAmount: 80.01 },
|
||||
}), {
|
||||
matches: false,
|
||||
billCountDifference: -1,
|
||||
receivableAmountDifference: -5.03,
|
||||
paidAmountDifference: 0.01,
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
|
||||
|
||||
export interface EtcRecordReconciliation {
|
||||
matches: boolean;
|
||||
passageCountDifference: number;
|
||||
vehicleCountDifference: number;
|
||||
tollAmountDifference: number;
|
||||
serviceFeeDifference: number;
|
||||
totalAmountDifference: number;
|
||||
}
|
||||
|
||||
export interface EtcBillReconciliation {
|
||||
matches: boolean;
|
||||
billCountDifference: number;
|
||||
receivableAmountDifference: number;
|
||||
paidAmountDifference: number;
|
||||
}
|
||||
|
||||
function hundredths(value: number): number {
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
export function reconcileEtcRecords(
|
||||
kpi: EtcOverviewResponse['kpi'],
|
||||
records: EtcTollRecordResponse,
|
||||
): EtcRecordReconciliation {
|
||||
const tollDifference = hundredths(records.summary.tollAmount) - hundredths(kpi.tollAmount);
|
||||
const serviceFeeDifference = hundredths(records.summary.serviceFee) - hundredths(kpi.serviceFee);
|
||||
const totalDifference = hundredths(records.summary.totalAmount) - hundredths(kpi.totalAmount);
|
||||
const passageCountDifference = records.total - kpi.passageCount;
|
||||
const vehicleCountDifference = records.summary.vehicleCount - kpi.vehicleCount;
|
||||
|
||||
return {
|
||||
matches: passageCountDifference === 0
|
||||
&& vehicleCountDifference === 0
|
||||
&& tollDifference === 0
|
||||
&& serviceFeeDifference === 0
|
||||
&& totalDifference === 0,
|
||||
passageCountDifference,
|
||||
vehicleCountDifference,
|
||||
tollAmountDifference: tollDifference / 100,
|
||||
serviceFeeDifference: serviceFeeDifference / 100,
|
||||
totalAmountDifference: totalDifference / 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileEtcBills(
|
||||
kpi: EtcOverviewResponse['kpi'],
|
||||
bills: EtcBillResponse,
|
||||
): EtcBillReconciliation {
|
||||
const receivableDifference = hundredths(bills.summary.receivableAmount) - hundredths(kpi.receivableAmount);
|
||||
const paidDifference = hundredths(bills.summary.paidAmount) - hundredths(kpi.paidAmount);
|
||||
const billCountDifference = bills.total - kpi.billCount;
|
||||
|
||||
return {
|
||||
matches: billCountDifference === 0 && receivableDifference === 0 && paidDifference === 0,
|
||||
billCountDifference,
|
||||
receivableAmountDifference: receivableDifference / 100,
|
||||
paidAmountDifference: paidDifference / 100,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenDailyRow } from '../types.js';
|
||||
import {
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
summarizeHydrogenRows,
|
||||
} from './model.js';
|
||||
|
||||
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
|
||||
const now = new Date(2026, 7, 12, 23, 30);
|
||||
assert.deepEqual(getQuickRange('thisWeek', now), {
|
||||
start: '2026-08-10',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('thisMonth', now), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('last15', now), {
|
||||
start: '2026-07-29',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
});
|
||||
|
||||
test('自定义日期倒置时仅交换查询边界', () => {
|
||||
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.equal(getRangeModeLabel('custom'), '自定义区间');
|
||||
assert.equal(getRangeModeLabel('last15'), '近 15 天');
|
||||
});
|
||||
|
||||
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
|
||||
const rows: HydrogenDailyRow[] = [
|
||||
{
|
||||
date: '2026-08-12',
|
||||
totalKg: 0,
|
||||
chainPct: -1,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ name: 'A站', kg: 0, pricePerKg: 0, chainPct: -1 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-10',
|
||||
totalKg: 100,
|
||||
chainPct: 0,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ name: 'A站', kg: 100, pricePerKg: 20, chainPct: 0 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-11',
|
||||
totalKg: 300,
|
||||
chainPct: 2,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ name: 'B站', kg: 300, pricePerKg: 25, chainPct: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const summary = summarizeHydrogenRows(rows);
|
||||
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
|
||||
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
|
||||
assert.equal(summary.totalKg, 400);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.avgKg, 200);
|
||||
assert.equal(summary.stationCount, 2);
|
||||
assert.equal(summary.peakDay?.date, '2026-08-11');
|
||||
assert.equal(summary.lowDay?.date, '2026-08-10');
|
||||
assert.equal(summary.zeroDays, 1);
|
||||
});
|
||||
|
||||
test('空数据保持零值且没有峰谷日', () => {
|
||||
assert.deepEqual(summarizeHydrogenRows(null), {
|
||||
trendData: [],
|
||||
totalKg: 0,
|
||||
activeDays: 0,
|
||||
stationCount: 0,
|
||||
avgKg: 0,
|
||||
peakDay: null,
|
||||
lowDay: null,
|
||||
zeroDays: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { HydrogenDailyRow } from '../types';
|
||||
|
||||
export {
|
||||
formatYmd,
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
QUICK_PICK_OPTIONS,
|
||||
type RangeMode,
|
||||
} from '../daily-range/model';
|
||||
|
||||
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||
const source = rows ?? [];
|
||||
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
|
||||
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
|
||||
const activeDays = source.filter(row => row.totalKg > 0).length;
|
||||
const stationNames = new Set<string>();
|
||||
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
|
||||
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
|
||||
null,
|
||||
);
|
||||
const lowDay = trendData
|
||||
.filter(item => item.totalKg > 0)
|
||||
.reduce<HydrogenDailyRow | null>(
|
||||
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
trendData,
|
||||
totalKg,
|
||||
activeDays,
|
||||
stationCount: stationNames.size,
|
||||
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays: source.filter(row => row.totalKg === 0).length,
|
||||
};
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildHydrogenDrillUrl,
|
||||
parseHydrogenDrillContext,
|
||||
} from './hydrogen-drill-context.js';
|
||||
|
||||
test('parses a station drill context including station zero', () => {
|
||||
assert.deepEqual(
|
||||
parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=0&hydrogenStationName=%E6%9C%AA%E5%85%B3%E8%81%94%E7%AB%99%E7%82%B9&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07'),
|
||||
{
|
||||
level: 'station',
|
||||
stationId: 0,
|
||||
stationName: '未关联站点',
|
||||
vehicleScope: 'lingniu',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-07',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('requires a valid entity for station and customer levels', () => {
|
||||
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview');
|
||||
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').level, 'overview');
|
||||
assert.equal(
|
||||
parseHydrogenDrillContext(`?hydrogenLevel=customer&hydrogenCustomer=${'x'.repeat(129)}`).level,
|
||||
'overview',
|
||||
);
|
||||
});
|
||||
|
||||
test('parses and builds a customer drill context', () => {
|
||||
const context = parseHydrogenDrillContext(
|
||||
'?hydrogenLevel=customer&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07',
|
||||
);
|
||||
assert.deepEqual(context, {
|
||||
level: 'customer',
|
||||
customerName: '武汉客户',
|
||||
vehicleScope: 'lingniu',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-08-07',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
buildHydrogenDrillUrl(
|
||||
{ pathname: '/energy', search: '', hash: '#hydrogen' },
|
||||
{ ...context, year: 2026 },
|
||||
),
|
||||
'/energy?hydrogenLevel=customer&hydrogenYear=2026&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07#hydrogen',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds a namespaced station URL and preserves unrelated parameters', () => {
|
||||
const url = buildHydrogenDrillUrl(
|
||||
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
|
||||
{
|
||||
level: 'station',
|
||||
year: 2026,
|
||||
stationId: 18,
|
||||
stationName: '测试加氢站',
|
||||
vehicleScope: 'lingniu',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-07',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
url,
|
||||
'/energy?company=5&hydrogenLevel=station&hydrogenYear=2026&hydrogenStation=18&hydrogenStationName=%E6%B5%8B%E8%AF%95%E5%8A%A0%E6%B0%A2%E7%AB%99&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07#hydrogen',
|
||||
);
|
||||
});
|
||||
|
||||
test('clears station parameters when returning to overview', () => {
|
||||
const url = buildHydrogenDrillUrl(
|
||||
{
|
||||
pathname: '/energy',
|
||||
search: '?hydrogenLevel=station&hydrogenStation=18&hydrogenStationName=test&company=5',
|
||||
hash: '#hydrogen',
|
||||
},
|
||||
{ level: 'overview', vehicleScope: 'lingniu' },
|
||||
);
|
||||
|
||||
assert.equal(url, '/energy?company=5&hydrogenScope=lingniu#hydrogen');
|
||||
});
|
||||
|
||||
test('preserves a selected order date and page in the hydrogen analysis URL', () => {
|
||||
const context = parseHydrogenDrillContext(
|
||||
'?hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05&hydrogenPage=3',
|
||||
);
|
||||
assert.equal(context.selectedDate, '2026-08-05');
|
||||
assert.equal(context.orderPage, 3);
|
||||
assert.equal(
|
||||
buildHydrogenDrillUrl(
|
||||
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
|
||||
context,
|
||||
),
|
||||
'/energy?company=5&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05&hydrogenPage=3#hydrogen',
|
||||
);
|
||||
assert.equal(
|
||||
parseHydrogenDrillContext('?hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-08').selectedDate,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(parseHydrogenDrillContext('?hydrogenPage=3').orderPage, undefined);
|
||||
assert.equal(parseHydrogenDrillContext('?hydrogenDate=2026-08-05&hydrogenPage=0').orderPage, undefined);
|
||||
assert.equal(
|
||||
buildHydrogenDrillUrl(
|
||||
{ pathname: '/energy', search: '', hash: '#hydrogen' },
|
||||
{ level: 'overview', vehicleScope: 'lingniu', orderPage: 3 },
|
||||
),
|
||||
'/energy?hydrogenScope=lingniu#hydrogen',
|
||||
);
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { CustomerType } from './types';
|
||||
|
||||
export type HydrogenDrillLevel = 'overview' | 'station' | 'customer';
|
||||
|
||||
export interface HydrogenDrillContext {
|
||||
level: HydrogenDrillLevel;
|
||||
year?: number;
|
||||
stationId?: number;
|
||||
stationName?: string;
|
||||
customerName?: string;
|
||||
vehicleScope: CustomerType;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
selectedDate?: string;
|
||||
orderPage?: number;
|
||||
}
|
||||
|
||||
interface LocationParts {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function validDate(value: string | null): string | undefined {
|
||||
if (!value || !DATE_PATTERN.test(value)) return undefined;
|
||||
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
|
||||
return Number.isFinite(timestamp) ? value : undefined;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | null): number | undefined {
|
||||
if (!value || !/^\d+$/.test(value)) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function stationId(value: string | null): number | undefined {
|
||||
if (!value || !/^\d+$/.test(value)) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function parseHydrogenDrillContext(search: string): HydrogenDrillContext {
|
||||
const params = new URLSearchParams(search);
|
||||
const requestedLevel = params.get('hydrogenLevel');
|
||||
const parsedStationId = stationId(params.get('hydrogenStation'));
|
||||
const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined;
|
||||
const rawCustomerName = params.get('hydrogenCustomer')?.trim();
|
||||
const customerName = rawCustomerName && rawCustomerName.length <= 128
|
||||
? rawCustomerName
|
||||
: undefined;
|
||||
const context: HydrogenDrillContext = {
|
||||
level: requestedLevel === 'station' && parsedStationId !== undefined
|
||||
? 'station'
|
||||
: requestedLevel === 'customer' && customerName
|
||||
? 'customer'
|
||||
: 'overview',
|
||||
vehicleScope: params.get('hydrogenScope') === 'external' ? 'external' : 'lingniu',
|
||||
};
|
||||
|
||||
const year = positiveInteger(params.get('hydrogenYear'));
|
||||
if (year && year >= 2000 && year <= 2100) context.year = year;
|
||||
if (context.level === 'station') {
|
||||
context.stationId = parsedStationId;
|
||||
if (parsedStationName) context.stationName = parsedStationName;
|
||||
}
|
||||
if (context.level === 'customer') context.customerName = customerName;
|
||||
const startDate = validDate(params.get('hydrogenStart'));
|
||||
const endDate = validDate(params.get('hydrogenEnd'));
|
||||
const selectedDate = validDate(params.get('hydrogenDate'));
|
||||
if (startDate) context.startDate = startDate;
|
||||
if (endDate) context.endDate = endDate;
|
||||
const rangeStart = startDate && endDate ? (startDate <= endDate ? startDate : endDate) : undefined;
|
||||
const rangeEnd = startDate && endDate ? (startDate <= endDate ? endDate : startDate) : undefined;
|
||||
if (selectedDate && (!rangeStart || !rangeEnd || (selectedDate >= rangeStart && selectedDate <= rangeEnd))) {
|
||||
context.selectedDate = selectedDate;
|
||||
const orderPage = positiveInteger(params.get('hydrogenPage'));
|
||||
if (orderPage && orderPage > 1) context.orderPage = orderPage;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function buildHydrogenDrillUrl(
|
||||
location: LocationParts,
|
||||
context: HydrogenDrillContext,
|
||||
): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
for (const key of [
|
||||
'hydrogenLevel',
|
||||
'hydrogenYear',
|
||||
'hydrogenStation',
|
||||
'hydrogenStationName',
|
||||
'hydrogenCustomer',
|
||||
'hydrogenScope',
|
||||
'hydrogenStart',
|
||||
'hydrogenEnd',
|
||||
'hydrogenDate',
|
||||
'hydrogenPage',
|
||||
]) {
|
||||
params.delete(key);
|
||||
}
|
||||
|
||||
if (context.level !== 'overview') params.set('hydrogenLevel', context.level);
|
||||
if (context.year) params.set('hydrogenYear', String(context.year));
|
||||
if (context.level === 'station' && context.stationId !== undefined) {
|
||||
params.set('hydrogenStation', String(context.stationId));
|
||||
if (context.stationName) params.set('hydrogenStationName', context.stationName);
|
||||
}
|
||||
if (context.level === 'customer' && context.customerName) {
|
||||
params.set('hydrogenCustomer', context.customerName);
|
||||
}
|
||||
params.set('hydrogenScope', context.vehicleScope);
|
||||
if (context.startDate) params.set('hydrogenStart', context.startDate);
|
||||
if (context.endDate) params.set('hydrogenEnd', context.endDate);
|
||||
if (context.selectedDate) params.set('hydrogenDate', context.selectedDate);
|
||||
if (context.selectedDate && context.orderPage && context.orderPage > 1) {
|
||||
params.set('hydrogenPage', String(context.orderPage));
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
LabelList,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface DistributionChartsProps {
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
yearKg: number;
|
||||
}
|
||||
|
||||
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||
</div>
|
||||
|
||||
{/* 5 卡占位 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 月度柱图占位 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-[120px]">
|
||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
正在加载氢能总览…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatKg as fmtKg } from '../model';
|
||||
|
||||
interface InsightCardsProps {
|
||||
monthAvgKg: number;
|
||||
bestMonth: HydrogenMonthlyPoint | null;
|
||||
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||
monthMomentum: number | null;
|
||||
top5Share: number;
|
||||
profitYield: number;
|
||||
stationAvgKg: number;
|
||||
stationCount: number;
|
||||
yearProfitValue: string;
|
||||
yearProfitUnit: string;
|
||||
yearRevenueValue: string;
|
||||
yearRevenueUnit: string;
|
||||
}
|
||||
|
||||
export function InsightCards({
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
stationAvgKg,
|
||||
stationCount,
|
||||
yearProfitValue,
|
||||
yearProfitUnit,
|
||||
yearRevenueValue,
|
||||
yearRevenueUnit,
|
||||
}: InsightCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Gauge size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||
<Building2 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{profitYield.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
|
||||
import type { HydrogenKpi } from '../../types';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
|
||||
interface KpiCardProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||
<span className="text-slate-400">{r.label}</span>
|
||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||||
const monthKgFmt = fmtKg(k.monthKg);
|
||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
||||
const todayKgFmt = fmtKg(k.todayKg);
|
||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
<KpiCard
|
||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-cyan-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢量"
|
||||
hero={yearKgFmt}
|
||||
rows={[
|
||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-blue-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢费"
|
||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||
rows={[
|
||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-emerald-50"
|
||||
accentClass={profitColor}
|
||||
label="时享加氢获利"
|
||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||
rows={[
|
||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-amber-50"
|
||||
accentClass="text-amber-600"
|
||||
label="本月加氢"
|
||||
hero={monthKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-violet-50"
|
||||
accentClass="text-violet-600"
|
||||
label="本日加氢"
|
||||
hero={todayKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatYuan as fmtYuan } from '../model';
|
||||
|
||||
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
|
||||
|
||||
interface MonthlyChartsProps {
|
||||
activeYear: number;
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
monthlyDual: MonthlyChartPoint[];
|
||||
}
|
||||
|
||||
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
|
||||
return (
|
||||
<>
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||||
{monthlyDual.map((_, i) => (
|
||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
}}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { formatRefreshTime } from '../model';
|
||||
|
||||
interface OverviewHeaderProps {
|
||||
activeYear: number;
|
||||
availableYears: number[];
|
||||
lastRefreshAt: number;
|
||||
refreshing: boolean;
|
||||
onSelectYear: (year: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function OverviewHeader({
|
||||
activeYear,
|
||||
availableYears,
|
||||
lastRefreshAt,
|
||||
refreshing,
|
||||
onSelectYear,
|
||||
onRefresh,
|
||||
}: OverviewHeaderProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => onSelectYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
interface RefreshOverlayProps {
|
||||
refreshing: boolean;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{refreshing && hasData && (
|
||||
<motion.div
|
||||
key="refresh-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
style={{ width: '40%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
|
||||
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
|
||||
return (
|
||||
<>
|
||||
{stations.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">加氢站</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stations.map((s, i) => {
|
||||
const kgFmt = fmtKg(s.kg);
|
||||
const revFmt = fmtYuan(s.revenue);
|
||||
return (
|
||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
|
||||
return (
|
||||
<>
|
||||
{customers.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">客户</th>
|
||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((c2, i) => {
|
||||
const kgFmt = fmtKg(c2.kg);
|
||||
const costFmt = fmtYuan(c2.cost);
|
||||
const revFmt = fmtYuan(c2.revenue);
|
||||
return (
|
||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||
{c2.payer === 'lingniu' ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenOverviewResponse } from '../api.js';
|
||||
import {
|
||||
deriveOverviewMetrics,
|
||||
formatKg,
|
||||
formatRelative,
|
||||
formatYuan,
|
||||
} from './model.js';
|
||||
|
||||
test('重量和金额单位保持现有阈值及负数格式', () => {
|
||||
assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' });
|
||||
assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' });
|
||||
assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' });
|
||||
assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' });
|
||||
assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' });
|
||||
});
|
||||
|
||||
test('相对更新时间保持秒、分钟、小时和未来时间边界', () => {
|
||||
const now = new Date(2026, 7, 13, 12, 0, 0).getTime();
|
||||
assert.equal(formatRelative(now + 60_000, now), '刚刚');
|
||||
assert.equal(formatRelative(now - 30_000, now), '30 秒前');
|
||||
assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前');
|
||||
assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前');
|
||||
});
|
||||
|
||||
test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => {
|
||||
const data = {
|
||||
kpi: {
|
||||
yearKg: 1000,
|
||||
yearFee: 0,
|
||||
yearProfit: -50,
|
||||
ourYearKg: 0,
|
||||
customerYearKg: 0,
|
||||
ourYearFee: 0,
|
||||
yearRevenue: 500,
|
||||
monthKg: 0,
|
||||
monthFee: 0,
|
||||
monthRevenue: 0,
|
||||
monthProfit: 0,
|
||||
todayKg: 0,
|
||||
todayFee: 0,
|
||||
todayRevenue: 0,
|
||||
todayProfit: 0,
|
||||
lingniuBornKg: 0,
|
||||
lingniuBornFee: 0,
|
||||
},
|
||||
top5: [{ rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
|
||||
regions: [],
|
||||
monthly: [
|
||||
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
|
||||
{ month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 },
|
||||
],
|
||||
customers: [],
|
||||
stations: [
|
||||
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
|
||||
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
|
||||
],
|
||||
availableYears: [2026],
|
||||
year: 2026,
|
||||
} satisfies HydrogenOverviewResponse;
|
||||
|
||||
const metrics = deriveOverviewMetrics(data);
|
||||
assert.equal(metrics.monthAvgKg, 125);
|
||||
assert.equal(metrics.bestMonth?.month, '2026-02');
|
||||
assert.equal(metrics.latestMonth?.month, '2026-02');
|
||||
assert.equal(metrics.monthMomentum, 50);
|
||||
assert.equal(metrics.top5Share, 60);
|
||||
assert.equal(metrics.profitYield, -10);
|
||||
assert.equal(metrics.stationAvgKg, 500);
|
||||
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { HydrogenOverviewResponse } from '../api';
|
||||
|
||||
export function formatKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
|
||||
export function formatYuan(yuan: number): { value: string; unit: string } {
|
||||
const absolute = Math.abs(yuan);
|
||||
if (absolute >= 100_000_000) {
|
||||
return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
}
|
||||
if (absolute >= 10_000) {
|
||||
return {
|
||||
value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }),
|
||||
unit: '万元',
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
|
||||
unit: '元',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRelative(timestamp: number, now = Date.now()): string {
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 5) return '刚刚';
|
||||
if (seconds < 60) return `${seconds} 秒前`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function formatRefreshTime(timestamp: number, now = Date.now()): string {
|
||||
const exactTime = new Date(timestamp).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`;
|
||||
}
|
||||
|
||||
export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
|
||||
const { kpi, monthly, stations, top5 } = data;
|
||||
const monthAvgKg = monthly.length > 0
|
||||
? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length
|
||||
: 0;
|
||||
const bestMonth = monthly.reduce<typeof monthly[number] | null>(
|
||||
(best, item) => (!best || item.kg > best.kg ? item : best),
|
||||
null,
|
||||
);
|
||||
const latestMonth = monthly[monthly.length - 1];
|
||||
const previousMonth = monthly[monthly.length - 2];
|
||||
|
||||
return {
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0
|
||||
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
|
||||
: null,
|
||||
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
|
||||
profitYield: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
|
||||
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
|
||||
monthlyDual: monthly.map(month => ({
|
||||
...month,
|
||||
monthLabel: `${month.month.slice(5).replace(/^0/, '')}月`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reconcileHydrogenDaily } from './hydrogen-reconciliation.js';
|
||||
import type { HydrogenDailyRow, HydrogenOrderResponse } from './types.js';
|
||||
|
||||
const daily: HydrogenDailyRow = {
|
||||
date: '2026-07-01',
|
||||
totalKg: 123.45,
|
||||
chainPct: 0,
|
||||
customerType: 'lingniu',
|
||||
stations: [],
|
||||
};
|
||||
|
||||
const orders: HydrogenOrderResponse = {
|
||||
date: '2026-07-01',
|
||||
vehicleScope: 'lingniu',
|
||||
stationId: 18,
|
||||
customerName: null,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
recordCount: 3,
|
||||
totalPages: 1,
|
||||
totalKg: 123.45,
|
||||
totalCost: 1000,
|
||||
totalRevenue: 1200,
|
||||
items: [],
|
||||
};
|
||||
|
||||
test('matches daily hydrogen totals at the same date, scope, and entity grain', () => {
|
||||
assert.deepEqual(reconcileHydrogenDaily(daily, orders, 'lingniu', 18), {
|
||||
matches: true,
|
||||
dateMatches: true,
|
||||
scopeMatches: true,
|
||||
entityMatches: true,
|
||||
kgDifference: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports amount and filter-grain differences independently', () => {
|
||||
const result = reconcileHydrogenDaily(
|
||||
daily,
|
||||
{
|
||||
...orders,
|
||||
date: '2026-07-02',
|
||||
vehicleScope: 'external',
|
||||
stationId: null,
|
||||
customerName: '武汉客户',
|
||||
totalKg: 123.44,
|
||||
},
|
||||
'lingniu',
|
||||
18,
|
||||
);
|
||||
|
||||
assert.equal(result.matches, false);
|
||||
assert.equal(result.dateMatches, false);
|
||||
assert.equal(result.scopeMatches, false);
|
||||
assert.equal(result.entityMatches, false);
|
||||
assert.equal(result.kgDifference, -0.01);
|
||||
});
|
||||
|
||||
test('matches an exact customer drill without requiring a station', () => {
|
||||
const customerOrders = { ...orders, stationId: null, customerName: '武汉客户' };
|
||||
assert.equal(
|
||||
reconcileHydrogenDaily(daily, customerOrders, 'lingniu', undefined, '武汉客户').matches,
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
|
||||
|
||||
export interface HydrogenDailyReconciliation {
|
||||
matches: boolean;
|
||||
dateMatches: boolean;
|
||||
scopeMatches: boolean;
|
||||
entityMatches: boolean;
|
||||
kgDifference: number;
|
||||
}
|
||||
|
||||
function hundredths(value: number): number {
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
export function reconcileHydrogenDaily(
|
||||
daily: HydrogenDailyRow,
|
||||
orders: HydrogenOrderResponse,
|
||||
expectedScope: CustomerType,
|
||||
expectedStationId?: number,
|
||||
expectedCustomerName?: string,
|
||||
): HydrogenDailyReconciliation {
|
||||
const dateMatches = daily.date === orders.date;
|
||||
const scopeMatches = expectedScope === orders.vehicleScope;
|
||||
const entityMatches = (expectedStationId ?? null) === orders.stationId
|
||||
&& (expectedCustomerName ?? null) === orders.customerName;
|
||||
const kgDifferenceInHundredths = hundredths(orders.totalKg) - hundredths(daily.totalKg);
|
||||
|
||||
return {
|
||||
matches: dateMatches && scopeMatches && entityMatches && kgDifferenceInHundredths === 0,
|
||||
dateMatches,
|
||||
scopeMatches,
|
||||
entityMatches,
|
||||
kgDifference: kgDifferenceInHundredths / 100,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export type CustomerType = 'external' | 'lingniu';
|
||||
export type ElectricVehicleScope = CustomerType | 'all';
|
||||
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
||||
|
||||
export interface HydrogenKpi {
|
||||
@@ -23,7 +22,6 @@ export interface HydrogenKpi {
|
||||
}
|
||||
|
||||
export interface HydrogenStationTop {
|
||||
id: number;
|
||||
rank: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
@@ -54,7 +52,6 @@ export interface HydrogenCustomerRow {
|
||||
}
|
||||
|
||||
export interface HydrogenStationFull {
|
||||
id: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
revenue: number;
|
||||
@@ -63,7 +60,6 @@ export interface HydrogenStationFull {
|
||||
}
|
||||
|
||||
export interface HydrogenStationRow {
|
||||
stationId: number;
|
||||
name: string;
|
||||
pricePerKg: number;
|
||||
kg: number;
|
||||
@@ -78,34 +74,6 @@ export interface HydrogenDailyRow {
|
||||
stations: HydrogenStationRow[];
|
||||
}
|
||||
|
||||
export interface HydrogenOrder {
|
||||
rowNumber: number;
|
||||
refuelTime: string;
|
||||
plate: string;
|
||||
customerName: string;
|
||||
stationName: string;
|
||||
amountKg: number;
|
||||
costPrice: number;
|
||||
costTotal: number;
|
||||
customerPrice: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface HydrogenOrderResponse {
|
||||
date: string;
|
||||
vehicleScope: CustomerType;
|
||||
stationId: number | null;
|
||||
customerName: string | null;
|
||||
page: number;
|
||||
limit: number;
|
||||
recordCount: number;
|
||||
totalPages: number;
|
||||
totalKg: number;
|
||||
totalCost: number;
|
||||
totalRevenue: number;
|
||||
items: HydrogenOrder[];
|
||||
}
|
||||
|
||||
export interface ElectricKpi {
|
||||
totalKwh: number;
|
||||
totalFee: number;
|
||||
@@ -129,105 +97,3 @@ export interface ElectricMonthGroup {
|
||||
fee: number;
|
||||
rows: ElectricDailyRow[];
|
||||
}
|
||||
|
||||
export interface ElectricChargeOrder {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
startTime: string;
|
||||
stationName: string;
|
||||
plate: string;
|
||||
vehicleKind: 'internal' | 'external' | 'unknown';
|
||||
orderStatus: string;
|
||||
kwh: number;
|
||||
electricityFee: number;
|
||||
serviceFee: number;
|
||||
totalFee: number;
|
||||
}
|
||||
|
||||
export interface ElectricChargeOrderResponse {
|
||||
date: string;
|
||||
vehicleScope: ElectricVehicleScope;
|
||||
recordCount: number;
|
||||
totalKwh: number;
|
||||
totalFee: number;
|
||||
truncated: boolean;
|
||||
items: ElectricChargeOrder[];
|
||||
}
|
||||
|
||||
export interface EtcOverviewResponse {
|
||||
integration: {
|
||||
providerConfigured: boolean;
|
||||
syncEnabled: boolean;
|
||||
providerType: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncStatus: 'never' | 'success' | 'failed' | 'partial' | 'unknown';
|
||||
};
|
||||
kpi: {
|
||||
passageCount: number;
|
||||
vehicleCount: number;
|
||||
tollAmount: number;
|
||||
serviceFee: number;
|
||||
totalAmount: number;
|
||||
billCount: number;
|
||||
receivableAmount: number;
|
||||
paidAmount: number;
|
||||
latestTransactionTime: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EtcListFilters {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface EtcTollRecord {
|
||||
id: number;
|
||||
recordCode: string;
|
||||
plate: string;
|
||||
customerName: string | null;
|
||||
entryStation: string | null;
|
||||
exitStation: string | null;
|
||||
transTime: string;
|
||||
tollAmount: number;
|
||||
serviceFee: number;
|
||||
totalAmount: number;
|
||||
costType: number;
|
||||
contractMatched: boolean;
|
||||
reviewStatus: number;
|
||||
}
|
||||
|
||||
export interface EtcTollRecordResponse {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
filters: EtcListFilters;
|
||||
summary: { vehicleCount: number; tollAmount: number; serviceFee: number; totalAmount: number };
|
||||
items: EtcTollRecord[];
|
||||
}
|
||||
|
||||
export interface EtcBill {
|
||||
id: number;
|
||||
billCode: string;
|
||||
customerName: string | null;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
tollCount: number;
|
||||
tollAmount: number;
|
||||
serviceFee: number;
|
||||
receivableAmount: number;
|
||||
paidAmount: number;
|
||||
paymentStatus: number;
|
||||
reviewStatus: number;
|
||||
}
|
||||
|
||||
export interface EtcBillResponse {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
filters: EtcListFilters;
|
||||
summary: { receivableAmount: number; paidAmount: number };
|
||||
items: EtcBill[];
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildHashSubTab, parseHashSubTab } from './useHashSubTab.js';
|
||||
|
||||
const tabs = ['daily', 'overview'] as const;
|
||||
|
||||
test('parses valid sub tabs and falls back to the module default', () => {
|
||||
assert.equal(parseHashSubTab('#electric/overview', 'electric', tabs), 'overview');
|
||||
assert.equal(parseHashSubTab('#electric/unknown', 'electric', tabs), 'daily');
|
||||
assert.equal(parseHashSubTab('#hydrogen/overview', 'electric', tabs), 'daily');
|
||||
});
|
||||
|
||||
test('builds a compact default hash and an explicit secondary hash', () => {
|
||||
assert.equal(buildHashSubTab('electric', 'daily', 'daily'), '#electric');
|
||||
assert.equal(buildHashSubTab('electric', 'overview', 'daily'), '#electric/overview');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user