Author SHA1 Message Date
kkfluous 5eb38a4b05 chore: checkpoint local changes
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-25 12:37:58 +08:00
kkfluous 2518d9ee54 chore: bump version to 1.1.15
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-20 14:36:18 +08:00
kkfluous 62efef0ab9 feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-20 13:59:03 +08:00
kkfluous 37a9f303ec chore: release v1.1.14
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 12:04:02 +08:00
kkfluous 06fe75b4c7 refactor: modularize application domains 2026-08-13 12:03:34 +08:00
kkfluous d610e4b841 feat(mileage): add report loading overlay
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-12 23:17:06 +08:00
kkfluous 7fce6b8474 feat(mileage): refine daily report controls 2026-08-12 23:11:35 +08:00
kkfluous bd0627d00f feat(mileage): add daily operations report 2026-08-12 23:05:07 +08:00
290 changed files with 53156 additions and 16517 deletions
-51
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
# Energy Hydrogen Board Design QA
- Source of truth: `/tmp/energy-h2-bi-board-prototype-0818/index.html`
- Prototype archive: `/Users/kkfluous/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/wxid_1704407055112_3af8/msg/file/2026-08/energy-h2-bi-board-html.zip`
- Implementation: `http://localhost:3000/energy#hydrogen/overview`
- Desktop viewport: 1280 x 720 CSS px
- Mobile viewport checked for the daily flow: 390 x 844 CSS px
## Compared States
| State | Prototype | Implementation | Combined comparison |
| --- | --- | --- | --- |
| Global overview | `/var/tmp/proto-global-overview.png` | `/var/tmp/impl-global-overview-final.png` | `/var/tmp/compare-global-overview-final.png` |
| Global daily | `/var/tmp/proto-global-daily.png` | `/var/tmp/impl-global-daily-final.png` | `/var/tmp/compare-global-daily-final.png` |
| All stations | `/var/tmp/proto-station.png` | `/var/tmp/impl-station-final.png` | `/var/tmp/compare-station-final.png` |
| Station detail | `/var/tmp/proto-station-detail.png` | `/var/tmp/impl-station-detail-final.png` | `/var/tmp/compare-station-detail-final.png` |
All desktop comparisons use the same viewport, pixel dimensions, navigation state, and side-by-side source/implementation input.
## Interaction Evidence
- Global overview KPI, month, region, station, customer, and insight entries open real ledger drill-down data.
- Global daily supports date presets, custom dates, all/Lingniu/external vehicle scopes, chart-to-date focus, and date -> station -> customer -> vehicle/source drill-down.
- Daily and station evidence tables provide Excel export where the prototype exposes an export command.
- All-station mode supports all/single station display, station selection, clickable daily KPI dialogs, and zero-activity stations.
- Station detail includes a continuous 10-day series with zero-filled late days, recent 7-day table, range trend, and 12-month customer quantity/fee matrices.
- Entering and leaving a station detail resets the page scroll position so the header and notice remain visible.
- A clean browser tab loaded the final overview with no console warnings or errors.
- Daily mobile flow has no body-level horizontal overflow; wide analytical tables retain intentional internal horizontal scrolling.
## Iteration History
1. P1: Station detail inherited the long station-list scroll position. Added deterministic scroll reset on enter and return.
2. P1: Daily drill stopped at station level. Implemented the prototype's full four-level drill and real ledger rows.
3. P1: Station KPI cards were static. Added the three prototype daily detail dialogs using continuous server-side daily summaries.
4. P2: Prototype state matrix was incomplete. Added global overview, global daily, all-station, single-station selector, and station detail states.
5. P2: Icons differed from the prototype. Replaced approximate icons with matching Lucide Fuel, Zap, Wallet, Calendar, Truck, Refresh, Download, and navigation icons.
6. P2: Recharts could measure a transitioning container at a negative size. Added minimum and initial dimensions and verified a clean reload.
7. P2: Station detail omitted dates without records. Zero-filled the requested range so delayed or absent reporting is visible rather than silently skipped.
## Residual P3
- Prototype screenshots contain fixed mock dates and values; implementation screenshots use the current read-only database values.
- The prototype warning says opening balances are still calibrating. The implementation uses a truthful traceability notice because the live ledger does not expose that calibration state.
- Native date-picker popovers vary by operating system; the closed controls match the prototype hierarchy and icon roles.
final result: passed
+21 -31
View File
@@ -5,40 +5,30 @@ 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"
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:
-216
View File
@@ -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 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
+671
View File
@@ -0,0 +1,671 @@
# LN-BI 项目移交交接说明
> 文档版本:1.0
> 更新日期:2026-08-21
> 对应代码版本:`1.1.15`
> 对应分支 / 提交:`main` / `2518d9ee5440c39138427ce76d1f6e0faf8d9f29`
> 项目仓库:`https://gitea.lnh2e.com/shishengliang/ln-bi.git`
## 1. 文档目的与交接边界
本文用于帮助新的研发、测试、运维和产品人员快速接管 LN-BI 项目,覆盖:
- 系统定位、代码结构和运行拓扑;
- 登录认证、角色鉴权和数据权限;
- 本地开发与免登录调试;
- 前后端技术栈;
- 数据对接方、数据库、外部服务及对应 BI / 报表;
- 当前功能、后台任务、接口边界和部署流程;
- 已知技术债、安全风险和交接检查清单。
本文依据当前仓库代码和配置编写,不记录数据库密码、JWT 密钥、API Key、OSS 密钥或 CI 仓库凭据。此类信息应通过密码管理器、Portainer / Docker 环境变量或 CI Secret 单独移交。
## 2. 项目概览
### 2.1 系统定位
LN-BI 是羚牛业务数据的统一 BI Web 应用。目前包含两组主入口:
| 主入口 | 主要模块 | 默认路由 |
| --- | --- | --- |
| 资产 BI | 资产管理、里程管理、车辆 / 加氢热力图、智能调度 | `/asset` |
| 能源 BI | 氢能、电能、ETC | `/energy` |
应用还保留两个不在主导航中展示的后台入口:
| 隐藏入口 | 用途 | 路由 |
| --- | --- | --- |
| 充电记录导入 | 上传并管理电费 XLSX 数据 | `/ele/import` |
| 用户反馈管理 | 查看、回复和推进用户反馈 | `/admin/feedback` |
页面内部主要使用 Hash 保存模块和子页面状态,例如 `/energy#hydrogen/overview`。入口兼容逻辑位于 `src/app/routing.ts`
### 2.2 总体架构
```mermaid
flowchart LR
A[羚牛业务系统] -->|一次性 jumpToken| B[LN-BI React 前端]
B -->|/api/auth/exchange| C[Hono API]
C -->|校验 jumpToken / 获取用户角色| A
C -->|签发 8 小时本地 JWT| B
B -->|Bearer JWT| C
C --> D[(主业务 MySQL / 跨 Schema)]
C --> E[(氢能 MySQL 连接\n默认复用主库)]
C --> F[(车辆位置 PostgreSQL)]
C --> G[OneOS 里程 API]
C --> H[阿里云 OSS]
B --> I[高德地图 JS API]
```
生产环境由一个 Node.js 进程同时提供 REST API 和 Vite 构建后的静态页面。开发环境中,Vite 前端和 Hono 后端分别运行,由 Vite 将 `/api` 代理到后端。
### 2.3 关键目录
| 路径 | 说明 |
| --- | --- |
| `src/App.tsx` | 应用入口、认证门禁、主路由和隐藏页面装配 |
| `src/app/modules.ts` | 资产 / 能源导航模块注册与角色可见性 |
| `src/auth/` | 前端认证状态、JWT 注入、未授权页面 |
| `src/shared/auth/` | 前后端共用角色常量和模块访问判断 |
| `src/server/` | Hono 服务、认证中间件、数据库连接、API 路由和后台任务 |
| `src/modules/` | 各 BI 页面和交互实现 |
| `src/modules/energy/hydrogen-bi-v2/` | 当前氢能 BI 原型迁入版、真实数据适配器和下钻实现 |
| `docs/` | 接口、重构和交接文档 |
| `Dockerfile` | 前端构建 + Node 运行时镜像 |
| `docker-compose.yml` | 当前容器部署参数样例 |
| `woodpecker.yml` | Woodpecker CI 构建、测试、镜像推送流程 |
### 2.4 当前质量基线
2026-08-21 在当前提交和本地依赖环境完成以下验证:
| 检查 | 结果 |
| --- | --- |
| `npm run lint` | 通过 |
| `npm test` | 113 项通过,0 项失败 |
| `npm run build` | 通过,Vite 成功生成生产构建 |
测试和构建会输出 Node.js `DEP0205``module.register()` 已弃用)警告,目前不阻断运行;后续升级 `tsx` / Node 工具链时应处理。
## 3. 登录认证方式
### 3.1 正常登录链路
项目自身没有用户名 / 密码登录表单,正常入口依赖羚牛业务系统单点跳转:
1. 用户先登录羚牛业务系统。
2. 业务系统跳转到 LN-BI,并在 URL 中携带一次性 `jumpToken`
3. 前端调用 `GET /api/auth/exchange?jumpToken=...`
4. LN-BI 后端调用业务系统的 `issueTokenByJump` 接口校验一次性令牌并取得用户、部门编码和角色。
5. 后端根据角色计算数据权限级别,并通过主业务库 `tab_department` 补全部门名称。
6. 后端使用 `JWT_SECRET` 签发有效期 8 小时的 LN-BI JWT。
7. 前端将 JWT 和用户信息保存到当前标签页的 `sessionStorage`
- `bi_jwt`
- `bi_user`
8. 前端从地址栏移除 `jumpToken`,之后所有受保护 API 均发送 `Authorization: Bearer <JWT>`
9. API 返回 `401` 时,前端清空本地会话并显示“会话已过期”。
关键实现:
- 前端:`src/auth/AuthProvider.tsx`
- Token 注入:`src/auth/api-client.ts`
- Token 换取:`src/server/auth/login.ts`
- API 认证:`src/server/auth/middleware.ts`
### 3.2 公开接口与受保护接口
不要求 JWT 的接口:
- `/api/health`
- `/api/auth/*`
其余 `/api/*` 默认经过统一认证中间件。能源、智能调度、加氢热力图和反馈管理在统一认证之上还有模块级或接口级角色校验。
### 3.3 会话特征
| 项目 | 当前实现 |
| --- | --- |
| Token 类型 | HS256 JWT`jsonwebtoken` 默认签名算法) |
| 有效期 | 8 小时 |
| 浏览器存储 | `sessionStorage`,关闭标签页后失效 |
| 用户来源 | 羚牛业务系统 jumpToken 换取 |
| 部门名称来源 | 主业务库 `tab_department` |
| 退出方式 | 当前无独立退出接口;清除标签页会话或收到 401 后清理 |
## 4. 角色鉴权与数据权限
权限分为两层,接手时必须分别理解:
1. **模块访问权限**:决定能否进入能源、智能调度或反馈后台。
2. **数据范围权限**:决定用户可查看全量、部门或个人负责的数据。
### 4.1 数据范围角色
| 业务系统角色 | JWT `permissionLevel` | 数据范围 |
| --- | --- | --- |
| `所有权限``数智中心``BI-Leader` | `full` | 全量数据 |
| `BI-Leader-Dep` | `department` | `departmentName / department = user.depName` |
| 其他角色或无上述角色 | `personal` | `managerId = user.userId` |
数据过滤和客户名称脱敏由 `src/server/auth/permissions.ts` 提供。目前明确接入该过滤的主要范围包括:
- 资产车辆统计与列表;
- 里程实时监控;
- 里程考核车辆明细;
- 智能调度建议。
注意:数据权限不是数据库行级安全,而是路由读取数据后在服务端过滤。新增 API 时必须主动调用统一过滤逻辑或实现等价的 SQL 过滤,不能只依赖前端隐藏。
### 4.2 模块访问角色
| 模块 / 能力 | 允许角色 | 前端控制 | 后端控制 |
| --- | --- | --- | --- |
| 资产管理、里程管理、车辆热力图 | 已认证用户 | 导航默认展示 | 统一 JWT 中间件 |
| 能源 BI(氢能 / 电能 / ETC) | `BI-LEADER-ENERGY``所有权限` | `/energy` 门禁 | `/api/energy/*` 守卫 |
| 加氢热力图 | `BI-LEADER-ENERGY``所有权限` | 无权限时不出现在热力图子导航 | `/api/hydrogen-heatmap/*` 守卫 |
| 智能调度 | `BI-SCHEDULE-OPT` | 无角色时不显示模块 | `/api/scheduling/*` 守卫 |
| 反馈管理 | `BI-ADMIN-FEEDBACK` 或任一全量数据角色 | 隐藏页面,需直接访问 | 管理列表与更新接口校验 |
| 用户反馈提交 / 我的反馈 | 已认证用户 | 页面反馈入口 | 统一 JWT 中间件 |
角色常量和判断位于 `src/shared/auth/roles.ts`
### 4.3 容易误解的权限边界
- `full` 只表示数据范围,不代表自动拥有所有模块权限。
- `数智中心``BI-Leader` 虽会获得全量数据,但当前不会自动进入能源模块;能源仅额外接受 `所有权限`
- 智能调度当前仅接受 `BI-SCHEDULE-OPT`,不会因为拥有 `full` 数据权限自动放行。
- 前端隐藏不是安全措施;任何新增受限能力都必须增加后端守卫。
## 5. 本地免登录调试环境
### 5.1 环境要求
- Node.js 22Docker 和 CI 均使用 Node 22);
- npm,依赖版本以 `package-lock.json` 为准;
- 能访问所需数据库和外部 API 的网络环境;
- 本地配置文件 `.env`。该文件已被 `.gitignore` 忽略,禁止提交。
### 5.2 最小启动步骤
```bash
npm ci
npm run dev
```
启动后:
| 服务 | 地址 | 说明 |
| --- | --- | --- |
| Vite 前端 | `http://localhost:3000` | 对局域网开放,`/api` 代理到 3001 |
| Hono 后端 | `http://localhost:3001` | `SERVER_PORT` 未设置时的默认端口 |
| 健康检查 | `http://localhost:3001/api/health` | 只验证进程可访问,不验证数据库 |
### 5.3 免登录开关
本地前后端需要同时开启免登录:
```dotenv
# 仅用于本地开发,生产环境严禁设置为 1
VITE_DEV_BYPASS_AUTH=1
DEV_BYPASS_AUTH=1
```
- `VITE_DEV_BYPASS_AUTH=1`:前端直接构造“本地开发”全权限用户,仅在 Vite dev 模式生效。
- `DEV_BYPASS_AUTH=1`:后端认证中间件注入本地开发用户,否则前端虽然进入页面,API 仍会返回 401。
- 本地用户包含 `所有权限``BI-SCHEDULE-OPT``BI-ADMIN-FEEDBACK``BI-LEADER-ENERGY`
- 免登录只绕过认证,不会替代数据库、OneOS API、高德地图或 OSS 配置。
### 5.4 `.env` 配置模板
以下仅列变量名和占位符,不得把真实值写入本文或提交到 Git:
```dotenv
# 认证
EXTERNAL_API_BASE=https://<业务系统域名>
JWT_SECRET=<高强度随机密钥>
DEV_BYPASS_AUTH=1
VITE_DEV_BYPASS_AUTH=1
# 主业务 MySQL
DB_HOST=<host>
DB_PORT=3306
DB_USER=<user>
DB_PASSWORD=<password>
DB_NAME=<database>
# 氢能 MySQL;不填时复用 DB_*
HYDROGEN_DB_HOST=<host>
HYDROGEN_DB_PORT=3306
HYDROGEN_DB_USER=<user>
HYDROGEN_DB_PASSWORD=<password>
HYDROGEN_DB_NAME=<database>
# 历史里程 MySQL 连接配置
# 当前 src/server/mileage-db.ts 未被运行时代码引用;不要误认为修改后会影响里程页面
MILEAGE_DB_HOST=<host>
MILEAGE_DB_PORT=3306
MILEAGE_DB_USER=<user>
MILEAGE_DB_PASSWORD=<password>
MILEAGE_DB_NAME=<database>
# 车辆位置 PostgreSQL
HEATMAP_DB_HOST=<host>
HEATMAP_DB_PORT=5432
HEATMAP_DB_USER=<read-only-user>
HEATMAP_DB_PASSWORD=<password>
HEATMAP_DB_NAME=<database>
HEATMAP_DB_SSL=false
# OneOS 里程 API
ONEOS_MILEAGE_API_BASE_URL=https://<api-host>
ONEOS_MILEAGE_API_KEY=<api-key>
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
# 高德地图
AMAP_WEB_KEY=<web-key>
AMAP_SECURITY_JS_CODE=<security-code>
# 用户反馈截图 OSS
OSS_REGION=<region>
OSS_ENDPOINT=<endpoint>
OSS_BUCKET=<bucket>
OSS_ACCESS_KEY_ID=<access-key-id>
OSS_ACCESS_KEY_SECRET=<access-key-secret>
OSS_BASE_DIR=<directory>
# 运行参数
SERVER_PORT=3001
MILEAGE_REPORT_AUTO_ARCHIVE=0
```
本地调试建议将 `MILEAGE_REPORT_AUTO_ARCHIVE=0`,避免开发进程在 06:30 自动生成正式日报快照。
## 6. 技术栈
### 6.1 前端
| 分类 | 技术 |
| --- | --- |
| 语言 | TypeScript、TSX、CSS |
| 框架 | React 19 |
| 构建工具 | Vite 6 |
| 样式 | Tailwind CSS 4 + 模块专用 CSS |
| 图表 | Recharts 3;氢能原型包含自定义 DOM / CSS 图表 |
| 动效 | Motion for React |
| 图标 | Lucide React |
| Excel | SheetJS `xlsx` |
| 地图 | 高德地图 JS API |
| 路由 | 浏览器 Path + Hash 自研轻量路由,无 React Router |
### 6.2 后端
| 分类 | 技术 |
| --- | --- |
| 语言 / 运行时 | TypeScript、Node.js 22、ES Module |
| Web 框架 | Hono 4 + `@hono/node-server` |
| TypeScript 运行 | `tsx` |
| 认证 | `jsonwebtoken` |
| MySQL | `mysql2/promise` |
| PostgreSQL | `pg` |
| 对象存储 | `ali-oss` |
| 配置 | `dotenv` / 容器环境变量 |
| 测试 | Node.js 内置 Test Runner |
### 6.3 构建与部署
- `npm run lint`TypeScript 静态检查;
- `npm test`:运行 `src/**/*.test.ts`
- `npm run build`:生成 Vite `dist`
- `npm run start`Node 直接通过 `tsx` loader 启动服务;
- DockerNode 22 Alpine 多阶段构建;
- CIWoodpecker 执行安装、静态检查、测试、构建、Docker 镜像构建和 Harbor 推送;
- 镜像标签:`<分支名>-<package.json version>`
- 当前 Compose 使用 host 网络,容器服务端口由 `SERVER_PORT` 注入,现有部署样例为 `8111`
## 7. 数据对接与数据源映射
### 7.1 运行时数据源总表
| 数据源 / 对接方 | 连接方式 | 配置入口 | 主要数据 | 消费模块 |
| --- | --- | --- | --- | --- |
| 羚牛业务系统认证服务 | HTTPS API | `EXTERNAL_API_BASE` | jumpToken、用户、部门编码、角色 | 登录认证 |
| 主业务 MySQL | MySQL | `DB_*` | 车辆、合同、客户、部门、资产流转、电费、ETC、反馈、调度记录等 | 资产、里程关联信息、调度、电能、ETC、反馈 |
| 氢能业务库 | MySQL | `HYDROGEN_DB_*`;未配置则复用 `DB_*` | 加氢流水、加氢站、付款 / 结算、站点余额等 | 氢能 BI、单站、按日、下钻 |
| 里程考核数据 | 主业务 MySQL / 跨 Schema 查询 | 当前实际使用 `DB_*` | 考核目标、考核车辆、日报快照 | 里程统计、日报、智能调度 |
| OneOS 里程服务 | HTTPS API | `ONEOS_MILEAGE_API_*` | 按日 / 区间车辆里程、来源协议 | 里程监控、日报、调度 |
| 车辆位置分析库 | PostgreSQL 只读 | `HEATMAP_DB_*` | 每车每日首个有效定位点 | 车辆热力图 |
| 高德地图 | 浏览器 JS API | `AMAP_WEB_KEY``AMAP_SECURITY_JS_CODE` | 地图底图、空间展示 | 车辆 / 加氢热力图 |
| 阿里云 OSS | OSS SDK | `OSS_*` | 用户反馈截图 | 反馈提交、反馈管理 |
| 本地地区映射 | JSON | `src/server/routes/mileage/region-map.json` | 城市到运营区域映射 | 里程筛选和区域聚合 |
`src/server/mileage-db.ts` 仍定义了一组 `MILEAGE_DB_*` MySQL 连接,但当前没有任何运行时代码导入该连接池。因此它属于历史遗留配置,不计入当前有效运行时数据源;接手人不应通过修改 `MILEAGE_DB_*` 来排查现有里程页面。
### 7.2 主业务 MySQL 的主要表 / Schema
当前部署样例中 `DB_NAME` 指向业务库,同时代码存在 `lingniu_prod.*` 跨 Schema 查询。数据库账号必须具备实际所需 Schema 的最小权限。
| 数据域 | 代表表 | 用途 |
| --- | --- | --- |
| 车辆主数据 | `vehicle_info``vehicle_status``vehicle_model` | 资产、车辆归属、车型、运营状态 |
| 租赁 / 客户 | `vehicle_lease_order_record``vehicle_lease_contract_info``customer_info` | 客户、部门、经理、项目 |
| 资产流转 | `delivery_vehicle``return_vehicle_task``vehicle_replacement` | 交车、退车、换车周统计及明细 |
| 实时车辆 | `tab_truck_remote_sync_realtime_info` | 当日里程、车辆省份 |
| 里程考核 | `lingniu_prod.tab_mileage_assessment_target``lingniu_prod.tab_mileage_assessment_vehicle` | 目标、车辆、完成率 |
| 里程日报 | `lingniu_prod.tab_mileage_daily_report` | 每日归档快照 |
| 电费 | `bi_ele_charge_record` | XLSX 导入后的充电记录 |
| ETC | `etc_toll_record``energy_etc_bill` | 通行明细、应收和已收 |
| 调度 | `tab_scheduling_notifications` | 调度干预、执行状态和历史 |
| 用户反馈 | `bi_user_feedback` | 反馈、截图地址、回复和状态 |
### 7.3 氢能 BI 主要数据表
| 表 | 用途 |
| --- | --- |
| `hydrogen_fuel_ledger` | 加氢主流水,统计加氢量、成本、对客金额、车辆、客户、核对状态和数据来源 |
| `hydrogen_station` | 历史加氢站主数据 |
| `new_hydrogen_site` | 当前业务站点主数据及省市区等属性 |
| `hydrogen_station_payment` | 加氢站付款 / 结算记录 |
| `new_hydrogen_site_balance_record` | 站点预充值余额记录 |
| `tab_outside_hydrogen_site` | 外部站点与内部站点、坐标的映射,用于热力图 |
| `common_district` | 行政区划名称映射 |
氢能 V2 API 统一提供总览、按日树、下钻等视图。旧 `/hydrogen/*` 接口仍保留用于兼容和回滚,但当前氢能页面入口只装配 `hydrogen-bi-v2/PrototypeBoard.tsx`
### 7.4 数据口径注意事项
- 氢能“车辆归属”和“成本承担方”是两个独立维度,不得互相替代。
- 氢能成本承担方按业务字段归纳为“我司承担、客户承担、其他”;利润口径是客户承担订单的“对客总价 - 等量订单成本总价”。
- 电能导入按 `order_no` 去重,并根据车辆信息匹配内部 / 外部 / 未知车辆。
- ETC 金额、应收和已收直接读取台账,不做推算;没有数据时前端展示空状态。
- 里程按自然日和上海时区处理;OneOS API 支持单日和区间查询,并按协议优先级归一化来源。
- 客户名称会根据数据权限在服务端进行脱敏。
## 8. 当前功能清单
### 8.1 资产管理 BI
| 功能域 | 当前能力 |
| --- | --- |
| 资产总览 | 总资产、运营、库存、待交付、周交车 / 退车 / 换车 |
| 车型分析 | 按车辆类型、车型、批次逐级统计和展开 |
| 部门分析 | 按部门 / 经理统计、展开车辆明细 |
| 区域分析 | 按大区、省市、客户筛选与统计 |
| 客户分析 | 客户多选、品牌、部门、经理、区域筛选 |
| 库存分析 | 按区域 / 车型切换,支持多级展开和筛选 |
| 资产流转 | 自定义日期范围,交车 / 退车 / 换车趋势与明细下钻 |
| 车辆明细 | 车牌、车型、批次、客户、状态、位置等筛选和弹窗 |
| 导出 | 资产相关列表和明细 Excel 导出 |
| 自动刷新 | 主页面数据每 60 秒刷新 |
### 8.2 里程管理 BI
| 子页面 | 当前能力 |
| --- | --- |
| 实时监控 | 当日 / 指定日 / 区间里程,来源协议优先级、部门、客户、项目、主体、状态、区域、品牌、里程区间等筛选 |
| 实时监控 | 当日 / 累计 / 统计时间排序,异常和在线状态识别,车辆详情 |
| 统计报表 | 考核目标、目标车辆、累计完成率、当年完成率、日均要求、趋势下钻 |
| 每日汇报 | 运营 / 库存车辆、当日里程、环比、车型与地区分组、近 7 日趋势、历史快照 |
| 导出 | 监控区间和车辆汇总 Excel 导出 |
| 缓存 | 服务启动立即刷新,之后每分钟刷新实时监控缓存 |
| 自动归档 | 上海时间每天 06:30 归档上一自然日日报,可通过环境变量关闭 |
### 8.3 智能调度
| 功能 | 当前能力 |
| --- | --- |
| 建议生成 | 根据考核里程、剩余天数、车辆状态和车型生成高低里程调度建议 |
| 筛选 | 按建议类型、部门、车型等条件筛选和搜索 |
| 详情 | 查看当前车辆、候选车辆及差距 |
| 操作 | 单条 / 批量登记调度干预 |
| 历史 | 通知 / 干预记录查询、状态更新、取消或完成 |
| 导出 | 建议列表 CSV 导出 |
### 8.4 车辆热力图
- 按日期范围、车牌 / VIN、考核批次筛选;
- 定位活跃度 / 车辆覆盖度切换;
- 网格聚合、全国概览、区域下钻和附近车辆;
- 地图和详情面板可独立展开,支持全屏;
- PostgreSQL 连接配置为只读事务默认值。
### 8.5 加氢热力图
- 按日期、站点搜索、承担维度筛选;
- 加氢量、加氢频次、车辆覆盖三种热力指标;
- 加氢站排名、区域下钻、附近站点和 GPS 覆盖提示;
- 仅对有有效坐标的数据进行地图聚合;
- 受能源角色控制。
### 8.6 氢能经营 BI(当前 V2 页面)
| 页面 / 维度 | 当前能力 |
| --- | --- |
| 全局总览 | 年份、核对状态、车辆归属筛选;累计加氢量、累计成本、利润、本月、本日 KPI |
| 趋势 | 月度加氢量、月度收支、站点 Top5、区域省 / 市占比 |
| 汇总 | 加氢站汇总、客户账单汇总、列表折叠、排序和筛选 |
| KPI 下钻 | 支持先站点后客户或先客户后站点,再到车辆 / 流水 |
| 图表下钻 | 月份、站点、区域、客户等上下文穿透 |
| 账单下钻 | 客户 → 日期 → 车辆流水;站点 → 日期 → 客户 / 流水 |
| 按日页面 | 日期范围、车辆归属、核对状态;日期 → 站点 → 客户 → 车辆 / 数据源多层展开 |
| 单站页面 | 站点日期筛选、日汇总、趋势、流水和导出 |
| 导出 | KPI 穿透、客户账单、站点账单、每日明细 Excel |
| 接口 | `/api/energy/h2/v2/meta``overview``daily``daily-tree``drill` |
说明:原型源码中仍有演示常量和兼容组件;真实运行数据应以 `prototype-adapter.ts``prototype-real-daily.tsx``prototype-real-drills.tsx` 和 V2 API 返回为准。修改页面时必须同时检查 Web 与移动端,不能只以测试通过代替原型截图验收。
### 8.7 电能经营 BI
| 页面 / 能力 | 当前实现 |
| --- | --- |
| 按日 | 日充电量、费用、订单、内部 / 外部车辆和趋势 |
| 总览 | 汇总 KPI、月份趋势、车辆归属统计 |
| 数据导入 | 隐藏入口上传 `.xlsx`,按订单号去重、批次管理、记录搜索和归属汇总 |
| 数据表 | `bi_ele_charge_record`,接口首次调用时自动确保建表 |
### 8.8 ETC 看板
- 通行明细笔数;
- 涉及车辆去重数;
- 通行费金额;
- ETC 账单数、应收和已收;
- 最新通行时间;
- 无台账时展示“暂无数据”,不生成模拟数据。
### 8.9 用户反馈闭环
- 普通用户提交新维度、Bug、体验或其他反馈;
- 支持最多 6 张截图,单张最大 5 MB,上传至 OSS;
- 用户查看自己的反馈历史;
- 管理员按状态查看、回复和更新为待处理 / 处理中 / 已完成 / 已忽略;
- 反馈表由接口首次调用时自动创建并兼容补列。
## 9. API 路由概览
| 路由前缀 | 用途 |
| --- | --- |
| `/api/auth` | jumpToken 换 JWT、查看当前 JWT 用户 |
| `/api/vehicles` | 资产总览、车型 / 部门 / 区域 / 客户 / 库存、流转、车辆明细 |
| `/api/mileage` | 实时监控、目标、趋势、车辆近期里程、日报及历史 |
| `/api/scheduling` | 调度建议、通知和执行记录 |
| `/api/energy` | 氢能 V2 / 兼容接口、电能经营、ETC |
| `/api/ele` | 电费 XLSX 导入、列表、批次、聚合 |
| `/api/vehicle-heatmap` | 车辆位置热力图配置、元数据、点位和附近车辆 |
| `/api/hydrogen-heatmap` | 加氢热力图配置、元数据、点位和附近站点 |
| `/api/feedback` | 反馈提交、截图上传、我的反馈、管理列表和更新 |
## 10. 后台任务与自动建表
### 10.1 服务启动动作
`src/server/bootstrap.ts` 在正式服务进程启动时执行:
1. 确保 `tab_scheduling_notifications` 存在;
2. 立即刷新里程监控缓存;
3. 每 60 秒刷新里程监控缓存;
4. 启动里程日报 06:30 自动归档调度器。
### 10.2 按需自动建表
以下接口会在首次使用时执行 `CREATE TABLE IF NOT EXISTS`
- 电费:`bi_ele_charge_record`
- 用户反馈:`bi_user_feedback`
- 里程日报:`lingniu_prod.tab_mileage_daily_report`
- 智能调度:`tab_scheduling_notifications`(服务启动时)。
生产数据库账号若严格只读,这些功能会启动或调用失败。交接时应明确“读数据账号”和“应用写表账号”的权限边界。
## 11. 开发、测试和发布流程
### 11.1 常用命令
```bash
# 安装锁定依赖
npm ci
# 前后端开发模式
npm run dev
# 仅后端 / 仅前端
npm run dev:server
npm run dev:client
# 质量检查
npm run lint
npm test
npm run build
# 生产式本地启动(需先 build
npm run start
```
### 11.2 发版步骤
1. 确认工作区只包含本次变更,排除 `.DS_Store`、临时脚本和本地数据文件。
2. 执行 `npm run lint && npm test && npm run build`
3. 按语义版本更新 `package.json``package-lock.json`
4. 提交并推送到目标分支。
5. Woodpecker 根据分支和版本生成镜像标签并推送 Harbor。
6. 在 Portainer / Compose 中更新镜像版本,保留原镜像标签用于回滚。
7. 验证健康检查、登录跳转、关键 API、Web 页面和移动端页面。
版本只在 Git 中更新不代表已部署;必须分别确认:
- 代码已推送;
- CI 已通过;
- 镜像已推送;
- 部署已更新;
- 页面和数据已验收。
## 12. 日志与排障入口
| 现象 | 优先检查 |
| --- | --- |
| 无法从业务系统登录 | 浏览器 URL 是否有 `jumpToken``/api/auth/exchange` 响应;`EXTERNAL_API_BASE`;后端认证日志 |
| 页面能进但 API 401 | 前后端免登录是否同时开启;`bi_jwt` 是否存在;JWT_SECRET 是否一致 |
| 页面 403 | 业务系统角色、JWT 中 `roles`、模块白名单,不要只看 `permissionLevel` |
| 健康检查正常但页面无数据 | `/api/health` 不检查数据库;继续检查具体 API、数据库连接和 SQL 权限 |
| 里程无数据 | OneOS API 地址 / Key、网络、traceId、协议兼容降级日志、主库车辆关联信息 |
| 热力图无底图 | 高德 Key 和安全码、域名白名单、浏览器控制台 |
| 车辆热力图无点位 | PostgreSQL 连接、只读权限、日期范围、`is_heatmap_eligible` |
| 氢能数据慢 | V2 API 查询耗时、筛选范围、数据库索引、是否误走兼容接口 |
| 反馈图片失败 | OSS 配置、Bucket 权限、文件类型和 5 MB 限制 |
| 日报未归档 | `MILEAGE_REPORT_AUTO_ARCHIVE`、容器时区、06:30 日志、日报表写权限 |
## 13. 已知风险与优先整改项
### P0:凭据管理
1. 当前仓库的部署配置和部分数据库连接代码存在硬编码凭据或默认口令。交接后应立即:
- 将数据库、JWT、Harbor、OSS、API Key 全部迁移到 CI Secret / Portainer Secret / 环境变量;
- 删除代码和配置中的真实 fallback;
- 对已经进入 Git 历史的凭据执行轮换;
- 不要只删除当前文件内容而忽略历史提交。
2. `Dockerfile` 和认证代码存在可预测的 JWT 默认密钥。生产必须显式注入随机 `JWT_SECRET`,并在缺失时让应用拒绝启动。
### P1:认证与权限
1. 前端当前用公开的 `/api/health` 判断缓存 JWT 是否有效,因此该请求并未真正校验 Token;过期 Token 会在第一次访问受保护 API 时才被清理。应改为调用 `/api/auth/me`
2. `jumpToken` 通过查询参数传递,可能进入代理访问日志;业务条件允许时应改为 POST body,并限制日志记录。
3. `cors()` 当前未限制来源;生产应配置允许域名白名单。
4. `full` 数据角色与模块角色不是同一套白名单,容易产生“能看全量数据但进不了模块”的误解。变更角色策略前应由产品 / 管理员确认。
5. 隐藏页面不是授权控制。充电导入接口目前仅依赖“已登录”,若属于管理能力,应补独立角色和后端守卫。
### P1:数据与运行稳定性
1. 主库、跨 Schema 查询和专用数据库连接并存,部署账号权限和 Schema 默认值需形成正式清单。
2. `src/server/mileage-db.ts` 是当前未被引用的历史连接文件,`docker-compose.yml` 仍保留相应变量,容易误导排障;确认无外部依赖后应删除或重新接入。
3. 应用启动和部分接口会自动建表;数据库权限收紧前需先迁移为正式数据库变更脚本。
4. 氢能兼容接口和 V2 接口同时存在,新增功能应明确修改当前 V2 路径,避免只修旧接口。
5. 氢能原型目录仍包含演示常量;新增展示必须确认数据来自真实 API,禁止把演示数据带入经营口径。
6. 车辆和加氢热力图的默认日期目前为代码常量,长期运行应改为根据数据水位或当前日期计算。
### P2:工程治理
1. 仓库缺少统一根 README,本交接文档可作为后续 README 的基础。
2. 本地 `.DS_Store` 容易进入工作区,建议补充全局 / 项目忽略规则。
3. 目前使用 Path + Hash 自研路由;新增页面时必须同时验证直达、刷新、前进后退和移动端底栏状态。
4. 氢能页面属于严格原型复刻范围,CSS / DOM 调整必须做桌面与移动端截图对比验收。
## 14. 交接资料与权限清单
以下内容不应写入 Git,需要由原负责人通过安全渠道单独移交:
- Gitea 项目成员权限及分支保护规则;
- Woodpecker 项目、流水线和 Secret 管理权限;
- Harbor 项目及镜像拉取 / 推送账号;
- Portainer / ECS / Docker 服务管理权限;
- 主业务、氢能、里程和位置数据库的只读 / 读写账号;
- OneOS 里程 API 地址、API Key、调用方白名单和联系人;
- 羚牛业务系统认证接口联系人和 jumpToken 协议;
- 高德地图 Key、安全码及域名白名单;
- OSS Bucket、RAM 用户和目录权限;
- 生产域名、反向代理、证书和 DNS 管理权限;
- 数据口径负责人:资产、里程、调度、氢能、电能、ETC 各一名。
## 15. 接手验收清单
### 15.1 代码与环境
- [ ] 能拉取 `main` 并确认目标版本;
- [ ] Node.js 22、`npm ci`、lint、test、build 均通过;
- [ ] 已获得不含明文传播的本地 / 测试环境变量;
- [ ] 本地前后端和免登录调试正常;
- [ ] `.env`、数据库导出、截图和临时文件不会进入 Git。
### 15.2 认证与权限
- [ ] 从业务系统 jumpToken 跳转登录成功;
- [ ] 普通、部门、全量三类数据权限分别验收;
- [ ] 能源、智能调度、反馈管理员角色分别验收;
- [ ] 无权限用户前端不可见且后端返回 403;
- [ ] Token 过期后能正确退出并重新登录。
### 15.3 数据与功能
- [ ] 资产、里程、调度、氢能、电能、ETC 的主要 API 都能访问真实数据;
- [ ] BI 汇总值与最小粒度明细守恒;
- [ ] 氢能成本承担方、对客金额、成本金额和利润口径已由业务负责人确认;
- [ ] 里程 OneOS API 的单日、区间和来源协议正常;
- [ ] 车辆 / 加氢热力图可加载地图、点位和详情;
- [ ] Excel / CSV 导入导出可用;
- [ ] 用户反馈和截图上传可用;
- [ ] 06:30 日报归档在测试环境完成一次验证。
### 15.4 发布与回滚
- [ ] Woodpecker 流水线通过;
- [ ] Harbor 中存在对应版本镜像;
- [ ] Portainer / Compose 使用目标镜像标签和正确 Secret;
- [ ] 部署后验证健康检查、登录、API、Web 和移动端;
- [ ] 记录上一稳定镜像标签、数据库变更和回滚步骤。
## 16. 维护原则
1. **先确认真实数据口径,再改页面**:经营指标必须能从汇总下钻到真实流水。
2. **前端隐藏不代替后端鉴权**:任何管理或敏感能力都要有 API 守卫。
3. **配置与凭据分离**:代码只保留变量名和安全默认行为,不保留真实密码。
4. **原型页面以截图验收**:氢能模块必须对照原型验证布局、样式、交互、下钻层级和移动端。
5. **变更必须可回滚**:发版保留旧镜像,数据库变更先备份、再迁移、再核对。
6. **区分可达与可用**:健康检查正常不代表数据库、外部 API 和经营数据正常。
@@ -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 的兼容处理
- 看似可合并但必须保持差异的流程
命名和类型能够表达清楚的代码不额外添加注释。
## 已识别但不在本轮修复的问题
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
- 监控页不同入口的请求参数目前不完全一致。
- 数据库连接配置与部署凭据需要独立安全治理。
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ln-bi",
"version": "1.1.13",
"version": "1.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.13",
"version": "1.1.15",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.13",
"version": "1.1.15",
"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();
}
+223
View File
@@ -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
View File
@@ -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 (
+38
View File
@@ -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'],
);
});
+72
View File
@@ -0,0 +1,72 @@
import { lazy } from 'react';
import {
Activity,
Fuel,
MapPinned,
Route,
Truck,
Wallet,
Zap,
} 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: Zap, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Wallet, 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;
}
+20
View File
@@ -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'), '');
});
+56
View File
@@ -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),
};
}
-99
View File
@@ -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
View File
@@ -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();
}
-12
View File
@@ -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} />
</>
);
}
-228
View File
@@ -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)}
</>
);
}
+36 -350
View File
@@ -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)}
<FeedbackLauncher
isAdmin={isAdmin}
menuOpen={menuOpen}
onCloseMenu={() => setMenuOpen(false)}
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
onToggleMenu={() => setMenuOpen(current => !current)}
/>
)}
</div>
<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"
<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); }}
/>
</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>
</>
);
}
-166
View File
@@ -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)}
</>
);
}
+13 -5
View File
@@ -83,6 +83,9 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
const { user } = useAuth();
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
// 氢能经营看板已在模块内提供原型一致的标题与导航,避免桌面端重复显示全局面包屑。
const showDesktopBreadcrumb = activeModule !== 'hydrogen';
const isHydrogenPrototype = activeModule === 'hydrogen';
const watermarkText = useMemo(() => {
const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -92,14 +95,16 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
return (
<DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen">
{/* 全局水印 */}
{/* 氢费看板按原型交付;其他模块继续保留全局水印 */}
{!isHydrogenPrototype ? (
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
backgroundRepeat: 'repeat',
}} />
</div>
{/* Web 侧边栏 (md 及以上) */}
) : null}
{/* Web 侧边栏 (md 及以上):能源各模块使用同一套规格,以电能为基准。 */}
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
<Building2 size={22} />
@@ -118,7 +123,8 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn(
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
'group relative flex flex-col items-center justify-center transition-all',
'h-16 w-16 rounded-2xl text-[10px] font-black',
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
@@ -157,15 +163,17 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
{showDesktopBreadcrumb ? (
<div className={cn('pointer-events-none fixed right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex', isHydrogenPrototype ? 'left-[72px]' : 'left-20')}>
<span className="text-slate-600"> BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
) : null}
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />}
</Suspense>
<FeedbackFab module={activeModule} />
{!isHydrogenPrototype ? <FeedbackFab module={activeModule} /> : null}
</main>
{/* 移动端底部导航 (md 以下) */}
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
export type FeedbackStep = 1 | 2 | 3;
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
export interface UploadedImg {
url: string;
thumbDataUrl: string;
}
+33 -90
View File
@@ -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>
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;
};
+310
View File
@@ -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);
});
+412
View File
@@ -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);
}
+55 -276
View File
@@ -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="充电记录导入"
@@ -164,203 +123,49 @@ export default function EleImportPage() {
</div>
)}
>
{/* 上传区 */}
<section
<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 f = e.dataTransfer.files?.[0];
if (f) onPick(f);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) onPick(droppedFile);
}}
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)}
onInputChange={(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>
{/* 上传结果提示 */}
<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>
{/* 聚合卡 */}
<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>
{/* 批次 */}
{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">
<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"
<UploadFeedback
result={result}
error={error}
onCloseResult={() => setResult(null)}
onCloseError={() => setError(null)}
/>
<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>
<SummaryKpis overall={overall} />
<BatchTable
batches={batches}
batchFilter={batchFilter}
onClearFilter={() => setBatchFilter('')}
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)}
/>
<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>
<RotatingFooterHint className="pb-4" />
@@ -368,29 +173,3 @@ export default function EleImportPage() {
</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>
);
}
+44
View File
@@ -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');
});
+41
View File
@@ -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);
}
+52
View File
@@ -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;
}
-110
View File
@@ -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>
);
}
-315
View File
@@ -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>
);
}
+31 -163
View File
@@ -1,184 +1,52 @@
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 { useEffect, useState } from 'react';
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react';
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
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>
);
function formatYuan(value: number) {
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
}
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);
let cancelled = false;
fetchEtcOverview()
.then(result => { if (!cancelled) setData(result); })
.catch(reason => { if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); });
return () => { cancelled = true; };
}, []);
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);
if (error) return <ErrorState message="ETC 数据源暂时不可用,请稍后刷新。" />;
if (!data) return <LoadingState label="正在核对 ETC 台账" />;
if (!data.hasData) {
return (
<div className="flex flex-col gap-3">
<EmptyState title="暂无 ETC 通行台账" description="当前账单和通行明细均未入库,接入后会自动展示统计数据。" />
<RotatingFooterHint hints={['ETC 台账状态已按数据库实时核对', '待通行明细入库后自动生成统计']} />
</div>
);
}
};
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>
<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>
<MetricTile icon={ReceiptText} label="通行明细" value={data.tollRecordCount.toLocaleString('zh-CN')} unit="" helper={data.latestTollTime ? `最新记录 ${data.latestTollTime}` : '暂无通行时间'} />
<MetricTile icon={CarFront} label="涉及车辆" value={data.vehicleCount.toLocaleString('zh-CN')} unit="台" helper="按通行明细车牌去重" tone="emerald" />
<MetricTile icon={WalletCards} label="通行费金额" value={formatYuan(data.totalAmount)} helper="通行费与服务费合计" tone="amber" />
<MetricTile icon={FileText} label="ETC 账单" value={data.billCount.toLocaleString('zh-CN')} unit="" helper={`应收 ${formatYuan(data.receivableAmount)} · 已收 ${formatYuan(data.paidAmount)}`} tone="slate" />
</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 ? '已配置' : '未配置'}
<SurfaceCard className="p-4">
<div className="flex items-start gap-2 text-xs font-bold text-slate-500">
<BadgeCheck size={17} className="mt-0.5 shrink-0 text-emerald-500" />
<div> ETC </div>
</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>
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} />
{error && <ErrorState message={error} />}
<RotatingFooterHint />
</div>
);
}
+50 -316
View File
@@ -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 { motion, AnimatePresence } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react';
import { AnimatePresence, motion } 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,154 +48,55 @@ 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 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}
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<AnalysisScopeSwitch
ariaLabel="电能车辆范围"
value={vehicleScope}
options={VEHICLE_SCOPE_OPTIONS}
onChange={updateVehicleScope}
icon={Truck}
className="mt-2"
/>
</SurfaceCard>
<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"
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'} />
</div>
{/* 外部车辆 数据未就绪 */}
{showExternalEmpty && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 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 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 月份分组表 */}
{!showExternalEmpty && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
@@ -260,23 +104,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 +131,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 +142,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}`}
<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="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">
<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>
</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>
);
})}
@@ -427,7 +162,6 @@ export default function ElectricDaily() {
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
+5 -13
View File
@@ -4,10 +4,9 @@ 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 },
{ id: 'daily', label: '日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
@@ -17,23 +16,16 @@ export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<PageFrame
title="电能成本看板"
subtitle="围绕充电量、费用、日趋势车辆归属展示电能支出结构,辅助识别费用波动。"
title="电能经营看板"
subtitle="充电量、费用、日趋势车辆归属"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="electric" />}
meta="按日 · 总览"
>
<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>
</FadeIn>
</AnimatePresence>
</PageFrame>
+20 -49
View File
@@ -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,37 @@ 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 chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电`
: '本月每日充电';
const dataIsCurrentMonth = trendMonthLabel === currentMonth;
const chartTitle = trendMonthLabel ? `${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 latestDay = trendData.at(-1) ?? null;
const displayedMonthKwh = dataIsCurrentMonth ? k.monthKwh : trendData.reduce((sum, item) => sum + item.kwh, 0);
const displayedMonthFee = dataIsCurrentMonth ? k.monthFee : trendData.reduce((sum, item) => sum + item.fee, 0);
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 = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0;
const dataAsOf = data.latestChargeTime ?? '暂无充电记录';
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 ?? '暂无记录'} · ·
<div className="rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[11px] font-bold text-slate-400">
{dataAsOf} · {dataIsCurrentMonth ? '本月数据' : `${trendMonthLabel ?? '暂无'} 数据`}
</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={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'} />
<MetricTile icon={CalendarClock} label={dataIsCurrentMonth ? '本月充电费' : '数据最新月费用'} value={fmtYuan(displayedMonthFee)} helper={fmtKwh(displayedMonthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`${dataIsCurrentMonth ? '本月' : '最新月'} ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={BatteryCharging} label={dataIsCurrentMonth ? '今日充电' : '最新日充电'} value={(dataIsCurrentMonth ? k.todayKwh : latestDay?.kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={dataIsCurrentMonth ? `${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%` : `${latestDay?.date ?? '暂无记录'} · ${fmtYuan(latestDay?.fee ?? 0)}`} tone={dataIsCurrentMonth && 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 +75,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 ? (displayedMonthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{dataIsCurrentMonth ? '本月' : '最新月'} / </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 +113,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>
+2 -4
View File
@@ -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>
@@ -0,0 +1,97 @@
import './styles/energy-bi-board.css';
export type HydrogenBoardScope = 'global' | 'station';
export type HydrogenBoardView = 'daily' | 'overview';
export function HydrogenBoardNotice() {
return null;
}
export default function HydrogenBoardChrome({
scope,
view,
rangeText,
onScopeChange,
onViewChange,
}: {
scope: HydrogenBoardScope;
view: HydrogenBoardView;
rangeText?: string;
onScopeChange: (scope: HydrogenBoardScope) => void;
onViewChange: (view: HydrogenBoardView) => void;
}) {
return (
<header className="ehb-chrome">
<div className="ehb-chrome__lead">
<div className="ehb-crumb"> BI / </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 2, flexWrap: 'wrap' }}>
<h1 style={{ margin: 0 }}></h1>
{scope === 'global' && rangeText ? (
<span
className="ehb-time-range-pill"
style={{
fontSize: 12,
color: '#0284c7',
background: '#eff6ff',
border: '1px solid #bae6fd',
padding: '3px 10px',
borderRadius: 16,
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
boxShadow: '0 1px 2px rgba(2, 132, 199, 0.06)',
}}
>
📅 {rangeText}
</span>
) : null}
</div>
</div>
<div className="ehb-chrome__tools">
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="范围">
<button
type="button"
role="tab"
className={scope === 'global' ? 'is-active' : ''}
aria-selected={scope === 'global'}
onClick={() => onScopeChange('global')}
>
</button>
<button
type="button"
role="tab"
className={scope === 'station' ? 'is-active' : ''}
aria-selected={scope === 'station'}
onClick={() => onScopeChange('station')}
>
</button>
</div>
{scope === 'global' ? (
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="视图">
<button
type="button"
role="tab"
className={view === 'daily' ? 'is-active' : ''}
aria-selected={view === 'daily'}
onClick={() => onViewChange('daily')}
>
</button>
<button
type="button"
role="tab"
className={view === 'overview' ? 'is-active' : ''}
aria-selected={view === 'overview'}
onClick={() => onViewChange('overview')}
>
</button>
</div>
) : null}
</div>
</header>
);
}
+276 -506
View File
@@ -1,534 +1,304 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Building2, ChevronRight, Fuel, Plug, ReceiptText, TrendingUp, Truck, UserRound, X } 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 RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { fetchHydrogenDaily, fetchHydrogenDailyDetail, type HydrogenVerifyScope } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyDetailResponse, HydrogenDailyRow } from './types';
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';
buildHydrogenDailyTrend,
filterHydrogenRowsByStation,
getHydrogenDailyStations,
getQuickRange,
getRangeModeLabel,
mergeHydrogenDailyRows,
normalizeRange,
summarizeHydrogenRows,
type HydrogenDailyBoardScope,
type HydrogenDailyVehicleScope,
type RangeMode,
} from './hydrogen-daily/model';
import DailyRangeControls from './daily-range/DailyRangeControls';
import { DailyDetailTable } from './hydrogen-daily/components/DailyDetailTable';
import { DailyKpiGrid } from './hydrogen-daily/components/DailyKpiGrid';
import { DailyTrendChart } from './hydrogen-daily/components/DailyTrendChart';
import { StationDailyOverview } from './hydrogen-daily/components/StationDailyOverview';
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
import './styles/energy-bi-board.css';
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [
{ id: 'lingniu', label: '羚牛车辆' },
{ id: 'external', label: '外部车辆' },
];
export interface HydrogenDailyProps {
scope?: HydrogenDailyBoardScope;
onRangeTextChange?: (rangeText: string) => void;
}
const HYDROGEN_ORDERS_ID = 'hydrogen-orders';
interface DailyDatasets {
lingniu: HydrogenDailyRow[] | null;
external: HydrogenDailyRow[] | null;
}
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);
export interface DailyDetailState {
loading: boolean;
data: HydrogenDailyDetailResponse | null;
error: string | null;
}
function formatUpdatedAt(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');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
export default function HydrogenDaily({ scope = 'global', onRangeTextChange }: HydrogenDailyProps) {
const [vehicleScope, setVehicleScope] = useState<HydrogenDailyVehicleScope>('all');
// Kept explicit even though this view has no verification toggle yet: drill
// requests always declare their data scope instead of silently defaulting.
const verifyScope: HydrogenVerifyScope = 'all';
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
const [datasets, setDatasets] = useState<DailyDatasets>({ lingniu: null, external: null });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryKey, setRetryKey] = useState(0);
const orderTriggerRef = useRef<HTMLElement | SVGElement | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [details, setDetails] = useState<Record<string, DailyDetailState>>({});
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 effectiveRange = useMemo(
() => normalizeRange(dateRange.start, dateRange.end),
[dateRange.start, dateRange.end],
);
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
const url = buildHydrogenDrillUrl(window.location, next);
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
setDrillContext(next);
}, []);
useEffect(() => {
onRangeTextChange?.(`${effectiveRange.start}${effectiveRange.end}`);
}, [effectiveRange.start, effectiveRange.end, onRangeTextChange]);
useEffect(() => {
let cancelled = false;
setLoading(true);
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)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
Promise.all([
fetchHydrogenDaily(query, 'lingniu'),
fetchHydrogenDaily(query, 'external'),
])
.then(([lingniu, external]) => {
if (cancelled) return;
setDatasets({ lingniu, external });
setUpdatedAt(formatUpdatedAt(new Date()));
})
.catch((reason) => {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [pick, vehicleScope, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]);
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
// 柱图:按日期升序,用于"从左到右时间流"
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 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);
next.has(date) ? next.delete(date) : next.add(date);
return next;
});
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');
};
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');
};
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);
};
const allRows = useMemo(
() => mergeHydrogenDailyRows(datasets.lingniu, datasets.external),
[datasets.external, datasets.lingniu],
);
const vehicleRows = useMemo(() => {
if (vehicleScope === 'lingniu') return datasets.lingniu;
if (vehicleScope === 'external') return datasets.external;
return allRows;
}, [allRows, datasets.external, datasets.lingniu, vehicleScope]);
const stationOptions = useMemo(() => getHydrogenDailyStations(vehicleRows), [vehicleRows]);
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);
if (scope !== 'station') return;
if (selectedStationId !== null && stationOptions.some((station) => station.id === selectedStationId)) return;
setSelectedStationId(stationOptions[0]?.id ?? null);
}, [scope, selectedStationId, stationOptions]);
const scopedRows = useMemo(() => {
if (scope !== 'station' || selectedStationId === null) return vehicleRows;
return filterHydrogenRowsByStation(vehicleRows, selectedStationId);
}, [scope, selectedStationId, vehicleRows]);
const summary = useMemo(
() => summarizeHydrogenRows(scopedRows),
[scopedRows],
);
const trend = useMemo(
() => buildHydrogenDailyTrend(datasets.lingniu, datasets.external, vehicleScope, scope === 'station' ? selectedStationId : null),
[datasets.external, datasets.lingniu, scope, selectedStationId, vehicleScope],
);
const selectedStation = stationOptions.find((station) => station.id === selectedStationId);
const dayCount = useMemo(() => {
const start = new Date(effectiveRange.start).getTime();
const end = new Date(effectiveRange.end).getTime();
return Math.max(1, Math.round(Math.abs(end - start) / (24 * 3600 * 1000)) + 1);
}, [effectiveRange.end, effectiveRange.start]);
const lingniuSum = useMemo(() => {
return (datasets.lingniu ?? []).reduce((s, r) => s + r.totalKg, 0);
}, [datasets.lingniu]);
const externalSum = useMemo(() => {
return (datasets.external ?? []).reduce((s, r) => s + r.totalKg, 0);
}, [datasets.external]);
const loadDetail = async (date: string, force = false) => {
if (!force && details[date]?.data) return;
setDetails((previous) => ({
...previous,
[date]: { loading: true, data: previous[date]?.data ?? null, error: null },
}));
try {
const data = await fetchHydrogenDailyDetail(
date,
vehicleScope,
scope === 'station' ? selectedStationId : null,
verifyScope,
);
setDetails((previous) => ({
...previous,
[date]: { loading: false, data, error: null },
}));
} catch (reason) {
setDetails((previous) => ({
...previous,
[date]: {
loading: false,
data: previous[date]?.data ?? null,
error: reason instanceof Error ? reason.message : String(reason),
},
}));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const toggleRow = (date: string) => {
setExpanded((previous) => {
const next = new Set(previous);
if (next.has(date)) {
next.delete(date);
} else {
next.add(date);
void loadDetail(date);
}
return next;
});
};
const handleSelectDate = (date: string) => {
setExpanded((previous) => new Set(previous).add(date));
void loadDetail(date);
setHighlightedDate(date);
const element = document.getElementById(`hydrogen-daily-row-${date}`);
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
if (loading && !scopedRows) {
return <LoadingState label="正在加载氢气按日看板数据..." />;
}
if (error && !scopedRows) {
return <ErrorState message={error} />;
}
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}
/>
<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">
<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={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>}
{/* 外部车辆:新系统数据还没准备好 */}
{!error && vehicleScope === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 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 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!error && !(vehicleScope === '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>
<span className="text-[11px] text-slate-400 font-bold"> · Kg</span>
</div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<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}
/>
))}
</Bar>
<defs>
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
)}
{selectedDate && (
<HydrogenOrders
date={selectedDate}
dailyRow={selectedDailyRow}
page={drillContext.orderPage ?? 1}
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={vehicleScope === 'all' ? 'all' : vehicleScope === 'lingniu' ? 'lingniu' : 'external'}
stations={scope === 'station' ? stationOptions : undefined}
selectedStationId={scope === 'station' ? selectedStationId : undefined}
vehicleScope={vehicleScope}
stationId={selectedStationId}
customerName={selectedCustomerName}
onPageChange={changeOrderPage}
onClose={closeOrders}
updatedAt={updatedAt}
loading={loading}
onQuickPick={(p: DateQuickPick) => {
setPick(p);
setDateRange(getQuickRange(p));
}}
onCustomPick={() => setPick('custom')}
onDateRangeChange={(field, value) => {
setPick('custom');
setDateRange((prev) => ({ ...prev, [field]: value }));
}}
onCustomerChange={(cust: CustomerType) => {
setVehicleScope(cust === 'all' ? 'all' : cust === 'lingniu' ? 'lingniu' : 'external');
}}
onStationChange={scope === 'station' ? setSelectedStationId : undefined}
onVehicleScopeChange={setVehicleScope}
onRefresh={() => setRefreshKey((k) => k + 1)}
/>
{error ? (
<div className="flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
<div className="flex items-center gap-2">
<AlertTriangle size={14} className="text-amber-600" />
<span>{error}</span>
</div>
<button
type="button"
onClick={() => setRefreshKey((k) => k + 1)}
className="inline-flex items-center gap-1 font-bold text-amber-900 hover:underline"
>
<RefreshCw size={12} />
</button>
</div>
) : null}
{scope === 'station' && selectedStation ? (
<StationDailyOverview
station={selectedStation}
totalKg={summary.totalKg}
totalFee={summary.totalFee}
averagePrice={summary.avgPrice}
/>
) : null}
<DailyKpiGrid
rangeLabel={getRangeModeLabel(pick)}
rangeText={`${effectiveRange.start}${effectiveRange.end}`}
totalKg={summary.totalKg}
activeDays={summary.activeDays}
dayCount={dayCount}
averageKg={summary.avgKg}
stationCount={summary.stationCount}
vehicleScope={vehicleScope}
lingniuKg={lingniuSum}
externalKg={externalSum}
selectedStationName={scope === 'station' ? selectedStation?.name : undefined}
/>
<DailyTrendChart
rows={trend}
averageKg={summary.avgKg}
peakDay={summary.peakDay}
lowDay={summary.lowDay}
zeroDays={summary.zeroDays}
selectedDate={highlightedDate}
onSelectDate={handleSelectDate}
/>
{scopedRows && scopedRows.length > 0 ? (
<DailyDetailTable
rows={scopedRows}
totalKg={summary.totalKg}
totalFee={summary.totalFee}
expanded={expanded}
highlightedDate={highlightedDate}
details={details}
onToggle={toggleRow}
onRetryDetail={(date) => void loadDetail(date, true)}
/>
) : (
<EmptyState
title="当前筛选条件下没有加氢记录"
description="可尝试切换统计区间、车辆范围或站点。"
/>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!error && !(vehicleScope === '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">
<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">
<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" />
) : rows.length === 0 ? (
<EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" />
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: '';
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>
</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>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{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"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
{s.name}
</div>
{s.pricePerKg > 0 && (
<div className="md:hidden mt-1">
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
{s.pricePerKg} /Kg
</span>
</div>
)}
</div>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
{!error && <RotatingFooterHint />}
</div>
);
}
+6 -38
View File
@@ -1,41 +1,9 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
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 },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
import { EnergyBiBoardApp } from './hydrogen-bi-v2/PrototypeBoard';
/**
* New standalone Hydrogen BI surface. The legacy feature files remain for a
* controlled rollback, but the live hydrogen route now uses only v2.
*/
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="hydrogen" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="hydrogen-view" ariaLabel="氢能分析视图" />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<div
id={`hydrogen-view-panel-${sub}`}
role="tabpanel"
aria-labelledby={`hydrogen-view-tab-${sub}`}
>
<HydrogenView sub={sub} />
</div>
</FadeIn>
</AnimatePresence>
</PageFrame>
);
return <EnergyBiBoardApp />;
}
-185
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useMemo, useState } from 'react';
import { Building2, CalendarDays, Landmark, ReceiptText, ShieldCheck, WalletCards } from 'lucide-react';
import { fetchHydrogenSettlement, type HydrogenSettlementRange, type HydrogenSettlementResponse } from './api';
import { getQuickRange } from './hydrogen-daily/model';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader';
type ViewRange = HydrogenSettlementRange | 'custom';
const RANGE_OPTIONS: { id: ViewRange; label: string }[] = [
{ id: 'latest', label: '最近有数据' },
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近15日' },
{ id: 'custom', label: '自定义' },
];
const matchModeText = {
exact: '精确匹配',
manual: '人工匹配',
group: '集团匹配',
unmatched: '待匹配',
} as const;
function formatYuan(value: number) {
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
}
export default function HydrogenSettlement() {
const [range, setRange] = useState<ViewRange>('latest');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [data, setData] = useState<HydrogenSettlementResponse | null>(null);
const [selectedStation, setSelectedStation] = useState<string>('');
const [sortKey, setSortKey] = useState<'date' | 'stationName' | 'matchMode' | 'paymentCount' | 'amount'>('date');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
const query = range === 'custom'
? { range: 'custom' as const, startDate: dateRange.start, endDate: dateRange.end }
: { range };
fetchHydrogenSettlement(query)
.then(result => { if (!cancelled) setData(result); })
.catch(reason => { if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); });
return () => { cancelled = true; };
}, [range, dateRange.start, dateRange.end]);
const stationOptions = useMemo(() => {
if (!data) return [];
return Array.from(new Set(data.rows.map(row => row.stationName))).sort((a, b) => a.localeCompare(b, 'zh-CN'));
}, [data]);
const rows = useMemo(
() => data?.rows.filter(row => !selectedStation || row.stationName === selectedStation) ?? [],
[data, selectedStation],
);
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]);
const selectedSummary = useMemo(() => ({
amount: rows.reduce((total, row) => total + row.amount, 0),
paymentCount: rows.reduce((total, row) => total + row.paymentCount, 0),
stationDayCount: rows.length,
}), [rows]);
useEffect(() => {
if (selectedStation && !stationOptions.includes(selectedStation)) setSelectedStation('');
}, [selectedStation, stationOptions]);
const setPreset = (next: ViewRange) => {
setRange(next);
if (next !== 'latest' && next !== 'custom') setDateRange(getQuickRange(next));
};
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
return (
<div className="flex flex-col gap-3">
<div className="px-1">
<h2 className="text-base font-black text-slate-900"></h2>
<p className="mt-0.5 text-[11px] font-bold text-slate-400"></p>
</div>
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{RANGE_OPTIONS.map(option => (
<button
key={option.id}
onClick={() => setPreset(option.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${range === 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>
))}
</div>
{range === 'custom' ? (
<div className="mt-2 grid grid-cols-2 gap-2">
{(['start', 'end'] as const).map(field => (
<label key={field} className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span>
<input
type="date"
value={dateRange[field]}
onChange={event => setDateRange(previous => ({ ...previous, [field]: event.target.value }))}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
))}
</div>
) : null}
{stationOptions.length > 0 ? (
<label className="mt-2 flex min-h-10 items-center gap-2 rounded-xl border border-slate-100 bg-slate-50 px-3 text-slate-600">
<Building2 size={14} className="shrink-0 text-slate-400" />
<select
aria-label="筛选现结加氢站"
value={selectedStation}
onChange={event => setSelectedStation(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] font-black outline-none"
>
<option value=""></option>
{stationOptions.map(station => <option key={station} value={station}>{station}</option>)}
</select>
</label>
) : null}
</SurfaceCard>
{error ? <ErrorState message="站日现结台账暂时不可用,请稍后刷新。" /> : null}
{!data && !error ? <LoadingState label="正在读取站日现结台账" /> : null}
{data ? (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={WalletCards} label="现结金额" value={formatYuan(selectedSummary.amount)} helper={`${data.range.start ?? '--'}${data.range.end ?? '--'}`} />
<MetricTile icon={ReceiptText} label="付款流水" value={selectedSummary.paymentCount.toLocaleString('zh-CN')} unit="笔" helper="按付款流水累计" tone="emerald" />
<MetricTile icon={CalendarDays} label="站日记录" value={selectedSummary.stationDayCount.toLocaleString('zh-CN')} unit="条" helper="日期与站点组合" tone="amber" />
<MetricTile icon={Landmark} label="最新付款日" value={data.summary.latestPaymentDate ?? '--'} helper={`涉及 ${selectedStation ? 1 : data.summary.stationCount} 个加氢站`} tone="slate" />
</div>
) : null}
{data && rows.length === 0 ? <EmptyState title="当前统计范围没有付款流水" description="可选择“最近有数据”查看最近一次已入库的站日现结记录。" /> : null}
{data && rows.length > 0 ? (
<SurfaceCard className="overflow-hidden">
<div className="flex items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
<div>
<h3 className="text-sm font-black text-slate-800"></h3>
<p className="mt-0.5 text-[11px] font-bold text-slate-400"></p>
</div>
<div className="flex items-center gap-1 rounded-lg bg-emerald-50 px-2 py-1 text-[10px] font-black text-emerald-600">
<ShieldCheck size={13} />
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-[620px] w-full text-left text-[12px]">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="px-4 py-3"><SortableColumnHeader label="付款日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="px-4 py-3"><SortableColumnHeader label="加氢站" sortKey="stationName" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="px-4 py-3"><SortableColumnHeader label="匹配方式" sortKey="matchMode" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="px-4 py-3 text-right"><SortableColumnHeader label="流水笔数" sortKey="paymentCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="px-4 py-3 text-right"><SortableColumnHeader label="现结金额" sortKey="amount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 font-bold text-slate-700">
{sortedRows.map(row => (
<tr key={`${row.date}-${row.stationId ?? row.stationName}`}>
<td className="whitespace-nowrap px-4 py-3 text-slate-500">{row.date}</td>
<td className="max-w-[260px] truncate px-4 py-3 text-slate-800" title={row.stationName}>{row.stationName}</td>
<td className="px-4 py-3"><span className="rounded-md bg-slate-100 px-2 py-1 text-[10px] text-slate-500">{matchModeText[row.matchMode]}</span></td>
<td className="px-4 py-3 text-right">{row.paymentCount}</td>
<td className="whitespace-nowrap px-4 py-3 text-right font-black text-emerald-600">{formatYuan(row.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</SurfaceCard>
) : null}
<RotatingFooterHint hints={['付款金额仅取已入库流水,不补算应付或未付金额', '登记、审批与付款操作不在 BI 页面开放']} />
</div>
);
}
+544
View File
@@ -0,0 +1,544 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowLeft,
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Download,
Fuel,
RefreshCw,
Wallet,
X,
} from 'lucide-react';
import * as XLSX from 'xlsx';
import { fetchHydrogenStationBoard } from './api';
import type {
HydrogenStationBoardCustomerMonth,
HydrogenStationBoardResponse,
HydrogenStationBoardStation,
HydrogenStationBoardSummaryDailyRow,
} from './types';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader';
import './styles/energy-bi-board.css';
import './hydrogen-bi-v2/prototype-station-daily.css';
function formatYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function defaultRange() {
const end = new Date();
end.setHours(0, 0, 0, 0);
const start = new Date(end);
start.setDate(start.getDate() - 9);
return { start: formatYmd(start), end: formatYmd(end) };
}
function formatNumber(value: number, digits = 2): string {
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function regionLabel(station: HydrogenStationBoardStation): string {
const parts = [station.province, station.city].filter(part => part && part !== '未归属');
return [...new Set(parts)].join(' · ') || '区域待补充';
}
function resetPageScroll() {
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
window.scrollTo(0, 0);
}
export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) {
const [dateRange, setDateRange] = useState(defaultRange);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const [data, setData] = useState<HydrogenStationBoardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (force = false) => {
setLoading(true);
try {
const result = await fetchHydrogenStationBoard({
startDate: dateRange.start,
endDate: dateRange.end,
stationId: selectedStationId,
force,
});
setData(result);
setError(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setLoading(false);
}
}, [dateRange.end, dateRange.start, selectedStationId]);
useEffect(() => { void load(false); }, [load]);
useEffect(() => {
// 站点列表较长,切换列表/详情时统一从页面顶部开始展示。
resetPageScroll();
const frame = window.requestAnimationFrame(resetPageScroll);
return () => window.cancelAnimationFrame(frame);
}, [selectedStationId]);
const selectedStation = data?.stations.find(station => station.id === selectedStationId) ?? null;
if (error && !data) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-5 text-sm text-red-700">
<div></div>
<button type="button" onClick={() => void load(true)} className="mt-3 rounded-lg bg-white px-3 py-2 text-xs font-bold shadow-sm"></button>
</div>
);
}
return (
<div className={embedded ? 'sd-embedded' : 'relative flex flex-col gap-3'}>
{!(selectedStation && data?.selected) ? <StationBoardHeader
embedded={embedded}
selectedStation={selectedStation}
range={dateRange}
latestLedgerTime={data?.summary.latestLedgerTime ?? null}
loading={loading}
onBack={() => {
setSelectedStationId(null);
window.requestAnimationFrame(resetPageScroll);
}}
onRangeChange={setDateRange}
onRefresh={() => void load(true)}
onExport={() => data && exportStationEvidence(data, selectedStation)}
/> : null}
{data ? selectedStation && data.selected ? (
<StationDetail
station={selectedStation}
data={data}
latestLedgerTime={data.summary.latestLedgerTime}
loading={loading}
onBack={() => setSelectedStationId(null)}
onRangeChange={setDateRange}
onRefresh={() => void load(true)}
onExport={() => exportStationEvidence(data, selectedStation)}
/>
) : (
<StationOverview
data={data}
onSelectStation={stationId => {
setSelectedStationId(stationId);
window.requestAnimationFrame(resetPageScroll);
}}
/>
) : (
<StationBoardSkeleton />
)}
{loading && data ? (
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-white/45 backdrop-blur-[1px]">
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-xs font-bold text-slate-600 shadow-xl">
<RefreshCw size={15} className="animate-spin text-blue-600" />
</div>
</div>
) : null}
</div>
);
}
function StationBoardHeader({
embedded,
selectedStation,
range,
latestLedgerTime,
loading,
onBack,
onRangeChange,
onRefresh,
onExport,
}: {
embedded: boolean;
selectedStation: HydrogenStationBoardStation | null;
range: { start: string; end: string };
latestLedgerTime: string | null;
loading: boolean;
onBack: () => void;
onRangeChange: (range: { start: string; end: string }) => void;
onRefresh: () => void;
onExport: () => void;
}) {
if (embedded && !selectedStation) {
return <header className="sd-topbar sd-topbar--embedded"><p className="sd-topbar__updated"> {latestLedgerTime ?? '暂无账本时间'}</p><div className="sd-topbar__tools"><StationRangePicker range={range} onChange={onRangeChange} /><button type="button" onClick={onRefresh} disabled={loading} className="sd-btn sd-btn--ghost"><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> </button></div></header>;
}
return (
<header className="rounded-[12px] border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
{selectedStation ? (
<button type="button" onClick={onBack} className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50">
<ArrowLeft size={15} />
</button>
) : null}
<div className="min-w-0 flex-1">
{selectedStation ? <h1 className="truncate text-[18px] font-black text-slate-950">{selectedStation.name}</h1> : null}
{selectedStation ? <p className="mt-0.5 text-[11px] font-medium text-slate-500">{regionLabel(selectedStation)} · {range.start} {range.end}</p> : null}
<p className={`${selectedStation ? 'mt-1' : ''} text-[11px] font-medium text-slate-400`}> {latestLedgerTime ?? '暂无账本时间'}</p>
</div>
<label className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600">
<Calendar size={14} className="text-blue-600" />
<span className="hidden sm:inline"></span>
<input aria-label="开始日期" type="date" value={range.start} onChange={event => onRangeChange({ ...range, start: event.target.value })} className="w-[110px] bg-transparent outline-none" />
<span></span>
<input aria-label="结束日期" type="date" value={range.end} onChange={event => onRangeChange({ ...range, end: event.target.value })} className="w-[110px] bg-transparent outline-none" />
</label>
<button type="button" onClick={onRefresh} disabled={loading} className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-60">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
{selectedStation ? (
<button type="button" onClick={onExport} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
<Download size={14} />
</button>
) : null}
</div>
</header>
);
}
function StationRangePicker({ range, onChange }: { range: { start: string; end: string }; onChange: (range: { start: string; end: string }) => void }) {
const [open, setOpen] = useState(false);
const [activeBoundary, setActiveBoundary] = useState<'start' | 'end'>('start');
const [draft, setDraft] = useState(range);
const initial = new Date(`${range.start}T00:00:00`);
const [displayMonth, setDisplayMonth] = useState({ year: initial.getFullYear(), month: initial.getMonth() + 1 });
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const closeWhenOutside = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', closeWhenOutside);
return () => document.removeEventListener('mousedown', closeWhenOutside);
}, []);
const anchor = new Date(`${range.end}T00:00:00`);
const apply = (next: { start: string; end: string }, close = false) => {
const normalized = next.start <= next.end ? next : { start: next.end, end: next.start };
setDraft(normalized);
if (close) { onChange(normalized); setOpen(false); }
};
const today = formatYmd(anchor);
const startOfWeek = new Date(anchor);
startOfWeek.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7));
const shortcuts = {
today: { start: today, end: today },
week: { start: formatYmd(startOfWeek), end: formatYmd(new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6)) },
month: { start: `${anchor.getFullYear()}-${String(anchor.getMonth() + 1).padStart(2, '0')}-01`, end: formatYmd(new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0)) },
};
const daysInMonth = new Date(displayMonth.year, displayMonth.month, 0).getDate();
const leadingDays = new Date(displayMonth.year, displayMonth.month - 1, 1).getDay();
const selectDay = (day: number) => {
const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (activeBoundary === 'start') {
const next = { start: value, end: value > draft.end ? value : draft.end };
setDraft(next); setActiveBoundary('end'); return;
}
const next = value < draft.start ? { start: value, end: draft.start } : { start: draft.start, end: value };
setDraft(next); setActiveBoundary('start');
};
return <div className="sd-date sd-date--right" ref={rootRef}>
<button type="button" className={`sd-date__trigger ${open ? 'is-open' : ''}`} aria-label="查询日期" aria-haspopup="dialog" aria-expanded={open} onClick={() => {
if (!open) { setDraft(range); setActiveBoundary('start'); setDisplayMonth({ year: initial.getFullYear(), month: initial.getMonth() + 1 }); }
setOpen(value => !value);
}}><span className="sd-date__label"></span><span className="sd-date__value">{range.start} {range.end}</span><ChevronDown size={15} aria-hidden className="sd-date__icon" /></button>
{open ? <div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label="查询日期">
<div className="sd-date__shortcuts"><button type="button" onClick={() => apply(shortcuts.today, true)}></button><button type="button" onClick={() => apply(shortcuts.week, true)}></button><button type="button" onClick={() => apply(shortcuts.month, true)}></button></div>
<div className="sd-date__range-tabs"><button type="button" className={activeBoundary === 'start' ? 'is-on' : ''} onClick={() => setActiveBoundary('start')}> {draft.start}</button><button type="button" className={activeBoundary === 'end' ? 'is-on' : ''} onClick={() => setActiveBoundary('end')}> {draft.end}</button></div>
<div className="sd-date__header"><button type="button" className="sd-date__nav" aria-label="上一月" onClick={() => setDisplayMonth(value => value.month === 1 ? { year: value.year - 1, month: 12 } : { ...value, month: value.month - 1 })}><ChevronLeft size={16} /></button><div className="sd-date__title">{displayMonth.year}{String(displayMonth.month).padStart(2, '0')}</div><button type="button" className="sd-date__nav" aria-label="下一月" onClick={() => setDisplayMonth(value => value.month === 12 ? { year: value.year + 1, month: 1 } : { ...value, month: value.month + 1 })}><ChevronRight size={16} /></button></div>
<div className="sd-date__week">{['日','一','二','三','四','五','六'].map(day => <span key={day}>{day}</span>)}</div><div className="sd-date__grid">{Array.from({ length: leadingDays }, (_, index) => <span key={`empty-${index}`} className="sd-date__day is-empty" />)}{Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const classes = ['sd-date__day', value === draft.start || value === draft.end ? 'is-selected' : '', value >= draft.start && value <= draft.end ? 'is-in-range' : ''].filter(Boolean).join(' '); return <button key={day} type="button" className={classes} onClick={() => selectDay(day)}>{day}</button>; })}</div>
<div className="sd-date__footer sd-date__footer--range"><button type="button" className="sd-date__today" onClick={() => apply(shortcuts.today, true)}></button><button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={() => apply(draft, true)}></button></div>
</div> : null}
</div>;
}
function StationOverview({ data, onSelectStation }: { data: HydrogenStationBoardResponse; onSelectStation: (id: number) => void }) {
const [displayMode, setDisplayMode] = useState<'all' | 'single'>('all');
const [displayStationId, setDisplayStationId] = useState(data.stations[0]?.id ?? 0);
const [dailyDrill, setDailyDrill] = useState<'kg' | 'fee' | 'payment' | null>(null);
const [partnersExpanded, setPartnersExpanded] = useState(false);
const { selfManagedStations, partnerStations } = useMemo(() => {
const selfManaged = data.stations
.filter(isSelfManagedStation)
.sort((left, right) => selfManagedStationRank(left) - selfManagedStationRank(right));
const selfManagedIds = new Set(selfManaged.map(station => station.id));
return {
selfManagedStations: selfManaged,
partnerStations: data.stations
.filter(station => station.kg > 0 && !selfManagedIds.has(station.id))
.sort((left, right) => right.kg - left.kg || left.name.localeCompare(right.name, 'zh-CN')),
};
}, [data.stations]);
const visiblePartnerStations = partnersExpanded ? partnerStations : partnerStations.slice(0, 10);
const selectedStation = data.stations.filter(station => station.id === displayStationId);
// Top5 的条形、图例与钻取入口必须共用同一份按加氢量降序的结果。
const shareStations = data.stations
.filter(station => station.kg > 0)
.sort((left, right) => right.kg - left.kg)
.slice(0, 5);
const shareTrackTotal = shareStations.reduce((sum, station) => sum + station.kg, 0);
return (
<>
<section aria-label="全站核心指标" className="sd-hero-kpis">
<div className="sd-hero-kpi"><span className="sd-hero-kpi__label"></span><strong className="sd-hero-kpi__value">{data.summary.stationCount}<span></span></strong><span className="sd-hero-kpi__sub"> · {data.summary.activeStationCount} </span></div>
<StationSummaryMetric label="统计加氢总量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`${data.summary.recordCount} 车次 · 点按查看日明细`} onClick={() => setDailyDrill('kg')} />
<StationSummaryMetric label="统计加氢量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`¥${formatNumber(data.summary.totalFee)} · ${data.summary.daily.length}`} onClick={() => setDailyDrill('fee')} />
<StationSummaryMetric label="统计现结金额" value={formatNumber(data.summary.paymentAmount)} unit="元" helper={`${data.summary.daily.filter(item => item.paymentAmount > 0).length} 天有进账`} onClick={() => setDailyDrill('payment')} />
</section>
<section className="sd-share-panel" aria-label="加氢量占比Top5">
<div className="sd-share-panel__head"><h2 className="sd-share-panel__title">Top5 <span className="sd-share-panel__range">{data.range.start} {data.range.end}</span></h2><span className="sd-share-panel__total"> <strong>{formatNumber(data.summary.totalKg)} Kg</strong></span></div>
<div className="sd-share-track">{shareStations.map((station, index) => <button key={station.id} type="button" className={`sd-share-seg sd-share-seg--${index}`} style={{ width: `${shareTrackTotal > 0 ? (station.kg / shareTrackTotal) * 100 : 0}%` }} onClick={() => onSelectStation(station.id)}><span className="sd-share-seg__pct">{(station.share * 100).toFixed(1)}%</span><span className="sd-share-seg__name">{station.name}</span></button>)}</div>
<ul className="sd-share-legend">{shareStations.map((station, index) => <li key={station.id}><button type="button" className="sd-share-legend__btn" onClick={() => onSelectStation(station.id)}><span className={`sd-share-legend__swatch sd-share-seg--${index}`} /><span className="sd-share-legend__rank" aria-label={`${index + 1}`}>{index + 1}</span><span className="sd-share-legend__name">{station.name}</span><span className="sd-share-legend__val">{formatNumber(station.kg)} Kg · {(station.share * 100).toFixed(1)}%</span></button></li>)}</ul>
</section>
<section aria-label="各站经营概况">
<div className="sd-section-head"><h2 className="sd-section-title"></h2><div className="sd-board-mode" role="tablist" aria-label="各站展示方式"><button type="button" role="tab" aria-selected={displayMode === 'all'} onClick={() => setDisplayMode('all')} className={displayMode === 'all' ? 'is-on' : ''}></button><button type="button" role="tab" aria-selected={displayMode === 'single'} onClick={() => setDisplayMode('single')} className={displayMode === 'single' ? 'is-on' : ''}></button>{displayMode === 'single' ? <select aria-label="选择站点" value={displayStationId} onChange={event => setDisplayStationId(Number(event.target.value))} className="sd-board-station-select">{data.stations.map(station => <option key={station.id} value={station.id}>{station.name}</option>)}</select> : null}</div></div>
{displayMode === 'all' ? <div className="sd-station-groups">
<StationGroup title="自营站" count={selfManagedStations.length} stations={selfManagedStations} onSelectStation={onSelectStation} emptyText="自营站数据待接入" />
<StationGroup title="合作站" count={partnerStations.length} stations={visiblePartnerStations} onSelectStation={onSelectStation} emptyText="本区间暂无合作站加氢量" />
{partnerStations.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setPartnersExpanded(value => !value)}>{partnersExpanded ? '收起合作站' : `更多合作站(还有 ${partnerStations.length - 10} 站)`} <ChevronDown size={14} aria-hidden className={partnersExpanded ? 'rotate-180' : ''} /></button> : null}
</div> : <div className="sd-station-single-card"><StationRows stations={selectedStation} onSelectStation={onSelectStation} /></div>}
</section>
<StationSummaryDailyDialog
mode={dailyDrill}
range={data.range}
rows={data.summary.daily}
onClose={() => setDailyDrill(null)}
/>
</>
);
}
function isSelfManagedStation(station: HydrogenStationBoardStation): boolean {
return station.name.includes('佛山南海羚牛') || station.name.includes('东鹏大道');
}
function selfManagedStationRank(station: HydrogenStationBoardStation): number {
return station.name.includes('佛山南海羚牛') ? 0 : 1;
}
function StationGroup({ title, count, stations, onSelectStation, emptyText }: {
title: string;
count: number;
stations: HydrogenStationBoardStation[];
onSelectStation: (id: number) => void;
emptyText: string;
}) {
return <section className="sd-station-group" aria-label={title}>
<div className="sd-station-group__head"><h3>{title}</h3><span>{count} </span></div>
{stations.length ? <div className="sd-station-single-card"><StationRows stations={stations} onSelectStation={onSelectStation} /></div> : <p className="sd-station-group__empty">{emptyText}</p>}
</section>;
}
function StationRows({ stations, onSelectStation }: { stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void }) {
return <>{stations.map(station => <button key={station.id} type="button" onClick={() => onSelectStation(station.id)} className="sd-station-row"><div className="sd-station-row__main"><span className="sd-station-card__icon"><Fuel size={17} /></span><div><div className="sd-station-card__name">{station.name}</div><div className="sd-station-card__region">{regionLabel(station)}</div></div>{station.kg > 0 ? <MiniSpark values={station.dailyKg.map(item => item.kg)} /> : <div className="sd-spark sd-spark--empty" />}<div className="sd-station-row__metrics"><StationRowMetric label="区间加氢" value={formatNumber(station.kg)} unit="Kg" /><StationRowMetric label="区间金额" value={formatNumber(station.fee)} unit="元" /><StationRowMetric label="现结进账" value={formatNumber(station.paymentAmount)} unit="元" /><div><span className="sd-station-card__m-label"></span><span className="sd-station-card__share">{(station.share * 100).toFixed(1)}%</span></div></div><span className="sd-station-card__go"><ChevronRight size={17} /></span></div></button>)}</>;
}
function StationSummaryMetric({ label, value, unit, helper, onClick }: { label: string; value: string; unit?: string; helper: string; onClick: () => void }) {
return <button type="button" onClick={onClick} className="sd-hero-kpi sd-hero-kpi--click"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}{unit ? <span>{unit}</span> : null}</strong><span className="sd-hero-kpi__sub">{helper}</span></button>;
}
function StationSummaryDailyDialog({
mode,
range,
rows,
onClose,
}: {
mode: 'kg' | 'fee' | 'payment' | null;
range: { start: string; end: string };
rows: HydrogenStationBoardSummaryDailyRow[];
onClose: () => void;
}) {
const [sortKey, setSortKey] = useState<'date' | 'recordCount' | 'kg' | 'fee' | 'paymentCount' | 'paymentAmount'>('date');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]);
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
if (!mode) return null;
const title = mode === 'kg'
? '统计加氢总量 · 日明细'
: mode === 'fee'
? '统计加氢金额 · 单日数据'
: '统计现结金额 · 单日数据';
return (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/60 p-3 backdrop-blur-[4px]" role="presentation" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section className="flex max-h-[82vh] w-full max-w-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="station-summary-drill-title">
<header className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
<div>
<h3 id="station-summary-drill-title" className="text-[15px] font-black text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] font-medium text-slate-400">{range.start} {range.end}</p>
</div>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200" aria-label="关闭"><X size={16} /></button>
</header>
<div className="overflow-auto">
{mode === 'payment' ? (
<table className="w-full min-w-[420px] text-[12px]">
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结笔数" sortKey="paymentCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结金额(元)" sortKey="paymentAmount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.paymentCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.paymentAmount)}</td></tr>)}</tbody>
</table>
) : (
<table className="w-full min-w-[620px] text-[12px]">
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢车次" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢量(Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢金额(元)" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.recordCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.kg)}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.fee)}</td></tr>)}</tbody>
</table>
)}
</div>
</section>
</div>
);
}
function StationRowMetric({ label, value, unit }: { label: string; value: string; unit: string }) {
return <div><span className="sd-station-card__m-label">{label}</span><strong>{value}<span>{unit}</span></strong></div>;
}
function MiniSpark({ values }: { values: number[] }) {
const max = Math.max(...values, 0);
return <div className="sd-spark" aria-label="站点每日加氢趋势">{values.map((value, index) => <span key={index} className="sd-spark__col"><span className="sd-spark__bar" style={{ height: `${max > 0 ? Math.max(6, Math.round((value / max) * 100)) : 0}%` }} /></span>)}</div>;
}
function StationDetail({
station,
data,
latestLedgerTime,
loading,
onBack,
onRangeChange,
onRefresh,
onExport,
}: {
station: HydrogenStationBoardStation;
data: HydrogenStationBoardResponse;
latestLedgerTime: string | null;
loading: boolean;
onBack: () => void;
onRangeChange: (range: { start: string; end: string }) => void;
onRefresh: () => void;
onExport: () => void;
}) {
const detail = data.selected!;
const [hoveredDate, setHoveredDate] = useState<string | null>(null);
const [dailySummaryExpanded, setDailySummaryExpanded] = useState(false);
const lastDaily = detail.daily.at(-1);
const visibleDailySummary = dailySummaryExpanded
? detail.daily
: detail.daily.slice(-30);
const monthKey = data.range.end.slice(0, 7);
const monthRows = detail.customerMonths.filter(item => item.month === monthKey);
const monthKg = monthRows.reduce((sum, row) => sum + row.kg, 0);
const intervalPayments = detail.daily.reduce((sum, row) => sum + row.paymentAmount, 0);
const trendMax = Math.max(...detail.daily.map(row => row.kg), 0);
const hovered = detail.daily.find(row => row.date === hoveredDate) ?? null;
return (
<>
<section className="sd-detail-top">
<div className="sd-detail-top__lead"><button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}><ArrowLeft size={16} /> </button><div><h1 className="sd-detail-top__title">{station.name}</h1><p className="sd-detail-top__meta">{regionLabel(station)} · {data.range.start} {data.range.end}</p><p className="sd-detail-top__updated"> {latestLedgerTime ?? '暂无账本时间'}</p></div></div>
<div className="sd-detail-top__tools"><StationRangePicker range={data.range} onChange={onRangeChange} /><button type="button" className="sd-btn sd-btn--ghost" onClick={onRefresh} disabled={loading}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> </button><button type="button" className="sd-btn sd-btn--primary" onClick={onExport}><Download size={14} /> </button></div>
</section>
<section className="sd-hero-kpis sd-hero-kpis--detail">
<DetailMetric label="当日加氢" value={formatNumber(lastDaily?.kg ?? 0)} unit="Kg" helper={`¥${formatNumber(lastDaily?.fee ?? 0)} · ${lastDaily?.recordCount ?? 0}车次`} />
<DetailMetric label={`${detail.daily.length} 日加氢`} value={formatNumber(station.kg)} unit="Kg" helper={`¥${formatNumber(station.fee)}`} />
<DetailMetric label="本月加氢" value={formatNumber(monthKg)} unit="Kg" helper={monthKey} />
<DetailMetric label={`${detail.daily.length} 日现结`} value={formatNumber(intervalPayments)} unit="元" helper={`${detail.daily.filter(row => row.paymentAmount > 0).length}天有进账`} />
</section>
<section className="sd-panel sd-panel--block">
<h2 className="sd-panel__title">{data.range.start} {data.range.end}</h2>
<div className="sd-table-scroll"><table className="sd-bi-table sd-bi-table--fill"><thead><tr><th> </th><th className="is-num">(Kg) </th><th className="is-num"> </th><th className="is-num"> </th><th className="is-num">() </th><th className="is-num"> </th></tr></thead><tbody>
<tr className="is-total"><td></td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.kg, 0))}</td><td className="is-num"></td><td className="is-num"></td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.fee, 0))}</td><td className="is-num">{detail.daily.reduce((sum, row) => sum + row.recordCount, 0)}</td></tr>
{[...visibleDailySummary].reverse().map(row => <tr key={row.date}><td className="is-mono">{row.date}</td><td className="is-num">{formatNumber(row.kg)}</td><td className={`is-num ${row.changeKg > 0 ? 'is-stock-up' : row.changeKg < 0 ? 'is-stock-down' : 'is-stock-flat'}`}>{row.changeKg > 0 ? '+' : ''}{formatNumber(row.changeKg)}{row.changeKg !== 0 ? <span className="sd-delta">{row.changeKg > 0 ? '▲' : '▼'}</span> : null}</td><td className="is-num">{formatNumber(row.avgPrice)}</td><td className="is-num">{formatNumber(row.fee)}</td><td className="is-num">{row.recordCount}</td></tr>)}
{detail.daily.length > 30 ? <tr><td colSpan={6} className="sd-table-more"><button type="button" onClick={() => setDailySummaryExpanded(value => !value)}>{dailySummaryExpanded ? '收起日期明细' : `更多(还有 ${detail.daily.length - 30} 天)`}</button></td></tr> : null}
</tbody></table></div>
</section>
<section className="sd-panel sd-panel--block"><div className="sd-panel__head-row"><h2 className="sd-panel__title"></h2><span className="sd-trend-legend-chip"><span className="sd-trend-legend-dot" />{station.name}</span></div><div className="sd-trend">{hovered ? <div className="sd-trend-tip"><div className="sd-trend-tip__date">{hovered.date}</div><div className="sd-trend-tip__row"><span className="sd-trend-tip__name"></span><strong>{formatNumber(hovered.kg)} Kg</strong></div><div className="sd-trend-tip__sub">{hovered.recordCount} · ¥{formatNumber(hovered.fee)}</div></div> : null}<div className="sd-trend--fill">{detail.daily.map(row => <div key={row.date} className={`sd-trend__col ${hoveredDate === row.date ? 'is-hover' : ''}`} onMouseEnter={() => setHoveredDate(row.date)} onMouseLeave={() => setHoveredDate(null)}><span className="sd-trend__val">{formatNumber(row.kg, 0)}</span><div className="sd-trend__bar-wrap"><span className="sd-trend__bar" style={{ height: `${trendMax > 0 ? Math.max(3, Math.round((row.kg / trendMax) * 100)) : 0}%` }} /></div><span className="sd-trend__date">{row.date.slice(5)}</span></div>)}</div></div></section>
<CustomerMonthMatrix title="客户月加氢量汇总(Kg" valueKey="kg" rows={detail.customerMonths} />
<CustomerMonthMatrix title="客户月加氢费汇总(元)" valueKey="fee" rows={detail.customerMonths} />
<StationCashPanels />
</>
);
}
function DetailMetric({ label, value, unit, helper }: { label: string; value: string; unit: string; helper: string }) {
return <div className="sd-hero-kpi sd-hero-kpi--accent"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}<span className="sd-unit">{unit}</span></strong><span className="sd-hero-kpi__sub">{helper}</span></div>;
}
function CustomerMonthMatrix({ title, valueKey, rows }: { title: string; valueKey: 'kg' | 'fee'; rows: HydrogenStationBoardCustomerMonth[] }) {
const months = useMemo(() => [...new Set(rows.map(row => row.month))].sort(), [rows]);
const customers = useMemo(() => [...new Set(rows.map(row => row.customerName))].sort((left, right) => left.localeCompare(right, 'zh-CN')), [rows]);
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false);
const values = useMemo(() => new Map(rows.map(row => [`${row.customerName}\u0000${row.month}`, row[valueKey]])), [rows, valueKey]);
const totals = useMemo(() => new Map(months.map(month => [month, rows.filter(row => row.month === month).reduce((sum,row)=>sum+row[valueKey],0)])), [months, rows, valueKey]);
const visibleCustomers = selectedCustomers.length === 0 ? customers : customers.filter(customer => selectedCustomers.includes(customer));
const shownCustomers = expanded ? visibleCustomers : visibleCustomers.slice(0, 10);
const trendClass = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? 'is-stock-up' : 'is-stock-down';
const trendMark = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? '▲' : '▼';
return (
<section className="sd-panel sd-panel--block">
<div className="sd-panel__head-row"><h2 className="sd-panel__title">{title}</h2><CustomerMultiSelect options={customers} value={selectedCustomers} onChange={setSelectedCustomers} /></div>
<div className="sd-table-scroll sd-table-scroll--matrix"><table className={`sd-bi-table sd-bi-table--matrix ${valueKey === 'fee' ? 'is-amount' : ''}`}><thead><tr><th></th>{months.map(month => <th key={month} className="is-num">{month.replace('-', '年')}</th>)}</tr></thead><tbody>
<tr className="is-total"><td></td>{months.map((month, index) => { const current = totals.get(month) ?? 0; const previous = index > 0 ? totals.get(months[index - 1]) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>
{shownCustomers.map(customer => <tr key={customer}><td title={customer}>{customer}</td>{months.map((month, index) => { const current = values.get(`${customer}\u0000${month}`) ?? 0; const previous = index > 0 ? values.get(`${customer}\u0000${months[index - 1]}`) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>)}
</tbody></table></div>{visibleCustomers.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : `更多(还有 ${visibleCustomers.length - 10} 条)`} <ChevronDown size={14} aria-hidden className={expanded ? 'rotate-180' : ''} /></button> : null}
</section>
);
}
function StationCashPanels() {
return <section className="sd-dual sd-dual--cash">
<section className="sd-panel sd-panel--grow">
<div className="sd-panel__head-row"><h2 className="sd-panel__title"></h2></div>
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th></th><th className="is-num">/</th><th className="is-num"></th><th className="is-num"></th><th className="is-num"></th><th></th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={6}></td></tr></tbody></table></div>
</section>
<section className="sd-panel sd-panel--grow">
<div className="sd-panel__head-row"><h2 className="sd-panel__title">/</h2><span className="sd-panel__meta"></span></div>
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th></th><th></th><th></th><th className="is-num"></th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={4}></td></tr></tbody></table></div>
</section>
</section>;
}
function CustomerMultiSelect({ options, value, onChange }: { options: string[]; value: string[]; onChange: (value: string[]) => void }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
const allSelected = value.length === 0 || value.length === options.length;
const selectedLabel = allSelected ? '全部客户' : value.length <= 2 ? value.join('、') : `已选 ${value.length}`;
const visibleOptions = search.trim() ? options.filter(option => option.includes(search.trim())) : options;
useEffect(() => {
if (!open) return;
const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
const toggle = (customer: string) => {
if (allSelected) { onChange([customer]); return; }
if (value.includes(customer)) { const next = value.filter(item => item !== customer); onChange(next.length === 0 ? [] : next); return; }
const next = [...value, customer];
onChange(next.length === options.length ? [] : next);
};
return <div className={`sd-msel ${open ? 'is-open' : ''}`} ref={rootRef}><button type="button" className="sd-msel__trigger" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(current => !current)}><span className="sd-msel__label"></span><span className="sd-msel__value" title={selectedLabel}>{selectedLabel}</span><ChevronDown size={14} aria-hidden className="sd-msel__chev" /></button>{open ? <div className="sd-msel__panel" role="listbox" aria-multiselectable="true"><div className="sd-msel__search"><input type="search" value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索客户" aria-label="搜索客户" /></div><div className="sd-msel__actions"><button type="button" onClick={() => onChange([])}></button><button type="button" onClick={() => { onChange([]); setOpen(false); }}></button></div><ul className="sd-msel__list">{visibleOptions.length === 0 ? <li className="sd-msel__empty"></li> : visibleOptions.map(option => { const on = allSelected || value.includes(option); return <li key={option}><button type="button" className={`sd-msel__opt ${on ? 'is-on' : ''}`} role="option" aria-selected={on} onClick={() => toggle(option)}><span className="sd-msel__check" aria-hidden>{on ? <Check size={12} /> : null}</span><span className="sd-msel__name">{option}</span></button></li>; })}</ul></div> : null}</div>;
}
function exportStationEvidence(data: HydrogenStationBoardResponse, station: HydrogenStationBoardStation | null) {
if (!station || !data.selected) return;
const workbook = XLSX.utils.book_new();
const daily = [['日期','加氢量(Kg)','较昨日(Kg)','平均单价','加氢金额(元)','车次','现结金额(元)','现结笔数'], ...data.selected.daily.map(row => [row.date,row.kg,row.changeKg,row.avgPrice,row.fee,row.recordCount,row.paymentAmount,row.paymentCount])];
const customers = [['月份','客户','加氢量(Kg)','加氢费(元)','车次'], ...data.selected.customerMonths.map(row => [row.month,row.customerName,row.kg,row.fee,row.recordCount])];
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(daily), '每日汇总');
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(customers), '客户月汇总');
XLSX.writeFile(workbook, `${station.name}_${data.range.start}_${data.range.end}_取证.xlsx`);
}
function StationBoardSkeleton() {
return <div className="grid gap-3"><div className="h-28 animate-pulse rounded-xl bg-slate-200/70" /><div className="h-72 animate-pulse rounded-xl bg-slate-200/70" /></div>;
}
+25 -3
View File
@@ -1,12 +1,34 @@
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
import HydrogenSettlement from './HydrogenSettlement';
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
export type HydrogenSubTab = 'daily' | 'overview';
export type HydrogenSubTab = 'daily' | 'overview' | 'settlement';
interface Props {
sub: HydrogenSubTab;
onSubChange?: (sub: HydrogenSubTab) => void;
onScopeChange?: (scope: HydrogenBoardScope) => void;
onDailyRangeTextChange?: (rangeText: string) => void;
onOverviewRangeTextChange?: (rangeText: string) => void;
}
export default function HydrogenView({ sub }: Props) {
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
export default function HydrogenView({
sub,
onSubChange,
onScopeChange,
onDailyRangeTextChange,
onOverviewRangeTextChange,
}: Props) {
if (sub === 'overview') {
return (
<HydrogenOverview
onSubChange={onSubChange}
onScopeChange={onScopeChange}
onRangeTextChange={onOverviewRangeTextChange}
/>
);
}
if (sub === 'settlement') return <HydrogenSettlement />;
return <HydrogenDaily scope="global" onRangeTextChange={onDailyRangeTextChange} />;
}
+2 -4
View File
@@ -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>
);
}
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildHydrogenSettlementQuery } from './api.js';
test('现结自定义日期明确传递 custom 范围,避免服务端回退到最近有数据', () => {
assert.equal(
buildHydrogenSettlementQuery({
range: 'custom',
startDate: '2026-05-26',
endDate: '2026-05-26',
}),
'range=custom&startDate=2026-05-26&endDate=2026-05-26',
);
});
+131 -55
View File
@@ -1,13 +1,13 @@
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,
HydrogenStationBoardResponse,
HydrogenDailyDetailResponse,
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailResponse,
} from './types';
const BASE = '/api/energy';
@@ -21,106 +21,182 @@ export interface HydrogenOverviewResponse {
stations: HydrogenStationFull[];
availableYears: number[];
year: number;
latestLedgerTime: string | null;
filter: {
stationId: number | null;
vehicleScope: HydrogenVehicleScope;
verifyScope: HydrogenVerifyScope;
};
}
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
export type HydrogenVehicleScope = 'all' | 'lingniu' | 'external';
export type HydrogenVerifyScope = 'all' | 'verified';
export interface HydrogenOverviewQuery {
year?: number;
stationId?: number | null;
vehicleScope?: HydrogenVehicleScope;
verifyScope?: HydrogenVerifyScope;
force?: boolean;
}
export function fetchHydrogenOverview(query: HydrogenOverviewQuery = {}): Promise<HydrogenOverviewResponse> {
const params = new URLSearchParams();
if (year) params.set('year', String(year));
if (force) params.set('force', '1');
if (query.year) params.set('year', String(query.year));
if (query.stationId) params.set('stationId', String(query.stationId));
if (query.vehicleScope && query.vehicleScope !== 'all') params.set('vehicleScope', query.vehicleScope);
if (query.verifyScope === 'verified') params.set('verifyScope', 'verified');
if (query.force) params.set('force', '1');
const q = params.toString();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
}
export interface HydrogenOverviewDetailQuery {
year: number;
vehicleScope?: HydrogenVehicleScope;
verifyScope?: HydrogenVerifyScope;
stationId?: number | null;
month?: string | null;
date?: string | null;
customerName?: string | null;
customerId?: number | null;
plateNo?: string | null;
region?: string | null;
regionGranularity?: 'province' | 'city';
groupBy?: HydrogenOverviewDetailGroupBy | null;
limit?: number;
}
export function fetchHydrogenOverviewDetail(query: HydrogenOverviewDetailQuery): Promise<HydrogenOverviewDetailResponse> {
const params = new URLSearchParams({ year: String(query.year) });
if (query.vehicleScope && query.vehicleScope !== 'all') params.set('vehicleScope', query.vehicleScope);
if (query.verifyScope === 'verified') params.set('verifyScope', 'verified');
if (query.stationId) params.set('stationId', String(query.stationId));
if (query.month) params.set('month', query.month);
if (query.date) params.set('date', query.date);
if (query.customerName) params.set('customerName', query.customerName);
if (query.customerId !== null && query.customerId !== undefined) params.set('customerId', String(query.customerId));
if (query.plateNo) params.set('plateNo', query.plateNo);
if (query.region) params.set('region', query.region);
if (query.regionGranularity) params.set('regionGranularity', query.regionGranularity);
if (query.groupBy) params.set('groupBy', query.groupBy);
if (query.limit) params.set('limit', String(query.limit));
return fetchJson<HydrogenOverviewDetailResponse>(`${BASE}/hydrogen/overview-detail?${params.toString()}`);
}
export interface HydrogenDailyQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
stationId?: number;
customerName?: string;
verifyScope?: HydrogenVerifyScope;
}
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);
if (query.verifyScope === 'verified') q.set('verifyScope', 'verified');
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 fetchHydrogenDailyDetail(
date: string,
customer: CustomerType,
stationId?: number | null,
verifyScope: HydrogenVerifyScope = 'all',
): Promise<HydrogenDailyDetailResponse> {
const params = new URLSearchParams({ date, customer });
if (stationId) params.set('stationId', String(stationId));
if (verifyScope === 'verified') params.set('verifyScope', 'verified');
return fetchJson<HydrogenDailyDetailResponse>(`${BASE}/hydrogen/daily-detail?${params.toString()}`);
}
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 HydrogenStationBoardQuery {
startDate: string;
endDate: string;
stationId?: number | null;
force?: boolean;
}
export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Promise<HydrogenStationBoardResponse> {
const params = new URLSearchParams({ startDate: query.startDate, endDate: query.endDate });
if (query.stationId) params.set('stationId', String(query.stationId));
if (query.force) params.set('force', '1');
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`);
}
export interface ElectricOverviewResponse {
kpi: ElectricKpi;
trend: ElectricDailyRow[];
latestRecordAt: string | null;
latestChargeTime: 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 interface EtcOverviewResponse {
tollRecordCount: number;
vehicleCount: number;
totalAmount: number;
latestTollTime: string | null;
billCount: number;
receivableAmount: number;
paidAmount: number;
hasData: boolean;
}
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;
export type HydrogenSettlementRange = DateQuickPick | 'latest';
export interface HydrogenSettlementRow {
date: string;
stationId: number | null;
stationName: string;
amount: number;
paymentCount: number;
matchMode: 'exact' | 'manual' | 'group' | 'unmatched';
}
function etcListParams(query: EtcListQuery): string {
export interface HydrogenSettlementResponse {
range: { start: string | null; end: string | null; mode: HydrogenSettlementRange | 'custom' };
summary: {
amount: number;
paymentCount: number;
stationDayCount: number;
stationCount: number;
latestPaymentDate: string | null;
};
rows: HydrogenSettlementRow[];
}
export interface HydrogenSettlementQuery {
range?: HydrogenSettlementRange | 'custom';
startDate?: string;
endDate?: string;
}
export function buildHydrogenSettlementQuery(query: HydrogenSettlementQuery = {}) {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.limit) params.set('limit', String(query.limit));
if (query.range) params.set('range', query.range);
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}` : ''}`);
export function fetchHydrogenSettlement(query: HydrogenSettlementQuery = {}): Promise<HydrogenSettlementResponse> {
const q = buildHydrogenSettlementQuery(query);
return fetchJson<HydrogenSettlementResponse>(`${BASE}/hydrogen/settlement${q ? `?${q}` : ''}`);
}
@@ -0,0 +1,67 @@
import { ArrowDown, ArrowDownUp, ArrowUp } from 'lucide-react';
export type SortDirection = 'asc' | 'desc';
export function SortableColumnHeader<Key extends string>({
label,
sortKey,
activeSortKey,
sortDirection,
onSort,
align = 'left',
className = '',
}: {
label: string;
sortKey: Key;
activeSortKey: Key;
sortDirection: SortDirection;
onSort: (sortKey: Key) => void;
align?: 'left' | 'right' | 'center';
className?: string;
}) {
const active = activeSortKey === sortKey;
const Icon = active ? (sortDirection === 'asc' ? ArrowUp : ArrowDown) : ArrowDownUp;
const order = active ? (sortDirection === 'asc' ? '升序' : '降序') : '未排序';
const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start';
return (
<button
type="button"
onClick={() => onSort(sortKey)}
className={`inline-flex w-full items-center ${justify} gap-1 rounded px-1 py-0.5 transition-colors ${active ? 'text-blue-600' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'} ${className}`}
title={`${label}${order},点击切换`}
aria-label={`${label}${order},点击切换`}
aria-sort={active ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<span>{label}</span>
<Icon size={12} strokeWidth={active ? 2.5 : 2} />
</button>
);
}
export function toggleSort<Key extends string>(
currentKey: Key,
currentDirection: SortDirection,
nextKey: Key,
): { key: Key; direction: SortDirection } {
return nextKey === currentKey
? { key: currentKey, direction: currentDirection === 'asc' ? 'desc' : 'asc' }
: { key: nextKey, direction: 'desc' };
}
export function sortBy<Key extends string, Row>(
rows: Row[],
sortKey: Key,
sortDirection: SortDirection,
valueOf: (row: Row, key: Key) => string | number | null | undefined,
): Row[] {
const multiplier = sortDirection === 'asc' ? 1 : -1;
// Keep the caller's row order immutable while remaining compatible with the
// ES2022 target used by this dashboard (Array.prototype.toSorted is ES2023).
return [...rows].sort((left, right) => {
const leftValue = valueOf(left, sortKey) ?? '';
const rightValue = valueOf(right, sortKey) ?? '';
if (typeof leftValue === 'number' && typeof rightValue === 'number') return (leftValue - rightValue) * multiplier;
return String(leftValue).localeCompare(String(rightValue), 'zh-CN', { numeric: true }) * multiplier;
});
}
@@ -0,0 +1,172 @@
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react';
import type { CustomerType, DateQuickPick } from '../types';
import type { HydrogenDailyVehicleScope } from '../hydrogen-daily/model';
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
interface DailyRangeControlsProps {
pick: RangeMode;
dateRange: { start: string; end: string };
customer: CustomerType;
stations?: { id: number; name: string }[];
selectedStationId?: number | null;
vehicleScope?: HydrogenDailyVehicleScope;
updatedAt?: string | null;
loading?: boolean;
onQuickPick: (pick: DateQuickPick) => void;
onCustomPick: () => void;
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
onCustomerChange: (customer: CustomerType) => void;
onStationChange?: (stationId: number | null) => void;
onVehicleScopeChange?: (scope: HydrogenDailyVehicleScope) => void;
onRefresh?: () => void;
}
export default function DailyRangeControls({
pick,
dateRange,
customer,
stations = [],
selectedStationId = null,
vehicleScope,
updatedAt,
loading = false,
onQuickPick,
onCustomPick,
onDateRangeChange,
onCustomerChange,
onStationChange,
onVehicleScopeChange,
onRefresh,
}: DailyRangeControlsProps) {
return (
<section className="rounded-[14px] border border-slate-200/70 bg-white px-3 py-3 shadow-sm md:px-4">
<div className="flex flex-col gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="flex rounded-md bg-slate-100 p-0.5">
{QUICK_PICK_OPTIONS.map(option => (
<button
key={option.id}
type="button"
onClick={() => onQuickPick(option.id)}
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
pick === option.id
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
{option.label}
</button>
))}
<button
type="button"
onClick={onCustomPick}
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
pick === 'custom'
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
</button>
</div>
<DateField label="开始日期" value={dateRange.start} onChange={value => onDateRangeChange('start', value)} />
<DateField label="结束日期" value={dateRange.end} onChange={value => onDateRangeChange('end', value)} />
{onStationChange ? (
<label className="inline-flex h-8 min-w-[190px] max-w-full items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 text-slate-600 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100">
<Fuel size={13} className="shrink-0 text-slate-400" />
<select
aria-label="选择加氢站"
value={selectedStationId ?? ''}
onChange={event => onStationChange(event.target.value === '' ? null : Number(event.target.value))}
className="min-w-0 flex-1 bg-transparent text-[12px] font-semibold text-slate-700 outline-none"
>
<option value=""></option>
{stations.map((station, index) => (
<option key={`${station.id}-${station.name}-${index}`} value={station.id}>{station.name}</option>
))}
</select>
</label>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{onVehicleScopeChange && vehicleScope ? (
<div className="flex rounded-md bg-slate-100 p-0.5" aria-label="车辆归属">
{([
['all', '全部车辆'],
['lingniu', '仅羚牛车辆'],
['external', '仅外部车辆'],
] as const).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => onVehicleScopeChange(id)}
className={`inline-flex h-8 items-center gap-1.5 rounded px-2.5 text-[12px] font-medium transition-colors ${
vehicleScope === id
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
<Truck size={13} />
{label}
</button>
))}
</div>
) : (
<div className="flex rounded-md bg-slate-100 p-0.5">
{(['lingniu', 'external'] as const).map(option => (
<button
key={option}
type="button"
onClick={() => onCustomerChange(option)}
className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${
customer === option
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
<Truck size={13} />
{option === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
)}
<div className="ml-auto flex items-center gap-3">
{updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null}
{onRefresh ? (
<button
type="button"
onClick={onRefresh}
disabled={loading}
title="刷新数据"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 text-[12px] font-semibold text-slate-600 transition-colors hover:border-blue-200 hover:text-blue-600 disabled:cursor-wait disabled:opacity-60"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
) : null}
</div>
</div>
</div>
</section>
);
}
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<label className="inline-flex h-8 items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100">
<Calendar size={13} className="shrink-0 text-sky-600" />
<span className="hidden text-[11px] font-medium text-slate-400 sm:inline">{label}</span>
<input
type="date"
aria-label={label}
value={value}
onChange={event => onChange(event.target.value)}
className="h-7 min-w-[112px] bg-transparent font-mono text-[11px] font-semibold text-slate-700 outline-none"
/>
</label>
);
}
+48
View File
@@ -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 ?? '当前时段';
}
-33
View File
@@ -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',
);
});
-59
View File
@@ -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);
});
-11
View File
@@ -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',
);
});
-63
View File
@@ -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,
});
});
-61
View File
@@ -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,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { fetchJson } from '../../../auth/api-client';
import type {
H2BiDailyResponse,
H2BiDailyTreeQuery,
H2BiDailyTreeResponse,
H2BiDrillQuery,
H2BiDrillResponse,
H2BiMetaResponse,
H2BiOverviewResponse,
H2BiQuery,
} from './types';
const BASE = '/api/energy/h2/v2';
function queryString(query: Record<string, unknown>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
}
return params.toString();
}
function request<T>(path: string, query: object = {}) {
const qs = queryString(query as Record<string, unknown>);
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`);
}
export function fetchH2BiMeta() {
return request<H2BiMetaResponse>('meta');
}
export function fetchH2BiOverview(query: H2BiQuery) {
return request<H2BiOverviewResponse>('overview', query);
}
export function fetchH2BiDaily(query: H2BiQuery) {
return request<H2BiDailyResponse>('daily', query);
}
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
}
export function fetchH2BiDrill(query: H2BiDrillQuery) {
return request<H2BiDrillResponse>('drill', query);
}
@@ -0,0 +1,70 @@
import {
fetchH2BiDaily,
fetchH2BiDailyTree,
fetchH2BiDrill,
fetchH2BiMeta,
fetchH2BiOverview,
} from "./api";
import type { H2BiDailyTreeResponse, H2BiDrillQuery, H2BiQuery } from "./types";
/**
* The only permitted bridge between the imported prototype DOM and live data.
* It deliberately maps data, never presentation: the original component keeps
* its markup, class names, ordering and interaction hierarchy unchanged.
*/
export async function loadPrototypeOverview(query: H2BiQuery) {
const [meta, overview] = await Promise.all([
fetchH2BiMeta(),
fetchH2BiOverview(query),
]);
return {
years: meta.years.map((item) => item.value),
range: overview.range,
watermark: overview.watermark,
kpi: overview.kpis,
monthlyQuantity: overview.monthly.map((item) => ({
month: item.month,
ownKg: item.lingniuKg,
extKg: item.externalKg,
totalKg: item.totalKg,
})),
monthlyRevenue: overview.monthly.map((item) => ({
month: item.month,
customerAmount: item.customerRevenue,
// 收入仅来自客户承担订单;成本按账本 settlement_type 分成客户、
// 我司、其他三段,三段之和严格等于当月总成本。
cost: item.customerCost,
customerCost: item.customerCost,
companyCost: item.companyCost,
otherCost: item.otherCost,
})),
topStations: overview.topStations.map((item, index) => ({
rank: index + 1,
...item,
ownKg: item.lingniuKg,
extKg: item.externalKg,
})),
regions: overview.regions,
stations: overview.stations,
customers: overview.customers,
};
}
export async function loadPrototypeDaily(query: H2BiQuery) {
return fetchH2BiDaily(query);
}
export async function loadPrototypeDailyTree(
date: string,
query: Pick<H2BiQuery, "stationId" | "vehicleScope" | "verifyScope">,
): Promise<H2BiDailyTreeResponse> {
return fetchH2BiDailyTree(date, query);
}
/**
* Keeps the prototype's station -> customer -> vehicle -> record hierarchy,
* while delegating every aggregate and leaf value to the live drill endpoint.
*/
export async function loadPrototypeDrill(query: H2BiDrillQuery) {
return fetchH2BiDrill(query);
}
@@ -0,0 +1,12 @@
import * as XLSX from 'xlsx';
export function downloadExcelAoa(
rows: Array<Array<string | number | boolean | null | undefined>>,
fileName: string,
sheetName: string,
) {
const workbook = XLSX.utils.book_new();
const sheet = XLSX.utils.aoa_to_sheet(rows);
XLSX.utils.book_append_sheet(workbook, sheet, sheetName.slice(0, 31));
XLSX.writeFile(workbook, fileName);
}
@@ -0,0 +1,692 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Download, RefreshCw, Truck } from "lucide-react";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "./api";
import { downloadExcelAoa } from "./prototype-download";
import type {
H2BiDailyResponse,
H2BiDailyTreeResponse,
H2BiDrillResponse,
H2BiQuery,
H2BiVehicleScope,
} from "./types";
const format = (value: number, digits = 2) =>
value.toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
scope === "own" ? "lingniu" : scope;
const sourceLabel = (source: unknown) => {
const value = String(source || "").toLowerCase();
if (value === "lingniu") return "羚牛上报";
if (value === "api") return "API接入";
if (value === "station") return "站点上报";
return String(source || "未知来源");
};
/** Keep raw API statuses out of the UI. Missing/unknown statuses are unverified. */
const verifyLabel = (status: unknown) => {
const value = String(status ?? "").trim().toLowerCase();
return value === "verified" || value === "pass" || value === "已验证"
? "已验证"
: "未验证";
};
export function PrototypeRealDailyView({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
fleetScope,
onFleetScopeChange,
verifyScope,
onRefresh,
}: {
startDate: string;
endDate: string;
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
fleetScope: "all" | "own" | "external";
onFleetScopeChange: (value: "all" | "own" | "external") => void;
verifyScope: "all" | "verified";
onRefresh: () => void;
}) {
const query = useMemo<H2BiQuery>(
() => ({
year: Number(startDate.slice(0, 4)),
startDate,
endDate,
vehicleScope: toScope(fleetScope),
verifyScope,
}),
[endDate, fleetScope, startDate, verifyScope],
);
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
const [trees, setTrees] = useState<Record<string, H2BiDailyTreeResponse>>({});
const [expandedDate, setExpandedDate] = useState<string | null>(null);
const [expandedStation, setExpandedStation] = useState<
Record<string, boolean>
>({});
const [expandedCustomer, setExpandedCustomer] = useState<
Record<string, boolean>
>({});
const [expandedStationLists, setExpandedStationLists] = useState<
Record<string, boolean>
>({});
const [expandedCustomerLists, setExpandedCustomerLists] = useState<
Record<string, boolean>
>({});
const [expandedRecordLists, setExpandedRecordLists] = useState<
Record<string, boolean>
>({});
const [customerRecords, setCustomerRecords] = useState<
Record<string, H2BiDrillResponse>
>({});
const [error, setError] = useState<string | null>(null);
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
useEffect(() => {
let alive = true;
setError(null);
setTrees({});
setExpandedDate(null);
setExpandedStation({});
setExpandedCustomer({});
setExpandedStationLists({});
setExpandedCustomerLists({});
setExpandedRecordLists({});
setCustomerRecords({});
void fetchH2BiDaily(query)
.then((result) => alive && setDaily(result))
.catch(
(reason: unknown) =>
alive &&
setError(
reason instanceof Error ? reason.message : "按日数据加载失败",
),
);
return () => {
alive = false;
};
}, [query]);
const ensureDateTree = (date: string) => {
if (trees[date]) return;
void fetchH2BiDailyTree(date, {
vehicleScope: query.vehicleScope,
verifyScope,
}).then((tree) => setTrees((items) => ({ ...items, [date]: tree })));
};
const openDate = (date: string, scrollIntoDate = false) => {
setExpandedDate(date);
ensureDateTree(date);
if (!scrollIntoDate) return;
setHighlightedDate(date);
window.setTimeout(() => {
dateRowRefs.current[date]?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 40);
window.setTimeout(() => {
setHighlightedDate((current) => (current === date ? null : current));
}, 2200);
};
const toggleDate = (date: string) => {
if (expandedDate === date) {
setExpandedDate(null);
return;
}
openDate(date);
};
const toggleCustomer = (
date: string,
stationId: string | number,
customerId: number,
) => {
const key = `${date}:${stationId}:${customerId}`;
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
if (customerRecords[key]) return;
void fetchH2BiDrill({
...query,
date,
stationId,
customerId,
groupBy: "record",
page: 1,
pageSize: 200,
}).then((result) =>
setCustomerRecords((items) => ({ ...items, [key]: result })),
);
};
const exportRows = () => {
const rows: Array<Array<string | number>> = [
["日期", "加氢站", "客户", "加氢量(Kg)", "成本(元)", "流水笔数"],
];
Object.values(trees).forEach((tree) =>
tree.stations.forEach((station) =>
station.customers.forEach((customer) =>
rows.push([
tree.date,
station.name,
customer.name,
customer.kg,
customer.cost,
customer.recordCount,
]),
),
),
);
downloadExcelAoa(
rows,
`每日加氢数据明细_${startDate}_${endDate}.xlsx`,
"每日加氢明细",
);
};
const trend = daily?.trend ?? [];
const maxKg = Math.max(...trend.map((row) => row.kg), 1);
const averageKg = daily?.kpis.averageDailyKg ?? 0;
const peak = trend.reduce(
(best, row) => (row.kg > best.kg ? row : best),
trend[0],
);
const trough = trend
.filter((row) => row.kg > 0)
.reduce(
(best, row) => (!best || row.kg < best.kg ? row : best),
undefined as (typeof trend)[number] | undefined,
);
return (
<div className="ehb-daily-container">
<section className="ehb-daily-filter-card">
<div className="ehb-daily-filter-row">
<div className="ehb-daily-filter-group">
<div className="ehb-pill-tabs">
<button
type="button"
className="ehb-pill-btn"
onClick={() => {
const today = endDate;
onStartDateChange(today.slice(0, 8) + "01");
}}
>
</button>
<button
type="button"
className="ehb-pill-btn"
onClick={() => {
const end = new Date(`${endDate}T00:00:00`);
const begin = new Date(end);
begin.setDate(end.getDate() - 14);
onStartDateChange(begin.toISOString().slice(0, 10));
}}
>
15
</button>
<button type="button" className="ehb-pill-btn is-active">
</button>
</div>
<input
className="ehb-modal-select"
type="date"
value={startDate}
onChange={(event) => onStartDateChange(event.target.value)}
/>
<input
className="ehb-modal-select"
type="date"
value={endDate}
onChange={(event) => onEndDateChange(event.target.value)}
/>
</div>
<div className="ehb-daily-filter-group">
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "all" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("all")}
>
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "own" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("own")}
>
<Truck size={14} />
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "external" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("external")}
>
<Truck size={14} />
</button>
</div>
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={onRefresh}
>
<RefreshCw size={14} />
</button>
</div>
</div>
</section>
{error ? <div className="ehb-empty">{error}</div> : null}
<section className="ehb-daily-kpi-grid">
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">
{format(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
</div>
<div className="ehb-daily-kpi-sub">
{startDate} {endDate}
</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">
¥{format(daily?.kpis.totalCost ?? 0)}
</div>
<div className="ehb-daily-kpi-sub"></div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
<div className="ehb-daily-kpi-sub">
{format(daily?.kpis.averageDailyKg ?? 0)} Kg
</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">
{daily?.kpis.stationCount ?? 0} <small></small>
</div>
<div className="ehb-daily-kpi-sub"></div>
</div>
</section>
<section className="ehb-daily-chart-section">
<div className="ehb-daily-chart-head">
<div className="ehb-daily-chart-title">
{" "}
<span className="ehb-title-sub">
</span>
</div>
<div className="ehb-daily-chart-meta-group">
<div className="ehb-daily-chart-legend">
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-own" />
</span>
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-ext" />
</span>
</div>
<span className="ehb-daily-chart-meta"> · Kg</span>
</div>
</div>
<div className="ehb-daily-summary-pills">
<div className="ehb-daily-pill-item">
<span></span>
<strong>{peak ? `${peak.date} ${format(peak.kg)} Kg` : "—"}</strong>
</div>
<div className="ehb-daily-pill-item">
<span></span>
<strong>
{trough ? `${trough.date} ${format(trough.kg)} Kg` : "—"}
</strong>
</div>
<div className="ehb-daily-pill-item">
<span></span>
<strong>{trend.filter((row) => row.kg === 0).length} </strong>
</div>
</div>
<div className="ehb-daily-bar-container">
<div
className="ehb-daily-avg-line"
style={{
bottom: `${Math.min(92, Math.round((averageKg / maxKg) * 100))}%`,
}}
>
<span className="ehb-daily-avg-label">
{format(averageKg)} Kg
</span>
</div>
{trend.map((row) => {
const total = row.kg || 1;
const ownRatio = (row.lingniuKg / total) * 100;
const extRatio = (row.externalKg / total) * 100;
const active = expandedDate === row.date;
return (
<button
type="button"
key={row.date}
className="ehb-daily-bar-col"
onClick={() => openDate(row.date, true)}
title={`${row.date} 加氢总量 ${format(row.kg)} Kg;点击展开并定位当日明细`}
aria-label={`展开并定位${row.date}当日加氢明细`}
>
<div
className="ehb-daily-bar-val"
style={{
color: active ? "#0284c7" : undefined,
fontWeight: active ? 700 : undefined,
}}
>
{Math.round(row.kg)}
</div>
<div
className={`ehb-daily-bar-fill is-stacked ${active ? "is-active" : ""}`}
style={{ height: `${Math.max(0, (row.kg / maxKg) * 100)}%` }}
>
{row.externalKg > 0 ? (
<div
className="ehb-bar-segment is-ext"
style={{ height: `${extRatio}%` }}
/>
) : null}
{row.lingniuKg > 0 ? (
<div
className="ehb-bar-segment is-own"
style={{ height: `${ownRatio}%` }}
/>
) : null}
</div>
<div
className="ehb-daily-bar-label"
style={{
color: active ? "#0284c7" : undefined,
fontWeight: active ? 700 : undefined,
}}
>
{row.date.slice(5)}
</div>
</button>
);
})}
</div>
</section>
<section className="ehb-daily-table-card">
<div className="ehb-daily-table-head">
<div className="ehb-daily-table-title">
{" "}
<span className="ehb-title-sub">
</span>
</div>
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={exportRows}
>
<Download size={14} />
Excel
</button>
</div>
<div
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
aria-hidden="true"
>
</div>
<div className="ehb-table-wrap">
<table className="ehb-table">
<thead>
<tr>
<th> / / / </th>
<th>(/Kg)</th>
<th>(Kg)</th>
<th>() / </th>
<th> / </th>
</tr>
</thead>
<tbody>
<tr style={{ background: "#f8fafc", fontWeight: 700 }}>
<td></td>
<td />
<td>{format(daily?.kpis.totalKg ?? 0)}</td>
<td>¥{format(daily?.kpis.totalCost ?? 0)}</td>
<td></td>
</tr>
{(daily?.days ?? []).map((day) => {
const tree = trees[day.date];
const open = expandedDate === day.date;
return (
<Fragment key={day.date}>
<tr
ref={(node) => {
dateRowRefs.current[day.date] = node;
}}
id={`daily-row-${day.date}`}
className={`ehb-daily-date-row${highlightedDate === day.date ? " is-highlighted" : ""}`}
style={{
cursor: "pointer",
background: open ? "#f0f9ff" : undefined,
}}
onClick={() => toggleDate(day.date)}
>
<td>
<span className="ehb-daily-tree-toggle is-date">
{open ? "▼" : "►"}
</span>
{day.date}{" "}
<span className="ehb-title-sub">
({day.stationCount ?? 0} )
</span>
</td>
<td></td>
<td>{format(day.kg)}</td>
<td>
¥{format(day.cost)} /{" "}
{format((day as { chainPct?: number }).chainPct ?? 0)}%
</td>
<td></td>
</tr>
{open &&
tree?.stations
.slice(0, expandedStationLists[day.date] ? undefined : 10)
.map((station) => {
const stationKey = `${day.date}:${station.id}`;
const stationOpen = !!expandedStation[stationKey];
return (
<Fragment key={stationKey}>
<tr
style={{
cursor: "pointer",
background: "#f8fafc",
}}
onClick={() =>
setExpandedStation((items) => ({
...items,
[stationKey]: !items[stationKey],
}))
}
>
<td className="ehb-tree-cell-l1">
<span className="ehb-daily-tree-toggle is-station">
{stationOpen ? "▼" : "►"}
</span>
<span className="ehb-daily-tree-branch"></span>
{station.name}
</td>
<td></td>
<td>{format(station.kg)}</td>
<td>¥{format(station.cost)}</td>
<td></td>
</tr>
{stationOpen &&
station.customers
.slice(
0,
expandedCustomerLists[stationKey]
? undefined
: 10,
)
.map((customer) => {
const customerKey = `${stationKey}:${customer.id}`;
const customerOpen =
!!expandedCustomer[customerKey];
const allRecords =
customerRecords[customerKey]?.records ?? [];
const records = allRecords.slice(
0,
expandedRecordLists[customerKey]
? undefined
: 20,
);
return (
<Fragment key={customerKey}>
<tr
style={{ cursor: "pointer" }}
onClick={() =>
toggleCustomer(
day.date,
station.id,
customer.id,
)
}
>
<td className="ehb-tree-cell-l2">
<span className="ehb-daily-tree-toggle is-customer">
{customerOpen ? "▼" : "►"}
</span>
<span className="ehb-daily-tree-branch"></span>
{customer.name}{" "}
<span className="ehb-title-sub">
({customer.recordCount} )
</span>
</td>
<td></td>
<td>{format(customer.kg)}</td>
<td>¥{format(customer.cost)}</td>
<td></td>
</tr>
{customerOpen &&
records.map((record) => (
<tr key={String(record.id)}>
<td className="ehb-tree-cell-l3">
<span className="ehb-daily-tree-branch"></span>
{String(record.time || "—").slice(11, 16)}{" "}
<strong>
{String(record.plateNo || "无车牌")}
</strong>{" "}
<span
className={`ehb-daily-record-tag ${record.vehicleScope === "lingniu" ? "is-own" : "is-external"}`}
>
{record.vehicleScope === "lingniu"
? "羚牛车辆"
: "外部车辆"}
</span>
<span
className="ehb-daily-record-tag is-source"
title={String(record.source || "未知来源")}
>
{sourceLabel(record.source)}
</span>
<span
className={`ehb-daily-record-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "is-verified" : "is-unverified"}`}
title={verifyLabel(record.verifyStatus)}
>
{verifyLabel(record.verifyStatus)}
</span>
</td>
<td>
{format(
Number(record.unitPrice ?? 0),
)}
</td>
<td>
{format(Number(record.kg ?? 0))}
</td>
<td>
¥{format(Number(record.cost ?? 0))}
</td>
<td></td>
</tr>
))}
{customerOpen && allRecords.length > 20 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedRecordLists((items) => ({
...items,
[customerKey]: !items[customerKey],
}));
}}
>
{expandedRecordLists[customerKey]
? "收起车辆明细"
: `更多车辆明细(还有 ${allRecords.length - 20} 笔)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
{stationOpen && station.customers.length > 10 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedCustomerLists((items) => ({
...items,
[stationKey]: !items[stationKey],
}));
}}
>
{expandedCustomerLists[stationKey]
? "收起客户"
: `更多客户(还有 ${station.customers.length - 10} 个)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
{open && tree && tree.stations.length > 10 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedStationLists((items) => ({
...items,
[day.date]: !items[day.date],
}));
}}
>
{expandedStationLists[day.date]
? "收起加氢站"
: `更多加氢站(还有 ${tree.stations.length - 10} 个)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -0,0 +1,869 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronLeft, Download, Search, Truck, X } from "lucide-react";
import { fetchH2BiDrill, fetchH2BiMeta } from "./api";
import { downloadExcelAoa } from "./prototype-download";
import type {
H2BiDrillGroupBy,
H2BiDrillGroupRow,
H2BiDrillResponse,
H2BiAmountScope,
H2BiMetaResponse,
H2BiQuery,
H2BiVehicleScope,
} from "./types";
type DrillKind = "kpi" | "customer" | "station" | "records";
export interface PrototypeDrillModalProps {
kind: DrillKind;
label: string;
query: H2BiQuery;
onClose: () => void;
}
type DrillRootDimension = "station" | "customer";
type DrillState = Pick<
H2BiQuery,
| "stationId"
| "customerId"
| "customerName"
| "plateNo"
| "date"
| "month"
| "region"
| "regionGranularity"
> & {
level: H2BiDrillGroupBy;
rootDimension: DrillRootDimension;
amountScope: H2BiAmountScope;
stationName?: string;
};
const formatNumber = (value: unknown, digits = 2) =>
Number(value ?? 0).toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope =>
value === "own" ? "lingniu" : value;
const verifyLabel = (status: unknown) => {
const value = String(status ?? "").trim().toLowerCase();
return value === "verified" || value === "pass" || value === "已验证"
? "已验证"
: "未验证";
};
const sourceLabel = (source: unknown) => {
const value = String(source ?? "").trim().toLowerCase();
if (value === "api") return "API接入";
if (value === "station" || value === "station_report") return "站点上报";
if (value === "lingniu" || value === "lingniu_report") return "羚牛上报";
return String(source ?? "真实账本");
};
const sourceClass = (source: unknown) => {
const value = String(source ?? "").trim().toLowerCase();
if (value === "api") return "ehb-tag--source-api";
if (value === "station" || value === "station_report") return "ehb-tag--source-station";
return "ehb-tag--source-lingniu";
};
/** 原生 select 会出现双箭头且不支持长列表检索;统一为可搜索选择器。 */
function DrillSearchSelect({
ariaLabel,
value,
onChange,
options,
allLabel,
placeholder,
disabled = false,
}: {
ariaLabel: string;
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
allLabel: string;
placeholder: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const close = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
if (open) document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
useEffect(() => {
if (!open) return;
setKeyword("");
requestAnimationFrame(() => inputRef.current?.focus());
}, [open]);
const selectedLabel =
options.find((option) => option.value === value)?.label ?? allLabel;
const matches = useMemo(() => {
const query = keyword.trim().toLowerCase();
return query
? options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query),
)
: options;
}, [keyword, options]);
return (
<div
className={`ehb-bi-search-select ${disabled ? "is-disabled" : ""}`}
ref={rootRef}
style={{ width: 180 }}
>
<button
type="button"
className={`ehb-bi-search-select__trigger ${open ? "is-open" : ""} ${value ? "has-value" : ""}`}
onClick={() => !disabled && setOpen((shown) => !shown)}
disabled={disabled}
aria-label={ariaLabel}
title={selectedLabel}
>
<span className="ehb-bi-search-select__label">{selectedLabel}</span>
<ChevronDown size={13} className="ehb-bi-search-select__chevron" />
</button>
{open && !disabled ? (
<div className="ehb-bi-search-select__dropdown">
<label className="ehb-bi-search-select__search">
<Search size={13} />
<input
ref={inputRef}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={placeholder}
aria-label={`搜索${ariaLabel}`}
/>
</label>
<div className="ehb-bi-search-select__list">
<button
type="button"
className={`ehb-bi-search-select__item ${value ? "" : "is-selected"}`}
onClick={() => {
onChange("");
setOpen(false);
}}
>
{allLabel}
</button>
{matches.map((option) => (
<button
key={option.value}
type="button"
className={`ehb-bi-search-select__item ${value === option.value ? "is-selected" : ""}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
title={option.label}
>
{option.label}
</button>
))}
{matches.length === 0 ? (
<div className="ehb-bi-search-select__empty"></div>
) : null}
</div>
</div>
) : null}
</div>
);
}
function initialState(kind: DrillKind, label: string): DrillState {
// Canonical labels use “2026年4月”. Accept the former “2026年2026-04”
// form too so a legacy label can never silently drop the month filter.
const monthParts =
label.match(/(\d{4})年(\d{1,2})月/) ?? label.match(/(\d{4})-(\d{1,2})/);
const regionMatch = label.match(/区域(市|省)(.+)/);
const stationIdMatch = label.match(/stationId=(\d+)/);
const cleanLabel = label.replace(/\|stationId=\d+$/, "");
// 对客金额、对客成本和利润都只看客户承担订单;利润减法使用完全
// 相同订单集。成本图的另外两段则按账本 settlement_type 定向下钻。
const amountScope: H2BiAmountScope = /我司承担成本/.test(cleanLabel)
? "company"
: /其他成本/.test(cleanLabel)
? "other"
: /加氢利润|对客金额|对客收入|客户收入|客户承担成本|对客成本/.test(cleanLabel)
? "customer"
: "all";
if (kind === "customer" || kind === "station")
return { level: "date", rootDimension: "station", amountScope };
if (kind === "records")
return { level: "record", rootDimension: "station", amountScope };
return {
level: stationIdMatch ? "customer" : "station",
rootDimension: "station",
amountScope,
stationId: stationIdMatch ? stationIdMatch[1] : undefined,
month: monthParts
? `${monthParts[1]}-${monthParts[2].padStart(2, "0")}`
: undefined,
region: regionMatch ? regionMatch[2] : undefined,
regionGranularity: regionMatch
? regionMatch[1] === "省"
? "province"
: "city"
: undefined,
// Keep this assignment solely to make the parsed label explicit in DevTools;
// it is not sent as a business filter.
customerName: cleanLabel.startsWith("客户:")
? cleanLabel.slice(3)
: undefined,
};
}
function titleFor(level: H2BiDrillGroupBy) {
return (
{
station: "加氢站",
customer: "客户",
date: "日期",
vehicle: "车辆",
record: "加氢记录",
} as const
)[level];
}
function nextState(current: DrillState, row: H2BiDrillGroupRow): DrillState {
if (current.level === "station") {
if (current.rootDimension === "customer" && current.customerId !== undefined)
return { ...current, level: "vehicle", stationId: row.id, stationName: row.name };
return { ...current, level: "customer", stationId: row.id, stationName: row.name };
}
if (current.level === "customer") {
if (current.rootDimension === "customer" && current.customerId === undefined)
return {
...current,
level: "station",
customerId: Number(row.id),
customerName: row.name,
};
return {
...current,
level: "vehicle",
customerId: Number(row.id),
customerName: row.name,
};
}
if (current.level === "date")
return { ...current, level: "vehicle", date: row.id };
if (current.level === "vehicle")
return { ...current, level: "record", plateNo: row.name };
return current;
}
function useDrill(query: H2BiQuery, state: DrillState) {
const [data, setData] = useState<H2BiDrillResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const key = JSON.stringify({ ...query, ...state });
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
void fetchH2BiDrill({
...query,
...state,
groupBy: state.level,
amountScope: state.amountScope,
page: 1,
pageSize: 200,
})
.then((result) => alive && setData(result))
.catch(
(reason: unknown) =>
alive &&
setError(
reason instanceof Error ? reason.message : "下钻数据加载失败",
),
)
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [key]);
return { data, error, loading };
}
function GroupTable({
state,
data,
onOpen,
}: {
state: DrillState;
data: H2BiDrillResponse;
onOpen: (row: H2BiDrillGroupRow) => void;
}) {
const customerBearingScope = data.amountScope === "customer";
const costLabel =
data.amountScope === "company"
? "我司承担成本金额"
: data.amountScope === "other"
? "其他成本金额"
: customerBearingScope
? "对客成本金额"
: "成本金额";
const isProfit = state.amountScope === "customer";
if (state.level === "record") {
return (
<table className="ehb-modal-table">
<thead>
<tr>
<th> / / </th>
<th> / </th>
<th></th>
<th></th>
<th style={{ textAlign: "right" }}></th>
<th style={{ textAlign: "right" }}> (Kg)</th>
<th style={{ textAlign: "right" }}>{costLabel} ()</th>
<th style={{ textAlign: "right" }}> ()</th>
</tr>
</thead>
<tbody>
{state.stationName ? (
<tr className="ehb-tree-row-station">
<td className="ehb-tree-cell-l1"><div className="ehb-tree-node-title"><span aria-hidden></span><strong>{state.stationName}</strong></div></td>
<td><span className="ehb-tree-node-sub"></span></td><td><span className="ehb-tree-node-sub"></span></td><td></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td>
</tr>
) : null}
{state.customerName ? (
<tr className="ehb-tree-row-customer">
<td className="ehb-tree-cell-l2"><div className="ehb-tree-node-title"><span aria-hidden></span><strong> {state.customerName}</strong></div></td>
<td><span className="ehb-tree-node-sub"></span></td><td><span className="ehb-tree-node-sub"></span></td><td></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td>
</tr>
) : null}
{state.plateNo ? (
<tr>
<td className="ehb-tree-cell-l3"><div className="ehb-tree-node-title"><span aria-hidden></span><strong> {state.plateNo}</strong></div></td>
<td><span className="ehb-tag ehb-tag--own-fleet"></span></td><td><span className="ehb-tree-node-sub"></span></td><td></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td><td style={{ textAlign: "right" }}></td>
</tr>
) : null}
{data.records.map((record, index) => (
<tr key={`${String(record.id)}-${index}`}>
<td className="ehb-tree-cell-l4">
<div className="ehb-tree-ord-block">
<strong>
{String(record.orderNo || record.id || "—")}
</strong>
<span className="ehb-tree-ord-time">
{String(record.time || "—")} · {String(record.stationName || "未关联站点")} · {String(record.customerName || "未关联客户")}
</span>
</div>
</td>
<td>
<span
className={`ehb-tag ${record.vehicleScope === "lingniu" ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}
>
{record.vehicleScope === "lingniu" ? "羚牛车辆" : "外部车辆"}
</span>
</td>
<td>
<span className={`ehb-tag ${sourceClass(record.source)}`}>
{sourceLabel(record.source)}
</span>
</td>
<td>
<span
className={`ehb-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "ehb-tag--verify-ok" : "ehb-tag--verify-warn"}`}
>
{verifyLabel(record.verifyStatus)}
</span>
</td>
<td style={{ textAlign: "right" }}>
1
</td>
<td style={{ textAlign: "right" }}>{formatNumber(record.kg)}</td>
<td style={{ textAlign: "right" }}>
¥{formatNumber(record.cost)}
</td>
<td style={{ textAlign: "right" }}>
¥{formatNumber(record.revenue)}
</td>
</tr>
))}
</tbody>
</table>
);
}
return (
<table className="ehb-modal-table">
<thead>
<tr>
<th> / / </th>
<th> / </th>
<th></th>
<th></th>
<th style={{ textAlign: "right" }}></th>
<th style={{ textAlign: "right" }}> (Kg)</th>
<th style={{ textAlign: "right" }}>{costLabel} ()</th>
<th style={{ textAlign: "right" }}> ()</th>
</tr>
</thead>
<tbody>
{state.stationName ? (
<tr className="ehb-tree-row-station">
<td className="ehb-tree-cell-l1">
<div className="ehb-tree-node-title"><span aria-hidden></span><strong>{state.stationName}</strong></div>
</td>
<td><span className="ehb-tree-node-sub"></span></td>
<td><span className="ehb-tree-node-sub"></span></td>
<td></td>
<td style={{ textAlign: "right" }}></td>
<td style={{ textAlign: "right" }}></td>
<td style={{ textAlign: "right" }}></td>
<td style={{ textAlign: "right" }}></td>
</tr>
) : null}
{state.customerName && state.level !== "customer" ? (
<tr className="ehb-tree-row-customer">
<td className="ehb-tree-cell-l2">
<div className="ehb-tree-node-title"><span aria-hidden></span><strong> {state.customerName}</strong></div>
</td>
<td><span className="ehb-tree-node-sub"></span></td>
<td><span className="ehb-tree-node-sub"></span></td>
<td></td>
<td style={{ textAlign: "right" }}></td>
<td style={{ textAlign: "right" }}></td>
<td style={{ textAlign: "right" }}></td>
<td style={{ textAlign: "right" }}></td>
</tr>
) : null}
{data.groups.map((row, index) => (
<tr
key={`${row.id}-${index}`}
onClick={() => onOpen(row)}
style={{ cursor: "pointer" }}
title={`点击继续查看${titleFor(nextState(state, row).level)}`}
>
<td
className={
state.level === "station"
? "ehb-tree-cell-l1"
: state.level === "customer"
? "ehb-tree-cell-l2"
: state.level === "vehicle"
? "ehb-tree-cell-l3"
: "ehb-tree-cell-l1"
}
>
<div className="ehb-tree-node-title">
<span aria-hidden>{state.level === "station" ? "▸" : "└─"}</span>
<strong>
{state.level === "customer" ? "客户:" : ""}
{row.name}
</strong>
{row.province ? <span className="ehb-tree-node-sub">{row.province}</span> : null}
<span className="ehb-kpi-drill-hint"> </span>
</div>
</td>
<td>
{state.level === "vehicle" ? (
<span
className={`ehb-tag ${row.lingniuKg > 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}
>
{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"}
</span>
) : state.level === "station" ? (
<span className="ehb-tree-node-sub">
{formatNumber(row.customerCount, 0)}
</span>
) : (
<span className="ehb-tree-node-sub"></span>
)}
</td>
<td>
<span className="ehb-tree-node-sub">
{state.level === "vehicle" ? "真实账本流水" : "全量自动归集"}
</span>
</td>
<td>
<span className="ehb-tree-node-sub">
{state.level === "station" ? "汇总" : "点击展开"}
</span>
</td>
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} </td>
<td style={{ textAlign: "right", fontWeight: 700 }}>
{formatNumber(row.kg)}
</td>
<td style={{ textAlign: "right" }}>
¥{formatNumber(row.cost)}
</td>
<td style={{ textAlign: "right", color: isProfit ? "#10b981" : undefined }}>
¥{formatNumber(row.revenue)}
</td>
</tr>
))}
</tbody>
</table>
);
}
/** Preserves the prototype overlay/card/meta/table anatomy, but every row is live ledger data. */
export function PrototypeDrillModal({
kind,
label,
query,
onClose,
}: PrototypeDrillModalProps) {
const [state, setState] = useState<DrillState>(() => ({
...initialState(kind, label),
...(kind === "customer" ? { customerName: label } : {}),
}));
const [history, setHistory] = useState<DrillState[]>([]);
const [search, setSearch] = useState("");
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
const [fleetCategoryFilter, setFleetCategoryFilter] = useState<
"all" | "own" | "external"
>(() =>
query.vehicleScope === "lingniu"
? "own"
: query.vehicleScope === "external"
? "external"
: "all",
);
useEffect(() => {
let alive = true;
void fetchH2BiMeta().then((result) => alive && setMeta(result)).catch(() => {
// The drill itself remains usable even when the optional select metadata is unavailable.
});
return () => {
alive = false;
};
}, []);
const liveQuery = useMemo(
() => ({ ...query, vehicleScope: fleetScope(fleetCategoryFilter) }),
[fleetCategoryFilter, query],
);
const live = useDrill(liveQuery, state);
const data = useMemo(() => {
if (!live.data || !search.trim()) return live.data;
const term = search.trim().toLowerCase();
return {
...live.data,
groups: live.data.groups.filter((row) =>
JSON.stringify(row).toLowerCase().includes(term),
),
records: live.data.records.filter((row) =>
JSON.stringify(row).toLowerCase().includes(term),
),
};
}, [live.data, search]);
const cleanLabel = label.replace(/\|stationId=\d+$/, "");
const customerBearingScope = live.data?.amountScope === "customer";
const profit =
Number(live.data?.summary.revenue ?? 0) -
Number(live.data?.summary.cost ?? 0);
const coverageStationCount = Number(
live.data?.summary.stationCount ??
(state.stationId
? 1
: state.level === "station"
? live.data?.groups.length ?? 0
: 0),
);
const traceability = Number(live.data?.summary.recordCount ?? 0)
? (Number(live.data?.summary.traceableRecordCount ?? 0) /
Number(live.data?.summary.recordCount ?? 1)) *
100
: 0;
const open = (row: H2BiDrillGroupRow) => {
setHistory((items) => [...items, state]);
setState(nextState(state, row));
};
const stationOptions = meta?.stations ?? [];
const customerOptions =
state.level === "customer"
? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? []
: state.customerId && state.customerName
? [{ id: String(state.customerId), name: state.customerName }]
: [];
const vehicleOptions =
state.level === "vehicle"
? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? []
: state.plateNo
? [{ id: state.plateNo, name: state.plateNo }]
: [];
const resetToRoot = (overrides: Partial<DrillState> = {}) => {
setHistory([]);
setSearch("");
setState({ ...initialState(kind, label), ...overrides });
};
const changeStation = (value: string) => {
if (!value) {
resetToRoot({ rootDimension: state.rootDimension, level: state.rootDimension });
return;
}
resetToRoot({
stationId: value,
stationName: stationOptions.find((station) => String(station.id) === value)?.name,
level: "customer",
rootDimension: "station",
});
};
const changeCustomer = (value: string) => {
if (!value) {
if (state.stationId) setState((current) => ({ ...current, level: "customer", customerId: undefined, plateNo: undefined }));
return;
}
setHistory([]);
setSearch("");
setState((current) => ({
...current,
customerId: Number(value),
customerName: customerOptions.find((customer) => customer.id === value)?.name,
plateNo: undefined,
level: "vehicle",
}));
};
const changeVehicle = (value: string) => {
if (!value) {
setState((current) => ({ ...current, level: "vehicle", plateNo: undefined }));
return;
}
setHistory([]);
setSearch("");
setState((current) => ({ ...current, plateNo: value, level: "record" }));
};
const exportCurrent = () => {
if (!data) return;
const rows: Array<Array<string | number>> = [
[
"加氢站 / 客户 / 车辆与凭证链路",
"加氢量(Kg)",
"成本金额(元)",
"对客金额(元)",
],
];
if (state.level === "record") {
data.records.forEach((record) =>
rows.push([
`${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`,
Number(record.kg ?? 0),
Number(record.cost ?? 0),
Number(record.revenue ?? 0),
]),
);
} else {
data.groups.forEach((row) =>
rows.push([row.name, row.kg, row.cost, row.revenue]),
);
}
downloadExcelAoa(rows, `${cleanLabel}_真实账本穿透.xlsx`, "真实账本穿透");
};
const switchRootDimension = (rootDimension: DrillRootDimension) => {
if (kind !== "kpi" || state.rootDimension === rootDimension) return;
setHistory([]);
setSearch("");
setState({
...initialState(kind, label),
rootDimension,
level: rootDimension,
});
};
const back = () => {
const previous = history.at(-1);
if (!previous) {
onClose();
return;
}
setHistory((items) => items.slice(0, -1));
setState(previous);
};
const scope =
state.date ||
state.month ||
query.date ||
query.month ||
(query.startDate && query.endDate
? `${query.startDate}${query.endDate}`
: `${query.year}`);
const displayTitle = /\d{4}年/.test(cleanLabel)
? `${cleanLabel}明细`
: `${query.year}${cleanLabel}明细`;
return (
<div
className="ehb-modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className="ehb-modal-card"
onClick={(event) => event.stopPropagation()}
>
<div className="ehb-modal-head">
<div className="ehb-modal-head__title-group">
<button
type="button"
className="ehb-modal-back-btn"
onClick={back}
title="返回"
>
<ChevronLeft size={18} />
<span>{history.length ? "返回上级" : "返回"}</span>
</button>
<div>
<div className="ehb-modal-head__title">{displayTitle}</div>
<div className="ehb-modal-head__sub"> · {scope}</div>
</div>
</div>
<div className="ehb-modal-head__actions">
<button
type="button"
className="ehb-modal-close-btn"
onClick={onClose}
title="关闭"
>
<X size={18} />
</button>
</div>
</div>
<div className="ehb-modal-body">
<div className="ehb-modal-meta-bar">
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label"></span>
<span className="ehb-modal-meta-val">
<span style={{ color: "#0284c7" }}>
{formatNumber(Number(live.data?.summary.kg ?? 0) / 1000)} T
</span>
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label"></span>
<span className="ehb-modal-meta-val">
<span style={{ color: "#10b981" }}>
¥{formatNumber(Number(live.data?.summary.cost ?? 0) / 10000)}
</span>
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label"></span>
<span className="ehb-modal-meta-val">
{coverageStationCount}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label"></span>
<span className="ehb-modal-meta-val">
<span style={{ color: "#f59e0b" }}>
{formatNumber(traceability, 0)}%
</span>
</span>
</div>
</div>
{cleanLabel === "加氢利润" && customerBearingScope ? (
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
¥
{formatNumber(live.data?.summary.revenue)} ¥
{formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)}
</div>
) : null}
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<DrillSearchSelect
ariaLabel="筛选加氢站"
value={state.stationId ? String(state.stationId) : ""}
onChange={changeStation}
options={stationOptions.map((station) => ({
value: String(station.id),
label: station.name,
}))}
allLabel="全部加氢站"
placeholder="搜索加氢站"
/>
<DrillSearchSelect
ariaLabel="筛选客户"
value={state.customerId ? String(state.customerId) : ""}
onChange={changeCustomer}
options={customerOptions.map((customer) => ({
value: String(customer.id),
label: customer.name,
}))}
allLabel="全部客户"
placeholder="搜索客户"
disabled={!state.stationId}
/>
<DrillSearchSelect
ariaLabel="筛选车辆"
value={state.plateNo ?? ""}
onChange={changeVehicle}
options={vehicleOptions.map((vehicle) => ({
value: vehicle.name,
label: vehicle.name,
}))}
allLabel="全部车辆"
placeholder="搜索车牌"
disabled={!state.customerId}
/>
<div className="ehb-fleet-segmented" role="radiogroup" aria-label="车辆归属">
{([
["all", "全部车辆"],
["own", "仅羚牛车辆"],
["external", "仅外部车辆"],
] as const).map(([value, text]) => (
<button
type="button"
role="radio"
aria-checked={fleetCategoryFilter === value}
className={`ehb-fleet-btn ${fleetCategoryFilter === value ? "is-active" : ""}`}
onClick={() => setFleetCategoryFilter(value)}
key={value}
>
{value !== "all" ? <Truck size={14} aria-hidden /> : null}
{text}
</button>
))}
</div>
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={exportCurrent}>
<Download size={14} aria-hidden />
Excel 穿
</button>
{kind === "kpi" && history.length === 0 ? (
<div className="ehb-pill-tabs" role="group" aria-label="下钻首层维度">
<button type="button" className={`ehb-pill-btn ${state.rootDimension === "station" ? "is-active" : ""}`} onClick={() => switchRootDimension("station")}> </button>
<button type="button" className={`ehb-pill-btn ${state.rootDimension === "customer" ? "is-active" : ""}`} onClick={() => switchRootDimension("customer")}> </button>
</div>
) : null}
<span className="ehb-modal-hint-text"> </span>
</div>
<label className="ehb-modal-search-input">
<Search size={14} />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前下钻结果" />
</label>
</div>
<div className="ehb-h5-scroll-hint"> </div>
<div className="ehb-modal-table-wrap is-v-scroll">
{live.loading ? (
<div className="ehb-empty"></div>
) : live.error ? (
<div className="ehb-empty">{live.error}</div>
) : data &&
(state.level === "record"
? data.records.length
: data.groups.length) > 0 ? (
<GroupTable state={state} data={data} onOpen={open} />
) : (
<div className="ehb-empty"></div>
)}
</div>
</div>
</div>
</div>
);
}
export function prototypeFleetScope(value: "all" | "own" | "external") {
return fleetScope(value);
}
@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import './styles/energy-bi-board.css';
import { isOssGateSessionAuthed } from '../../common/oss-access-gate';
export const ENERGY_BI_PASSWORD = 'lingniu';
export const ENERGY_BI_AUTH_KEY = 'energy-h2-bi-board-auth-v1';
export function isEnergyBiAuthed(): boolean {
return isOssGateSessionAuthed(ENERGY_BI_AUTH_KEY);
}
export function setEnergyBiAuthed(ok: boolean): void {
try {
if (ok) sessionStorage.setItem(ENERGY_BI_AUTH_KEY, '1');
else sessionStorage.removeItem(ENERGY_BI_AUTH_KEY);
} catch {
/* ignore */
}
}
interface EnergyBiAccessGateProps {
onOk: () => void;
}
/** 轻门禁:口令 lingniu · 本会话记住(与汇报舱 / 作战室同口径) */
export const EnergyBiAccessGate: React.FC<EnergyBiAccessGateProps> = ({ onOk }) => {
const [pwd, setPwd] = useState('');
const [err, setErr] = useState('');
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (pwd.trim() === ENERGY_BI_PASSWORD) {
setEnergyBiAuthed(true);
setErr('');
onOk();
return;
}
setErr('口令不对,请重试');
};
return (
<div className="ehb-gate">
<form className="ehb-gate-card" onSubmit={submit}>
<p className="ehb-gate-kicker">ONEOS · BI</p>
<h1 className="ehb-gate-title"></h1>
<p className="ehb-gate-sub"> · / · </p>
<label className="ehb-gate-label" htmlFor="ehb-pwd">
访
</label>
<input
id="ehb-pwd"
className="ehb-gate-input"
type="password"
autoComplete="current-password"
autoFocus
value={pwd}
onChange={(e) => {
setPwd(e.target.value);
if (err) setErr('');
}}
placeholder="请输入口令"
/>
<p className="ehb-gate-error" role="alert">
{err}
</p>
<button type="submit" className="ehb-gate-btn">
</button>
<p className="ehb-gate-foot"> · </p>
</form>
</div>
);
};
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More