Author SHA1 Message Date
shishengliang 9c13ab6d3b fix(energy): use new hydrogen site master data
ci/woodpecker/push/woodpecker Pipeline was successful
Use new_hydrogen_site for daily and overview station names and regional aggregation, with empty-name fallback. Include the OneOS mileage reconciliation investigation report.
2026-09-16 11:54:27 +08:00
shishengliang 1e4afacac7 ci: sync Woodpecker configuration from main
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-16 10:49:39 +08:00
shishengliang 0c26b973ef feat(assets): align flow statistics and separate abnormal inventory
ci/woodpecker/push/woodpecker Pipeline failed
Use operations signing times and delivery completion statuses; include replacement deliveries in delivery totals with range drilldowns and exports. Separate abnormal vehicles from inventory, add a date range picker, and enable CI for feature/asset-statistics.
2026-09-16 10:35:50 +08:00
shishengliang 6ea1b3d5dd revert: restore v1.1.14 (37a9f303)
ci/woodpecker/push/woodpecker Pipeline failed
2026-09-16 09:29:30 +08:00
shishengliang c49a02e3d9 fix: restore local mileage monitoring queries
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-16 09:10:39 +08:00
shishengliang d7538ed6eb chore(deploy): compose 镜像标签更新到 main-1.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
镜像标签此前停留在 main-1.1.15,而 package.json 已升到 1.2.0,
CI 按 `${分支}-${版本}` 产出的标签是 main-1.2.0(Harbor 已存在该标签)。
不更新会继续部署旧镜像,待核对相关修复无法生效。
2026-09-11 12:07:50 +08:00
shishengliang 6822eafd09 ci(woodpecker): Harbor 凭据改用 HARBOR_USERNAME / HARBOR_PASSWORD 注入
ci/woodpecker/push/woodpecker Pipeline was successful
原流水线从 harbor_user / harbor_password 两个 secret 取值,与 Woodpecker
中实际登记的 HARBOR_USERNAME / HARBOR_PASSWORD 名称不一致,docker login
拿不到账号。统一为同名注入,并同步登录命令中的变量名。
2026-09-11 11:54:28 +08:00
shishengliang 7c8e886663 fix(energy): 无车牌或未关联羚牛车辆的订单不再计入待核对
待核对原先只看「未核对 + 期初之后」,会把没有核对对象的流水也算进
待核对笔数与金额,虚高 KPI 并污染下钻列表。

在 unverified 过滤条件里补两条限定:
- NULLIF(TRIM(license_plate), '') IS NOT NULL:无车牌无核对对象
- COALESCE(vehicle_id, 0) <> 0:vehicle_id 为 NULL/0 即未关联羚牛车辆库

条件加在 buildFilterClauses 的 unverified 分支,汇总 KPI 与下钻共用,
因此两处口径自动一致;all / verified 不受影响。下钻弹窗提示同步说明口径。
2026-09-11 11:26:55 +08:00
shishengliang c4e2c03348 fix(energy): 待核对上线期初改为 2026-01-01,并统一前后端口径
待核对订单的起算日原先硬编码为 2026-05-01,散落在后端 SQL、看板诊断卡
和下钻提示三处,改一处就会与另两处口径不一致。

- 新增 shared/hydrogen-verify.ts 的 HYDROGEN_VERIFY_START_DATE 作为唯一来源
- repository 的 unverified 过滤条件改为引用该常量,起算日提前到 2026-01-01
- 看板桌面/移动诊断卡与待核对下钻提示同步显示新日期
- 契约测试锁定字面量,防止日期被无意改动

仅影响 verifyScope=unverified;all / verified 口径不变。
2026-09-11 11:17:38 +08:00
dsh-agent f82867b159 chore(release): 版本升至 1.2.0
CI 的镜像标签方案是 <分支>-<package.json version>。重构的 16 个提交一直没动版本,
继续停在 1.1.15 会让流水线覆盖掉现有的 main-1.1.15 镜像,失去回滚点。
升到 1.2.0 后产出独立的 main-1.2.0,旧镜像保留可回退。

仅元数据变更:版本号只被 CI 读取(woodpecker.yml 里 require('./package.json').version),
运行时不依赖它,因此不影响 lint / test / build 结果。
2026-09-11 11:17:31 +08:00
dsh-agent 25e53b8f66 refactor(stage16): 里程域移入 repository,后端分层全部完成
改动
- 新增 mileage/repository.ts:18 个查询函数,覆盖
  vehicle-info(含搬走的 VEHICLE_INFO_SQL 共享常量)、trend、监控缓存、targets(8 条)、
  daily-report-store(5 条)、daily-report-service。
- 6 个含 SQL 的文件改为调用 repository;路由/服务层不再出现 SQL 文本。
- 日报写入的两个函数改为返回 affectedRows(调用方本来只用它),而不是行数组。
- 架构守护的"已完整分层"清单加入 mileage —— 至此八个域全部在守护范围内。

SQL 保持性验证
- 逐字比对:改造前 6 个文件里的 19 条 SQL 字面量,除"动态 INSERT 关键字"一处外,
  全部在 repository.ts 中原样出现。
- 那一处是 `${insert} INTO ${TABLE}`(insert 为 'REPLACE' | 'INSERT IGNORE'),
  改为 `${replace ? "REPLACE" : "INSERT IGNORE"}`;已渲染两个分支与原文本逐字比对,均为 identical。

验证
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。
- 里程域的模型层测试(cache-model / oneos-model / daily-report-model)以及依赖里程的
  调度、车辆热力图契约测试全部通过。
2026-09-11 10:57:29 +08:00
dsh-agent 9750d35ba4 refactor(stage15): 氢能 v2 移入 repository,能源域完成并加入守护清单
改动
- hydrogen-bi-v2.ts 的 14 条查询移入 energy/repository.ts(SQL 与参数表达式逐条对应)。
- 片段组装也随之搬走,因为它们本质是 SQL 构造,留在路由里会让"路由不含 SQL"形同虚设:
  resolvedWhere / stationMasterRegionSql / buildFilterClauses / buildGroupSelect /
  buildGroupExpression / buildScopedWhere。
- 承担方映射片段(CUSTOMER/COMPANY/OTHER_BEARING_ORDER)移入 constants.ts 供多处共享。
- 结果形状从 mysql2 的 [rows, fields] 改为直接返回 rows,路由侧 13 处 [0] 索引同步调整。
- 架构守护的"已完整分层"清单加入 energy(现为 7 个域)。

提取方式与一次失误
- 查询文本用"反引号定界 + 括号配对"扫描提取。第一次用纯括号配对扫描时,
  因为把 SQL 文本里的括号也算了进去,导致调用点跨度越界、文件被改坏;
  已从 git 恢复并改用反引号定界(并对跨度做了"严格递增且不重叠"的断言)后一次通过。
- 中途还有一次变量写错文件(把 repository 内容写进了路由文件),
  被随后的正常写入覆盖,未造成实际损坏;已核对两个文件的首行与关键符号确认无误。

验证
- energy/routes.test.ts 的 13 个用例(含氢能 v2 的口径、利润、账单、路由顺序、
  SQL 与参数指纹)在提取前后全部通过。
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:53:56 +08:00
dsh-agent 7802e5b0eb refactor(stage14): 单站日报 10 条查询移入 repository(能源域四分之三)
改动
- energy/hydrogen-station-board.ts 的 10 条查询全部移入 energy/repository.ts,
  参数表达式逐条对应(含区间、站点、以及租户与手工充值来源的固定顺序)。
- 相关 SQL 片段助手搬进 constants.ts 并导出:
  NORMALIZED_PLATE / NORMALIZED_PLATE_UNICODE / EXTERNAL_CUSTOMER_MAP_CTE,
  以及 HYDROGEN_TENANT_ID / MANUAL_RECHARGE_SOURCE。
  它们本质是 SQL,继续留在路由里会让"路由不含 SQL"形同虚设;放 constants.ts
  是因为被多条查询共享,避免复制。
- 路由文件随之删掉 4 个只为 SQL 服务的 constants 导入(HYDROGEN_BASE_WHERE /
  _B / HYDROGEN_LOCAL / HYDROGEN_TABLE),它们已不再被该文件使用。
- 架构守卫允许 constants.ts 存放共享 SQL 片段,并注明理由。

提取方式
- 用平衡括号扫描从原文件按顺序取出 10 条 SQL 与各自的参数数组(含查询 10 的多行参数),
  按调用点跨度精确替换,避免手工转写 200 余行 SQL。

验证
- energy/routes.test.ts 的 13 个既有用例在提取前后均通过,其中包含单站看板的
  SQL 与参数断言(迁移未改变查询文本、参数顺序与响应口径)。
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。

未完成
- hydrogen-bi-v2.ts 仍有 7 条带 ${sqlWhere} / ${groupOrder} 等动态插值的查询,
  需要把 where 组装函数一并搬进 repository;完成后再把 energy 加入守护清单。
2026-09-11 10:48:35 +08:00
dsh-agent e4c8195ede refactor(stage13): 能源域先行搬迁电能与 ETC 的 SQL(该域完成一半)
改动
- 新增 energy/repository.ts:电能总览 KPI、本月逐日、最近有数据月份、昨日电量、
  按日区间查询、ETC 汇总,共 6 个查询函数(SQL 与参数逐字保留)。
- electric.ts / etc.ts 改为调用 repository;两者本就是 register*(app, deps) 形态,
  因此这次是纯粹的 SQL 位置迁移,没有改接口契约。
- 车辆归属筛选片段 electricKindClause 也移入 repository:
  它本质是 SQL,留在路由里会让"路由不含 SQL"这条规则形同虚设。

验证
- energy/routes.test.ts 的 13 个既有用例直接作为安全网(其中多处对 SQL 与参数做断言),
  迁移后全部通过,说明查询文本、参数顺序与口径未变。
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。

未完成(已在文档中写明)
- hydrogen-station-board.ts 仍有 10 条查询 + 4 个本地 SQL 片段助手;
- hydrogen-bi-v2.ts 仍有 7 条带动态插值的查询。
因此 energy 暂不加入架构守护的"已完整分层"清单,避免给出已完成的不实信号。
2026-09-11 10:46:58 +08:00
dsh-agent 5196db5df7 refactor(stage12): 调度域拆出 repository,跨域数据源改为显式注入
改动
- 新增 scheduling/repository.ts:通知/干预与建议列表的全部 SQL(13 条)。
- notify.ts / suggestions.ts 改为 register*(app, deps) 可注入,保留默认导出与 create*Router 工厂。
- suggestions.ts 的两处跨域依赖(里程车辆信息、OneOS 里程)改为依赖注入:
  既让本域可独立测试,也让"建议依赖里程数据"在类型上可见,而不是藏在 import 里。
- 跨域引用修正:mapRegion 原本从 '../vehicles/routes.js' 引入(为一个纯函数把整个
  车辆路由拖进依赖图),改为直接从 '../vehicles/model.js' 引入。

契约测试(新增 10 个用例)
- 逐条锁定 SQL 与参数顺序:干预登记的三步(查重 → INSERT → 回读)、409 阻断、
  批量循环、历史列表的状态过滤与 limit 上限(500/默认 200)、状态更新的 UPDATE 形状、
  400/404 分支、活跃映射与近 7 天计数、建议列表五条基础查询的内容与顺序。
- 架构守护升级:不再只看 routes.ts,而是要求该域**除 repository.ts 外的任何非测试文件
  都不得含 SQL**;匹配用"语句形状"正则(如 update <table> set)而非裸关键字,
  避免把 UpdateNotification 或日志 "update error" 误判。已验证 6/6 个 repository 被识别、
  其余文件零误判。

等价性验证
- 把 notify.ts / suggestions.ts 改造前的实现从 git 取出,与改造后跑同一批请求
  (9 个场景,含建议列表整条链路 8 次查询),对比落库 SQL、参数、HTTP 状态与响应体:
  完全一致。
- 期间修正了两处**验证工具自身**的缺陷(旧文件误引用新 notify;跨域默认实现的调用
  被记到另一侧),修正后结论可信。

lint / test(191) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:44:52 +08:00
dsh-agent 523f7b312d refactor(stage11): 两个热力图拆出 repository,并补契约测试
vehicle-heatmap
- 拆为 routes.ts / repository.ts(model.ts 原已存在),改为 registerVehicleHeatmapRoutes(app, deps)。
- 该域同时访问两个库,因此依赖分成 MysqlDatabase(execute,考核批次→车牌)与
  PgDatabase(query 返回 { rows },定位点),两者返回形状不同,不做统一抽象。
- 保留 $4 的原语义:是否"选择了考核批次"(而非"车牌集合是否为空");
  批次无车牌时由调用方提前返回,与原实现一致。
- 保留 loadBatchModelPlates 的降级行为(查询失败→空映射)与"命中缓存不查主库"。

hydrogen-heatmap
- 拆为 routes.ts / repository.ts,改为 registerHydrogenHeatmapRoutes(app, deps)。
- VALID_COORDINATE(含西藏排除)与 buildWhere 移入 repository 并导出:
  它被 meta 的统计口径复用,散落两处极易漂移。
- 5 条 SQL 由脚本从原文件按顺序抽取后原样落位,避免手工转写长 SQL 出错。

契约测试(新增 19 个用例)
- 逐条断言 SQL 文本(规范化空格)与参数顺序:分页/白名单/批次车牌数组/
  WHERE 片段顺序/IN 占位符拼接/半径内无站点时不发第二条查询。
- buildWhere 直接单测片段与参数顺序。
- 架构守护的"已完整分层"清单扩到 5 个域。

等价性验证(关键)
- 把两个文件改造前的实现从 git 取出,与改造后跑同一批请求,对比落库 SQL、参数、
  HTTP 状态与响应体:
    vehicle-heatmap  :8 个场景完全一致(含批次路径、"未知批次"提前返回、错误分支)
    hydrogen-heatmap :8 个场景完全一致(含筛选组合、无权限 403、错误分支)

期间的修正:曾为 /meta 的空结果新增 503 分支,属于原实现没有的行为变更,已回退为原样。

lint / test(181) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:40:13 +08:00
dsh-agent c199f031c5 refactor(stage10): ele / feedback 拆出 repository + model,并补契约测试
背景
- 这两个域把 SQL 与处理器放在同一文件,是全仓仅有的两个零接口测试的后端域。
  直接拆会改动 SQL 与参数顺序,而硬约束要求不得变更统计口径 —— 因此按
  "可注入化 -> 补契约测试 -> 再提取" 的顺序做。

改动
- ele:拆为 routes.ts(校验与组装)/ repository.ts(全部 SQL)/ model.ts(xlsx 解析、
  取值清洗、筛选片段、插入值组装等纯逻辑),并改为 registerEleRoutes(app, deps) 可注入。
- feedback:拆为 routes.ts / repository.ts,同样可注入;建表与截图上传也作为依赖注入。
- 两域各补测试:ele 7 个接口契约 + 6 个模型用例,feedback 9 个接口契约。
  mock pool 逐条断言 SQL 文本与参数顺序(含分页、状态白名单、批量插入的 30 列顺序)。
- 架构测试新增一条:已完整分层的三个域(vehicles / ele / feedback)必须有 repository.ts
  且 routes.ts 不得出现 SQL。

等价性验证(关键)
- 把改造前的实现从 git 取出,与改造后的实现跑同一批请求(含真实 xlsx 解析路径),
  对比每一步落库 SQL、参数、HTTP 状态与响应体:
    feedback:6 个场景,SQL + 参数 + 状态完全一致(差异仅 DDL 已移交 db/schema 层)
    ele     :5 个场景,SQL + 参数 + 状态 + 响应体完全一致(仅随机 batchId/时间戳做掩码)
- 期间的修正:曾把 /mine 与 /list 的列集合统一,二者实际不同(管理列表多 user_id/user_name),
  已按原样保留;测试同时锁定了这一差异。

lint / test(161) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:36:05 +08:00
dsh-agent 795fb207cb refactor(stage9): 后端业务域目录形状统一
- 每个域现在都是 src/server/routes/<domain>/ 目录:
  vehicles/routes.ts(原 routes/vehicles.ts)、vehicle-heatmap/{routes,model}.ts、
  hydrogen-heatmap/{routes,model}.ts、ele/routes.ts、feedback/routes.ts。
- 命名约定:index.ts 是聚合器(挂子路由),routes.ts 是叶子路由。mileage/energy/
  scheduling 保持 index.ts 聚合,其余统一为 routes.ts。
- 纯移动与改名,未改动任何 SQL、参数顺序、路由挂载前缀或处理器逻辑。
  import 改写由一次性 codemod 完成;vehicles/repository.test.ts 读取的源码路径同步更新。
- docs/ARCHITECTURE.md 补充"后端业务域形状"表,并如实标注 ele / feedback 尚未拆出
  repository(这两个域缺 SQL 指纹测试,建议先补测试再拆)。

lint / test(139) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:30:54 +08:00
dsh-agent 954d626afc refactor(stage8): 建表语句集中到 server/db/schema,并修复只读模式的漏写
问题
- 4 处运行时 DDL(scheduling / ele / feedback / mileage 日报)分散在路由与 store 里;
- 只读保护只按 HTTP 方法拦截,而建表由 GET 处理器触发,
  于是 DB_READ_ONLY=1 的"只读预览"仍可能执行 CREATE TABLE / ALTER TABLE(已记录在案的漏洞)。

改动
- 新增 src/server/db/schema/{guard,scheduling,ele,feedback,mileage-report}.ts:
  建表/改表的唯一位置;guard 提供 ddlAllowed(),DB_READ_ONLY=1 时每个 ensure* 直接跳过并打印一行日志。
- daily-report-store 的表名改为从 schema 模块导出,避免两处各写一份表名。
- 移除 feedback/index.ts 与 ele/migration.ts 中的内联 DDL。

验证
- 实测:DB_READ_ONLY=1 下依次调用 4 个 ensure*,全部跳过且未触碰数据库(无连接错误/挂起)。
- 架构测试新增 2 条:DDL 只允许出现在 server/db/schema;每个 schema 模块必须检查 ddlAllowed()。

lint / test(139) / build 全绿。
2026-09-11 10:29:50 +08:00
dsh-agent 507bc90ed2 refactor(stage5): 合并重复实现,移除失效的演示能力
Excel 导出(原先 4 份各自拼装 workbook)
- 新增 src/shared/xlsx.ts 作为唯一实现:文件名统一 .xlsx、sheet 名截断到 31 字符,
  对外提供 buildAoaSheet / buildJsonSheet / writeWorkbook / exportAoaSheet / exportJsonSheet。
  只收敛"组装与写出"这一层,各调用方仍自行决定列宽、冻结与数字格式,导出样式不变。
- 迁移 assets(内联 json_to_sheet + writeFile)、mileage/xlsx-export、
  hydrogen 的两份 helper(prototype-download.ts 与 download-xls.js,均已删除)。

高德地图(原先 2 份近乎逐行复制)
- 新增 src/shared/amap.ts:SDK 版本、插件列表、安全码注入、底图参数、
  控件位置与热力图色带只在此处定义;两个画布只保留各自的半径/透明度。
- 图例渐变条原先把同一组色值又写了一遍,改为复用 shared 的常量,图例与地图不会漂移。

数值格式化
- 两个下钻视图各自复制了同样的 formatNumber/format(共 42 处调用),
  统一到 hydrogen/model/display-format.ts 的 formatFixed;默认路径与既有行为逐字一致。
- 原 display-format.ts 里的 finiteNumber/formatNumber/formatScaled 无任何生产调用方,
  只被自己的测试引用;改为 formatFixed + blankForMissing 显式选项,
  既保留了"真零 vs 不可用"的区分能力,也不再留无人使用的导出。测试同步重写。

失效的演示能力
- Blur / DemoModeProvider 恒为 enabled=false,等于永久 no-op:
  移除 13 个文件里 50 处 <Blur> 包裹(渲染结果不变)、删除 components/Blur.tsx
  与 Shell 中的 Provider,调用方直接渲染原表达式。

未做(刻意)
- src/lib/cn.ts 不引入 tailwind-merge:全仓只有 2 处调用,不值得新增传递依赖。已在文档说明。

架构守护新增 2 条:只有 shared/amap.ts 可加载高德 SDK;
modules/** 不得再出现 book_new / book_append_sheet / writeFile(。

lint / test(137) / build 全绿,可达性仍为 0 未引用文件。
2026-09-11 10:28:32 +08:00
dsh-agent 5d14a28b0f refactor(stage6): 去掉热力图写死的默认区间与线上文件的演示数据
热力图默认区间
- 新增 src/shared/date-range.ts(近 N 天滚动窗口,按本地自然日)。
- 前端两个热力图与后端两个 query 模型原先都把默认区间写死为
  2026-01-01 ~ 2026-07-13,会随时间永久指向一段历史数据;
  现改为"近 30 天"滚动,并把 now 作为可注入参数以便测试。
- 两个 model 测试改为注入固定时钟并断言滚动窗口,不再硬编码日期。

氢能看板去演示数据(board/EnergyBiBoardApp.tsx,6554 -> 3765 行)
- 取消线上路径的 mock 兜底:省份下拉、站点汇总表、客户费用汇总表、
  区域排行在 overview 未就绪时显示空,而不是假的 25 个站点 / Top30 账单。
- 删除已确认"零渲染点"的死代码(用 TypeScript AST 精确定位声明范围,
  避免手工推断括号边界):
    KpiDrillModal / CustomerBillDrillModal / StationBillDrillModal /
    DrillPeriodControls / getYearTimeRange / getDrillTimeRange /
    MOCK_STATION_SUMMARY_LIST / MOCK_CUSTOMER_SUMMARY_LIST
  这些组件内含大量写死的 2026-08-08 快照日期与假流水,均不可达。
  线上 KPI 下钻走的是 PrototypeDrillModal(真实 API)。
- 单站日报"最后更新时间"初值不再用伪造时间戳,改为与接口空态一致的文案。

注:本次未改动任何 DOM 结构与 CSS 类名;删除的均为不可达分支。
lint / test(134) / build 全绿。
2026-09-11 10:23:31 +08:00
dsh-agent b28ca49491 refactor(stage7): 后端目录分层、架构守护测试与文档
后端目录
- db/:mysql / hydrogen / heatmap 连接与氢能只读 SQL 守卫收拢到一处。
- middleware/:auth(JWT → 注入 user)与 read-only 归位。
- 相关测试随文件移动(middleware/read-only.test.ts、db/hydrogen-read-only.test.ts)。
  import 改写由一次性 codemod 完成,未改变任何逻辑。

架构守护
- 新增 src/architecture.test.ts,断言 6 条分层铁律:
  server 不依赖 modules、前端不依赖 server、shared 为叶子层、
  无 vendor 引用、model.ts 不依赖 react、@ts-nocheck 仅限已登记的原型快照。
  豁免清单只减不增。

测试基建
- npm test 的 glob 同时匹配 .test.ts 与 .test.tsx(此前 .tsx 测试会被静默漏掉)。

文档
- 新增根 README.md:入口、快速开始、命令、目录、部署注意事项
  (JWT_SECRET 必须注入且 >=32 字符,否则拒绝启动)。
- 新增 docs/ARCHITECTURE.md:依赖方向、6 条硬规则、业务域形状、
  中间件顺序、新增模块步骤,以及"已知未尽事项"的诚实清单。

lint / test(134) / build 全绿。
2026-09-11 10:20:47 +08:00
dsh-agent 9a9c9d08e1 refactor(stage4): 集中配置与启动校验,权限守卫 fail-closed,凭据移出仓库
配置分层
- 新增 src/server/config.ts:环境变量集中读取 + 启动期校验。
  删除公开兜底密钥 ln-bi-default-secret:SSO 模式下 JWT_SECRET 缺失或
  短于 32 字符时 assertRuntimeConfig() 直接拒绝启动(已实测)。
- index.ts 不再自己读端口/环境;auth/config.ts 与 auth/login.ts 改为引用集中配置。

权限守卫
- energy / scheduling / hydrogen-heatmap 的守卫由 “user 存在才校验” 改为
  “无角色即拒绝”:user 缺失时按无权限处理,不再静默放行(fail-closed)。
- /api/ele/* 此前完全无鉴权,任何已登录用户都能写入电费表;现按能源域
  (BI-LEADER-ENERGY) 守卫。

接口契约
- 未匹配的 /api/* 由 200 text/html(SPA) 改为 404 application/json;
  未认证时仍是 401,避免向未授权调用方暴露路由是否存在。
- 新增全局 onError 返回 JSON 500。

凭据治理(此前均为 git 跟踪文件中的明文)
- Dockerfile 删除烧进镜像的 JWT_SECRET,改为必须运行时注入。
- docker-compose.yml 删除生产库口令/JWT 密钥/失效的 MILEAGE_DB_*,
  改为强制注入写法;补齐 OSS_* 与 NODE_ENV/DEV_BYPASS_AUTH/BI_AUTH_*。
- woodpecker.yml 删除 Harbor base64 凭据改用 secret,pull_request 不再推镜像。
- 删除 scripts-tmp/(含生产库 root 口令)与已跟踪的 .DS_Store;.gitignore 补全。
- 文档中残留的里程库口令改为占位符。

lint / test(128) / build 全绿。
2026-09-11 10:19:07 +08:00
dsh-agent 680c7e0662 refactor(stage3): 下线 5 个无调用方的 legacy 氢能接口
先确认调用方再删:/hydrogen/station-board 被单站日报页面(StationDailyApp /
StationDailyDetailView)实际使用,必须保留;其余 5 个确认零调用方。

- 删服务端路由 hydrogen-overview / -overview-detail / -daily / -daily-detail /
  -settlement(约 1,080 行),energy/index.ts 保留原有注册顺序语义。
- 删客户端对应请求函数与类型:api.ts 由 202 行精简为 74 行;
  types.ts 由 291 行精简为 112 行(只保留 StationBoard / Electric / ETC 闭包)。
- 顺带修正命名:HydrogenDailyQuery -> EnergyRangeQuery(它服务于电能按月接口)。
- routes.test.ts 同步收敛:删除 settlement / overview 两个已下线接口的测试,
  '四个处理器' 改为只覆盖电能;路由顺序断言更新为现存 9 条。
  电能 SQL 指纹沿用原值,证明取数口径未变。

测试 131 -> 128(删掉的 3 个用例覆盖的是已下线接口)。lint / test / build 全绿。
2026-09-11 10:16:43 +08:00
dsh-agent 7556dbcebc refactor(stage2): 生产代码移出 src/vendor,氢能收敛为分层 feature
src/vendor 语义是'第三方/参考',却装着线上氢能看板并反向 import
src/modules,依赖方向倒挂。本次把生产代码归位并建立氢能分层:

  src/modules/energy/hydrogen/
    index.tsx        导航入口(原 HydrogenModule.tsx)
    api.ts types.ts  HTTP 与 DTO
    dev-mock-api.ts  仅 dev 的 vite 插件(仍由 vite.config.ts 引用)
    model/           纯逻辑(format / bearing-labels / daily-detail-format)
    board/           经营看板 UI(原 vendor energy-h2-bi-board)
    station-daily/   单站日报 UI(原 vendor energy-h2-station-daily)
    drill/           下钻弹层 UI(原 hydrogen-bi-v2 的 UI 部分)
    common/          工具与共享组件(原 vendor common + prototype-download)
    fonts/           JetBrains Mono(原 vendor resources/design-system)

- UI 与 CSS 逐字节保留,仅移动位置与改写相对 import。
- 删除自挂载原型入口后遗留的 2 个 annotation-source.json。
- vite.config.ts 与 tsconfig 的路径/exclude 同步清理。
- independent-entry.test.ts 不再硬编码路径,改为断言'两个入口解析到同一文件'。

导入改写用一次性 codemod 完成(按旧位置解析、按新位置重写),
lint / test(131) / build 全绿,可达性分析仍为 0 未引用文件。
2026-09-11 10:14:37 +08:00
dsh-agent 3daa9808ce refactor(stage1): 删除已证实无引用的死代码
按 import 图可达性分析(从 main.tsx / server index+local / vite.config 出发)
删除 0 引用的文件,删除后可达性分析 UNREACHABLE 归零。

- 删旧氢能实现:PrototypeBoard / HydrogenBiV2App / prototype-adapter /
  prototype-source 全部 / HydrogenView+Overview+Daily+Settlement+BoardChrome+
  StationBoard / hydrogen-overview / hydrogen-daily
- 删死 CSS 4 个(约 12k 行)、删死 vendor 残留(自挂载 index.tsx、AccessGate、SdDatePicker)
- 删 server/mileage-db.ts(含硬编码凭据的死模块)
- DailyRangeControls:移除从未被传入的 vehicleScope/onVehicleScopeChange 死分支
- tsconfig:移除指向已删除/不存在路径的 exclude

测试 146 -> 131(删掉的 3 个测试文件覆盖的是被删的死代码)。
lint / test / build 全绿。
2026-09-11 10:11:33 +08:00
kfluousandHiFox Agent 89dca5c5a4 feat(energy): rotate phone fullscreen drill tables for wider viewing
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
2026-09-05 18:42:57 +08:00
kfluousandHiFox Agent 7d792933dd fix(auth): allow non-empty short access passwords
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
2026-09-05 18:28:39 +08:00
kfluousandHiFox Agent 0dc7e89c1d feat(auth): add configurable password login with SSO default
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
2026-09-05 18:22:41 +08:00
kfluous 4430fb75f1 Merge remote-tracking branch 'origin/main' into codex/hydrogen-oneos709
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-05 18:04:33 +08:00
kfluousandHiFox Agent df259fc12b feat(energy): improve hydrogen drill workspace and verification cutoff
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
2026-09-05 18:04:33 +08:00
kfluousandHiFox Agent df6b8201e8 fix(ci): use npmmirror for CI and Docker dependency installs
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
2026-09-05 17:15:28 +08:00
kfluousandHiFox Agent a177781fe7 feat(energy): integrate self-operated station customers and receipts
ci/woodpecker/push/woodpecker Pipeline was canceled
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
2026-09-05 17:03:15 +08:00
171 changed files with 1591 additions and 68212 deletions
-12
View File
@@ -1,12 +0,0 @@
# Copy to .env.local; never commit real credentials. Environment values win.
HYDROGEN_DB_HOST=
HYDROGEN_DB_PORT=3306
HYDROGEN_DB_USER=
HYDROGEN_DB_PASSWORD=
HYDROGEN_DB_NAME=
# Optional legacy/electric business database; use a database-level read-only user.
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASSWORD=
DB_NAME=
-1
View File
@@ -1,5 +1,4 @@
node_modules
dist
.env
.env.local
.worktrees
-47
View File
@@ -1,47 +0,0 @@
# 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
+5
View File
@@ -10,6 +10,11 @@ services:
DB_USER: "oneos_db_prod"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_NAME: "ln_asset_management"
HYDROGEN_DB_HOST: "47.99.185.173"
HYDROGEN_DB_PORT: "3306"
HYDROGEN_DB_USER: "root"
HYDROGEN_DB_PASSWORD: "lnMysql."
HYDROGEN_DB_NAME: "ln_asset_management"
MILEAGE_DB_HOST: "101.133.130.65"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
-671
View File
@@ -1,671 +0,0 @@
# 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 和经营数据正常。
-13
View File
@@ -1,13 +0,0 @@
# 本地只读预览
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
`.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
验证:`npm test``npm run lint``npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
@@ -0,0 +1,198 @@
# OneOS 日里程与累计里程差额排查报告
- 排查日期:2026-09-16Asia/Shanghai
- 涉及数据日期:2026-09-11 至 2026-09-15
- 接收方:OneOS 开放平台及里程计算服务维护人员
- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑
## 1. 问题概述
BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。
本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。
已确认的现象:
1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。
2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。
尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。
## 2. 查询范围与 BI 计算口径
本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。
```text
GET /api/mileage/monitoring
?startDate=2026-09-15
&endDate=2026-09-15
&sourcePriority=instrument
&limit=2000
```
`instrument` 对应上游协议优先级:
```json
["GB32960", "MQTT"]
```
BI 当前计算方式:
| 指标 | 计算方式 |
| --- | --- |
| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 |
| 单车单日里程 | OneOS `dailyMileageKm` |
| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 |
| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 |
| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 |
以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。
## 3. 汇总对账结果
单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`
| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 |
| --- | ---: | ---: | ---: | ---: |
| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — |
| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 |
| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | 8,666.347 |
| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 |
| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 |
按车牌匹配相邻日期,差额来源分解如下:
| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 |
| --- | --- | --- | --- |
| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 |
| 09-13 | 758 / +674.653 | 1 / 9,341.000 | 157 / 0 |
| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 |
| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 |
本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。
## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐
以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`
### 4.1 粤AGQ83779 月 15 日差额 +70.2 km
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime+08:00 |
| --- | --- | ---: | ---: | --- |
| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 |
| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 |
| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 |
```text
累计差 = 14,467.4 - 14,366.0 = 101.4
差额 = 101.4 - 31.2 = 70.2 km
```
查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。
### 4.2 粤AGR68399 月 15 日差额 +38.0 km
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime+08:00 |
| --- | --- | ---: | ---: | --- |
| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 |
| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 |
累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。
### 4.3 粤AGP36099 月 14 日差额 +808.7 km
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime+08:00 |
| --- | --- | ---: | ---: | --- |
| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 |
| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 |
| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 |
| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 |
9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。
## 5. 异常样本二:协议切换导致累计值倒退
车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime+08:00 |
| --- | --- | ---: | ---: | --- |
| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 |
| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
```text
9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km
9 月 13 日日里程 = 0 km
```
该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。
请重点核查:
1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数?
2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界?
3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文?
4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识?
5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。
## 6. 上游直接复现方法
接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`
使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。
```bash
curl --fail-with-body --silent --show-error \
'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \
-H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \
-H 'Content-Type: application/json' \
--data '{
"date": "2026-09-15",
"plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"],
"protocolPriority": ["GB32960", "MQTT"]
}'
```
依次将 `date` 替换为 `2026-09-12``2026-09-13``2026-09-14``2026-09-15`
逐车对比字段:`date``plateNumber``vin``status``dailyMileageKm``totalMileageKm``sourceProtocol``dataTime``calculatedAt``updatedAt`
本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。
## 7. 请上游提供的定位结果
### 7.1 明确字段契约
- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量?
- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值?
- `dataTime`:具体对应累计读数、日里程终点还是其他时间?
- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照?
- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00
- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。
### 7.2 提供样本计算明细
对上述四辆车,请返回:
1. 各协议参与选源的候选记录、时间及未选中原因。
2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。
3. 有效增量、被过滤增量及过滤原因。
4. 缺报、补报、跨日增量归属,以及历史重算记录。
5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。
## 8. 修复或口径确认后的验收建议
1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。
2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。
3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。
4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。
5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。
6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。
7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。
## 9. 本次排查边界
- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。
- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ln-bi",
"version": "1.1.15",
"version": "1.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.15",
"version": "1.1.14",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
+1 -4
View File
@@ -1,12 +1,9 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.15",
"version": "1.1.14",
"type": "module",
"scripts": {
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
"dev:local:server": "node --import tsx src/server/local.ts",
"dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort",
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite --port=3000 --host=0.0.0.0",
-16
View File
@@ -13,12 +13,6 @@ import {
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const HydrogenPrototypeBoard = lazy(
() =>
import("./vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp").then(
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
),
);
normalizeBrowserPath();
function AuthGate() {
@@ -89,16 +83,6 @@ function AuthGate() {
</Suspense>
);
}
if (routeKey === "energy/hydrogen-board") {
if (!canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
return (
<Suspense fallback={<LoadingState label="正在加载氢能经营看板" />}>
<HydrogenPrototypeBoard />
</Suspense>
);
}
// /energy 整组按能源权限控制
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
-1
View File
@@ -35,5 +35,4 @@ test('keeps energy heatmap visibility and the independent energy navigation', ()
buildModules('energy', []).map(module => module.id),
['hydrogen', 'electric', 'etc'],
);
assert.equal(buildModules('energy', [])[0]?.label, '氢费BI');
});
+5 -5
View File
@@ -1,12 +1,12 @@
import { lazy } from 'react';
import {
Activity,
BatteryCharging,
Fuel,
MapPinned,
Receipt,
Route,
Truck,
Wallet,
Zap,
} from 'lucide-react';
import type { ModuleConfig } from '../components/Shell';
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
@@ -48,9 +48,9 @@ const SCHEDULING_MODULE: ModuleConfig = {
};
const ENERGY_MODULES: ModuleConfig[] = [
{ id: 'hydrogen', label: '氢费BI', icon: Fuel, component: HydrogenModule },
{ id: 'electric', label: '电能', icon: Zap, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule },
{ id: 'hydrogen', label: '氢', icon: Fuel, component: HydrogenModule },
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
];
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
-3
View File
@@ -9,14 +9,11 @@ test('normalizes legacy asset paths without losing search parameters', () => {
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);
assert.equal(resolveCanonicalUrl({ pathname: '/energy/hydrogen-board', search: '', hash: '' }), null);
});
test('derives path groups and hidden routes from path or hash', () => {
assert.equal(getPathSet('/energy'), 'energy');
assert.equal(getPathSet('/energy/hydrogen-board'), 'energy');
assert.equal(getPathSet('/asset'), 'asset');
assert.equal(getRouteKey('/energy/hydrogen-board', ''), 'energy/hydrogen-board');
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
assert.equal(getRouteKey('/asset', '#mileage'), '');
+2 -11
View File
@@ -5,13 +5,7 @@ interface BrowserLocation {
search: string;
hash: string;
}
const ROOT_PATHS = new Set([
'/asset',
'/energy',
'/energy/hydrogen-board',
'/ele/import',
'/admin/feedback',
]);
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
'/': { path: '/asset' },
@@ -37,13 +31,10 @@ export function normalizeBrowserPath(): void {
}
export function getPathSet(pathname: string): PathSet {
return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset';
return pathname === '/energy' ? 'energy' : 'asset';
}
export function getRouteKey(pathname: string, hash: string): string {
if (pathname === '/energy/hydrogen-board') {
return 'energy/hydrogen-board';
}
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
return 'ele/import';
}
+5 -13
View File
@@ -83,9 +83,6 @@ 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, '-');
@@ -95,16 +92,14 @@ 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>
) : null}
{/* Web 侧边栏 (md 及以上):能源各模块使用同一套规格,以电能为基准。 */}
{/* 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} />
@@ -123,8 +118,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn(
'group relative flex flex-col items-center justify-center transition-all',
'h-16 w-16 rounded-2xl text-[10px] font-black',
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
@@ -163,17 +157,15 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
{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')}>
<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">
<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>
{!isHydrogenPrototype ? <FeedbackFab module={activeModule} /> : null}
<FeedbackFab module={activeModule} />
</main>
{/* 移动端底部导航 (md 以下) */}
+5 -4
View File
@@ -37,6 +37,7 @@ import {
getWeeklyFlowRange,
selectFlowDetails,
type VehicleModalSelection,
type FlowSelection,
} from './model';
import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
@@ -87,7 +88,7 @@ export default function AssetsModule() {
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
const [selectedFlow, setSelectedFlow] = useState<FlowSelection | null>(null);
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -384,11 +385,11 @@ export default function AssetsModule() {
const source = rows ?? flowStats?.details ?? [];
if (source.length === 0) return;
const table = source.map((item) => ({
日期: item.date,
业务日期: item.date,
类型: item.typeLabel,
车牌: item.plateNumber,
流转时间: item.eventTime || '',
提交时间: item.submitTime || '',
运维签章时间: item.eventTime || '',
建单时间: item.submitTime || '',
部门: item.department || '',
业务负责人: item.manager || '',
客户: item.customerName || '',
@@ -2,17 +2,18 @@ import React from 'react';
import { Download, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
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' },
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;
selectedFlow: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: React.Dispatch<React.SetStateAction<{ date: string; type: FlowType } | null>>;
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
}
export function FlowDetailModal({
@@ -21,6 +22,9 @@ export function FlowDetailModal({
setSelectedFlow,
exportFlowDetails,
}: FlowDetailModalProps) {
const dateLabel = selectedFlow
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start}${selectedFlow.end}`)
: '';
return (
<>
{/* Flow Detail Modal */}
@@ -37,16 +41,17 @@ export function FlowDetailModal({
<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>
<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}
{dateLabel} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
aria-label="关闭流转明细"
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"
>
@@ -56,7 +61,7 @@ export function FlowDetailModal({
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
onClick={() => exportFlowDetails(selectedFlowDetails, `${dateLabel}-${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"
>
@@ -72,8 +77,8 @@ export function FlowDetailModal({
<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-40 px-3 py-3"></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>
@@ -82,7 +87,7 @@ export function FlowDetailModal({
<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 font-black text-slate-950">{item.plateNumber}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600"></span>}</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>
@@ -105,13 +110,13 @@ export function FlowDetailModal({
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<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="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
@@ -66,6 +66,7 @@ export function VehicleDetailModal({
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
showPlateNumbers.category === 'Returned' ? '已还车' :
showPlateNumbers.category === 'Replaced' ? '已替换' :
showPlateNumbers.category === 'Abnormal' ? '异动' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
</p>
@@ -14,7 +14,7 @@ export function AssetSummaryDesktopTable({
}: AssetSummarySectionProps) {
return (
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
<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>
@@ -26,6 +26,7 @@ export function AssetSummaryDesktopTable({
<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 text-amber-600 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>
@@ -43,7 +44,7 @@ export function AssetSummaryDesktopTable({
'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'}`}>
<td colSpan={15} 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) ?
@@ -55,6 +56,7 @@ export function AssetSummaryDesktopTable({
<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="font-bold">{typeGroup.totalAbnormal}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div>
</div>
@@ -119,6 +121,12 @@ export function AssetSummaryDesktopTable({
) : ''}
</td>
))}
<td className="p-3 text-center border-r border-gray-100">
<button type="button" className="font-bold text-amber-600 hover:underline"
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>
{model.abnormal}
</button>
</td>
<td className="p-3 text-center border-r border-gray-100">
{model.pending > 0 ? (
<button
@@ -33,6 +33,7 @@ export function AssetSummaryMobileCards({
<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="font-bold">{typeGroup.totalAbnormal}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div>
</div>
@@ -55,9 +56,10 @@ export function AssetSummaryMobileCards({
{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">
<div className="flex flex-wrap justify-end 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>
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}> {model.abnormal}</button>
<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>
@@ -0,0 +1,118 @@
import { useRef, useState } from 'react';
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { getWeeklyFlowRange, type DateRange } from '../../model';
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
const [month, setMonth] = useState(() => parseDate(value.start));
const [hovered, setHovered] = useState('');
const today = ymd(new Date());
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
const valid = count > 0 && count <= 370;
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
const previewEnd = draft.end || hovered || draft.start;
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
const quickPicks = [
{ label: '今天', range: { start: today, end: today } },
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
{ label: '业务周', range: getWeeklyFlowRange() },
{ label: '近 7 天', range: { start: ymd(shiftDay(new Date(), -6)), end: today } },
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
];
const open = () => {
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
dialog.current?.showModal();
};
const choose = (date: string) => {
if (selecting === 'start') {
setDraft({ start: date, end: '' }); setSelecting('end');
} else {
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
setSelecting('start');
}
setHovered('');
};
const calendar = (offset: number) => {
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
const year = current.getFullYear(), m = current.getMonth();
const padding = (current.getDay() + 6) % 7;
const length = new Date(year, m + 1, 0).getDate();
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} {m + 1} </div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
{Array.from({ length }, (_, i) => {
const date = ymd(new Date(year, m, i + 1));
const endpoint = date === draft.start || date === draft.end;
const inRange = date >= rangeStart && date <= rangeEnd;
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400"> · </span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} </span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900"></h3><p className="mt-1 text-xs text-slate-400"></p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} </option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} </option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100"></button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} /></button>
</div>
</div>
</dialog>
</>;
}
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react';
import type { FlowType } from '../../api';
import { FLOW_META } from '../FlowDetailModal';
import type { FlowStatisticsCardProps } from './types';
import { FlowDateRangePicker } from './FlowDateRangePicker';
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
@@ -23,7 +24,7 @@ export function FlowStatisticsCard({
<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>
<span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
@@ -39,37 +40,8 @@ export function FlowStatisticsCard({
</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>
<p className="mt-1 text-[11px] text-slate-400"></p>
<FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
<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">
@@ -77,12 +49,16 @@ export function FlowStatisticsCard({
<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">
<button key={type} type="button"
data-testid={`asset-flow-total-${type}`}
disabled={flowLoading || !flowStats?.totals[type]}
onClick={() => flowStats && setSelectedFlow({ start: flowStats.start, end: flowStats.end, type })}
className="border-l border-slate-200/70 px-2 transition hover:bg-slate-100 focus-visible:outline-blue-500 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'}`}>
{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 className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
</button>
))}
</div>
</div>
@@ -114,7 +90,7 @@ export function FlowStatisticsCard({
<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 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">
@@ -133,7 +109,7 @@ export function FlowStatisticsCard({
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>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
</button>
);
})}
@@ -1,4 +1,4 @@
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse } from 'lucide-react';
import type { SummaryMetricsProps } from './types';
export function SummaryMetrics({
@@ -11,7 +11,7 @@ export function SummaryMetrics({
return (
<>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
<div className="grid grid-cols-2 md:grid-cols-5 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: '资产概览' })}>
@@ -49,11 +49,21 @@ export function SummaryMetrics({
<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>
<button type="button" data-testid="asset-abnormal-card"
className="rounded-2xl border border-amber-100 bg-white p-3 shadow-sm flex items-center gap-2 text-left transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '异动车辆' })}>
<div className="w-8 h-8 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600"><TriangleAlert size={14} /></div>
<div>
<div className="text-[9px] text-amber-600 font-bold leading-none mb-0.5"></div>
<div className="text-base font-bold text-amber-600 leading-none">{SUMMARY.inventory.abnormal}</div>
</div>
</button>
{/* 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: '待交车' })}>
@@ -70,7 +80,7 @@ export function SummaryMetrics({
<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="grid min-w-0 flex-1 grid-cols-4 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>
@@ -79,6 +89,10 @@ export function SummaryMetrics({
<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">{(SUMMARY.totalAssets > 0 ? SUMMARY.inventory.abnormal / SUMMARY.totalAssets * 100 : 0).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>
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
import type {
DateRange,
FlowSelection,
InventoryFilters,
VehicleModalSelection,
} from '../../model';
@@ -24,7 +25,7 @@ export interface FlowStatisticsCardProps {
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
flowDailyExpanded: boolean;
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
}
export interface AssetSummarySectionProps {
+14
View File
@@ -296,6 +296,20 @@ test('流转明细按日期和类型同时筛选,客户饼图按区域汇总',
['1'],
);
flowStats.details.push(
{ ...flowStats.details[0], id: '3', type: 'replaced', typeLabel: '替换交车' },
{ ...flowStats.details[0], id: '4', type: 'replaced', typeLabel: '替换交车', date: '2026-08-13' },
);
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map(x => x.id), ['1', '3']);
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'replaced' }).map(x => x.id), ['3']);
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'returned' }).map(x => x.id), ['2']);
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['1', '3', '4']);
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'returned' }).map(x => x.id), ['2']);
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'replaced' }).map(x => x.id), ['3', '4']);
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-13', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['4']);
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-14', end: '2026-08-15', type: 'delivered' }), []);
const customers = [
customer({ region: '广东', total: 2 }),
customer({ customer: '客户乙', region: '浙江', total: 5 }),
+10 -6
View File
@@ -50,6 +50,7 @@ export interface ModalVehicleFilters {
export type VehicleModalCategory =
| 'Inventory'
| 'Abnormal'
| 'Pending'
| 'Delivered'
| 'Returned'
@@ -77,7 +78,7 @@ export interface VehicleListRequestParams {
batch?: string;
model?: string;
location?: string;
category?: 'Inventory' | 'Operating' | 'Pending';
category?: 'Inventory' | 'Abnormal' | 'Operating' | 'Pending';
vehicleType?: string;
manager?: string;
customer?: string;
@@ -97,10 +98,9 @@ export type VehicleModalRequest =
}
| { kind: 'vehicles'; params: VehicleListRequestParams };
export interface FlowSelection {
date: string;
type: FlowType;
}
export type FlowSelection =
| { date: string; type: FlowType }
| { start: string; end: string; type: FlowType };
export function formatLocalDateTime(date: Date): string {
const year = date.getFullYear();
@@ -172,6 +172,7 @@ export function buildVehicleModalRequest(
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 === 'Abnormal') params.category = 'Abnormal';
if (selection.category === 'Inventory') params.category = 'Inventory';
if (selection.category === 'Operating') params.category = 'Operating';
if (selection.category === 'Pending') params.category = 'Pending';
@@ -391,7 +392,10 @@ export function selectFlowDetails(
) {
if (!flowStats || !selection) return [];
return flowStats.details.filter((detail) => (
detail.date === selection.date && detail.type === selection.type
('date' in selection
? detail.date === selection.date
: detail.date >= selection.start && detail.date <= selection.end) && (detail.type === selection.type ||
(selection.type === 'delivered' && detail.type === 'replaced'))
));
}
+6
View File
@@ -24,6 +24,7 @@ export interface BatchSummary {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -36,6 +37,7 @@ export interface ModelSummary {
model: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -49,6 +51,7 @@ export interface TypeSummary {
type: string;
totalAssets: number;
totalInventory: number;
totalAbnormal: number;
totalOperating: number;
inventoryRegions: Record<string, number>;
pending: number;
@@ -62,6 +65,7 @@ export interface BatchGroup {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -73,6 +77,7 @@ export interface BatchGroup {
type: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -153,6 +158,7 @@ export interface RegionTypeBreakdown {
total: number;
operating: number;
inventory: number;
abnormal: number;
pending: number;
customers: string[];
}
+67 -40
View File
@@ -1,52 +1,79 @@
import { useEffect, useState } from 'react';
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react';
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
import { motion } from 'motion/react';
import { Construction, Hammer } from 'lucide-react';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
function formatYuan(value: number) {
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
}
const ETC_HINTS = [
'ETC 通行费数据正在与发卡方系统打通…',
'工人 GG 正在搭脚手架,敬请期待 ~',
'马上能看到每月通行费明细啦',
'想看哪个维度的 ETC?反馈一下嘛',
'上线时机:等数据接通的那一天',
];
export default function ETCView() {
const [data, setData] = useState<EtcOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
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; };
}, []);
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 className="flex flex-col gap-4">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="relative w-20 h-20 mb-4">
<motion.div
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
>
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
<motion.div
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
>
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
</div>
);
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<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 className="text-base font-black text-slate-800 mb-1.5">ETC </div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
<br />
</div>
<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 className="mt-6 w-full max-w-xs space-y-2">
{[
{ label: '需求评审', done: true },
{ label: '数据对接', done: true },
{ label: '页面开发', done: false, current: true },
{ label: '正式上线', done: false },
].map((m, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
className="flex items-center gap-2.5 text-[11px]"
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
m.done ? 'bg-emerald-400'
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
: 'bg-slate-200'
}`} />
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
{m.label}
</span>
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto"></span>}
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto"></span>}
</motion.div>
))}
</div>
</SurfaceCard>
<RotatingFooterHint />
</motion.div>
<RotatingFooterHint hints={ETC_HINTS} />
</div>
);
}
+29 -27
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
@@ -28,7 +28,6 @@ export default function ElectricDaily() {
useEffect(() => {
let cancelled = false;
setError(null);
setMonths(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
@@ -39,11 +38,7 @@ export default function ElectricDaily() {
// 默认展开最新一个月
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
})
.catch(e => {
if (cancelled) return;
setMonths(null);
setError(e instanceof Error ? e.message : String(e));
});
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
@@ -64,6 +59,7 @@ export default function ElectricDaily() {
} = useMemo(() => summarizeElectricMonths(months), [months]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
@@ -76,23 +72,6 @@ export default function ElectricDaily() {
setDateRange(prev => ({ ...prev, [field]: value }));
};
if (error) {
return (
<div className="flex flex-col gap-3">
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<ErrorState message={error} />
</div>
);
}
return (
<div className="flex flex-col gap-3">
<DailyRangeControls
@@ -110,15 +89,37 @@ export default function ElectricDaily() {
<MetricTile
icon={Wallet}
label="充电费用"
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前范围未记录费用'}
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
tone={hasFeeDetail ? 'emerald' : 'slate'}
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
</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>
@@ -184,6 +185,7 @@ export default function ElectricDaily() {
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
+4 -4
View File
@@ -6,7 +6,7 @@ import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
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 }[];
@@ -16,11 +16,11 @@ export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<PageFrame
title="电能经营看板"
subtitle="充电量、费用、日趋势车辆归属"
title="电能成本看板"
subtitle="围绕充电量、费用、日趋势车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="按日 · 总览"
meta="时间单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
+12 -16
View File
@@ -34,32 +34,28 @@ export default function ElectricOverview() {
const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const dataIsCurrentMonth = trendMonthLabel === currentMonth;
const chartTitle = trendMonthLabel ? `${trendMonthLabel} 每日充电` : '每日充电';
const currentMonth = new Date().toISOString().slice(0, 7);
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电`
: '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length;
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 monthPrice = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0;
const dataAsOf = data.latestChargeTime ?? '暂无充电记录';
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
return (
<div className="flex flex-col gap-3">
<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 className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
2025-01-01
</div>
{/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
<MetricTile icon={CalendarClock} label={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'} />
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
@@ -75,8 +71,8 @@ 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 ? (displayedMonthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{dataIsCurrentMonth ? '本月' : '最新月'} / </div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> / </div>
</SurfaceCard>
</div>
+2 -2
View File
@@ -6,10 +6,10 @@ export default function EtcModule() {
return (
<PageFrame
title="ETC 通行费看板"
subtitle="通行明细、账单与回款状态"
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
icon={Receipt}
eyebrow="ETC BI"
meta="通行明细 · 账单"
meta="数据对接中 · 页面能力预留"
>
<ETCView />
</PageFrame>
@@ -1,97 +0,0 @@
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>
);
}
+227 -253
View File
@@ -1,304 +1,278 @@
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 { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import {
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';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
export interface HydrogenDailyProps {
scope?: HydrogenDailyBoardScope;
onRangeTextChange?: (rangeText: string) => void;
}
interface DailyDatasets {
lingniu: HydrogenDailyRow[] | null;
external: HydrogenDailyRow[] | null;
}
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';
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
const [datasets, setDatasets] = useState<DailyDatasets>({ lingniu: null, external: null });
const [loading, setLoading] = useState(true);
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [details, setDetails] = useState<Record<string, DailyDetailState>>({});
const effectiveRange = useMemo(
() => normalizeRange(dateRange.start, dateRange.end),
[dateRange.start, dateRange.end],
);
useEffect(() => {
onRangeTextChange?.(`${effectiveRange.start}${effectiveRange.end}`);
}, [effectiveRange.start, effectiveRange.end, onRangeTextChange]);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const query = pick === 'custom'
? { 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);
});
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
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]);
const {
trendData,
totalKg,
activeDays,
stationCount,
avgKg,
peakDay,
lowDay,
zeroDays,
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
useEffect(() => {
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),
},
}));
}
};
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);
}
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) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
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' });
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
if (loading && !scopedRows) {
return <LoadingState label="正在加载氢气按日看板数据..." />;
}
if (error && !scopedRows) {
return <ErrorState message={error} />;
}
return (
<div className="flex flex-col gap-3">
<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}
updatedAt={updatedAt}
loading={loading}
onQuickPick={(p: DateQuickPick) => {
setPick(p);
setDateRange(getQuickRange(p));
}}
customer={customer}
onQuickPick={applyQuickPick}
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)}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
{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 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={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
<button
type="button"
onClick={() => setRefreshKey((k) => k + 1)}
className="inline-flex items-center gap-1 font-bold text-amber-900 hover:underline"
{/* 外部车辆:新系统数据还没准备好 */}
{customer === '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"
>
<RefreshCw size={12} />
</button>
<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>
) : null}
<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>
)}
{scope === 'station' && selectedStation ? (
<StationDailyOverview
station={selectedStation}
totalKg={summary.totalKg}
totalFee={summary.totalFee}
averagePrice={summary.avgPrice}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<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}
/>
) : 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}
<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)}`}
/>
<DailyTrendChart
rows={trend}
averageKg={summary.avgKg}
peakDay={summary.peakDay}
lowDay={summary.lowDay}
zeroDays={summary.zeroDays}
selectedDate={highlightedDate}
onSelectDate={handleSelectDate}
<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)' }}
/>
{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="可尝试切换统计区间、车辆范围或站点。"
{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((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
))}
</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>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="hidden md:block text-right"> (/Kg)</span>
<span className="text-right"> (Kg)</span>
<span className="text-right"></span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span></span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
<span />
</div>
{/* 主行 + 子行 */}
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
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}`}>
<button
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{r.date}
</span>
<span className="hidden md:block text-right text-[12px] text-slate-300"></span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</button>
<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)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
{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>
)}
<RotatingFooterHint />
</div>
);
}
+30 -6
View File
@@ -1,9 +1,33 @@
import { EnergyBiBoardApp } from '../../vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp';
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';
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'];
/**
* The accepted hydrogen fee BI surface. It is shared with the independent
* acceptance route so the menu entry and /energy/hydrogen-board cannot drift.
*/
export default function HydrogenModule() {
return <EnergyBiBoardApp />;
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
+62 -238
View File
@@ -1,282 +1,106 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchHydrogenOverview,
fetchHydrogenOverviewDetail,
type HydrogenOverviewResponse,
type HydrogenVehicleScope,
type HydrogenVerifyScope,
} from './api';
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
import { useCallback, useEffect, useRef, useState } from 'react';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
import { InsightCards } from './hydrogen-overview/components/InsightCards';
import { KpiSection } from './hydrogen-overview/components/KpiSection';
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
import { OverviewDrillTreeDialog } from './hydrogen-overview/components/OverviewDrillTreeDialog';
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
import {
deriveOverviewMetrics,
formatYuan,
type OverviewDrillRequest,
type OverviewScope,
} from './hydrogen-overview/model';
import type {
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailResponse,
} from './types';
import './styles/energy-bi-board.css';
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
export interface HydrogenOverviewProps {
scope?: OverviewScope;
selectedStationId?: number | null;
onScopeChange?: (scope: HydrogenBoardScope) => void;
onSubChange?: (sub: 'daily' | 'overview' | 'settlement') => void;
onSelectStation?: (stationId: number | null) => void;
onOpenDaily?: () => void;
onRangeTextChange?: (rangeText: string) => void;
}
const DEFAULT_AVAILABLE_YEARS = [2026, 2025, 2024, 2023, 2022, 2021, 2020];
interface DrillSelection {
stationId?: number | null;
customerId?: number | null;
customerName?: string | null;
plateNo?: string | null;
region?: string | null;
month?: string | null;
date?: string | null;
}
export default function HydrogenOverview({
scope = 'global',
selectedStationId = null,
onScopeChange,
onSubChange,
onSelectStation,
onOpenDaily,
onRangeTextChange,
}: HydrogenOverviewProps) {
const currentYear = new Date().getFullYear();
const [activeYear, setActiveYear] = useState<number>(() => {
return DEFAULT_AVAILABLE_YEARS.includes(currentYear) ? currentYear : 2026;
});
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>('all');
const [verifyScope, setVerifyScope] = useState<HydrogenVerifyScope>('all');
const [internalStationId, setInternalStationId] = useState<number | null>(selectedStationId);
export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [year, setYear] = useState<number | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const [drillTree, setDrillTree] = useState<{
title: string;
selection: DrillSelection;
} | null>(null);
const effectiveStationId = scope === 'station' ? (internalStationId ?? selectedStationId) : null;
const load = useCallback(async (force = false) => {
if (force) setRefreshing(true);
else setLoading(true);
setError(null);
const refreshSeq = useRef(0);
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
const seq = ++refreshSeq.current;
setRefreshing(true);
try {
const result = await fetchHydrogenOverview({
year: activeYear,
vehicleScope,
verifyScope,
stationId: effectiveStationId,
force,
});
setData(result);
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
if (seq !== refreshSeq.current) return; // outdated
setData(d);
setError(null);
setLastRefreshAt(Date.now());
const rangeEnd = result.latestLedgerTime?.slice(0, 10) ?? (activeYear === currentYear ? '至今' : `${activeYear}-12-31`);
onRangeTextChange?.(`${activeYear}-01-01 至 ${rangeEnd}`);
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} catch (e) {
if (seq !== refreshSeq.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
setRefreshing(false);
if (seq === refreshSeq.current) setRefreshing(false);
}
}, [activeYear, currentYear, effectiveStationId, onRangeTextChange, vehicleScope, verifyScope]);
}, []);
// 初始加载 + 年份切换:用 force=false 命中热缓存
useEffect(() => { void load(year, false); }, [year, load]);
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
useEffect(() => {
void load(false);
}, [load]);
const t = setInterval(() => { void load(year, false); }, 60_000);
return () => clearInterval(t);
}, [year, load]);
const handleDrillRequest = (request: OverviewDrillRequest) => {
const selection: DrillSelection = {};
if (request.entityId) {
if (request.kind === 'station') selection.stationId = request.entityId;
if (request.kind === 'customer') selection.customerId = request.entityId;
if (error && !data) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
}
if (request.kind === 'customer' && !request.entityId) selection.customerName = request.key;
if (request.kind === 'month') selection.month = request.key;
if (request.kind === 'region') selection.region = request.key;
setDrillTree({
title: request.label || '数据下钻穿透',
selection,
});
};
const handleDrillLoad = useCallback(async (
groupBy: HydrogenOverviewDetailGroupBy | null,
selection: DrillSelection,
scopeParam: HydrogenVehicleScope,
includeAll = false,
): Promise<HydrogenOverviewDetailResponse> => {
return fetchHydrogenOverviewDetail({
year: activeYear,
vehicleScope: scopeParam,
verifyScope,
stationId: selection.stationId ?? effectiveStationId,
customerId: selection.customerId ?? null,
customerName: selection.customerName ?? null,
plateNo: selection.plateNo ?? null,
month: selection.month ?? null,
date: selection.date ?? null,
region: selection.region ?? null,
groupBy,
limit: includeAll ? 500 : 50,
});
}, [activeYear, effectiveStationId, verifyScope]);
const derived = useMemo(() => {
if (!data) return null;
return deriveOverviewMetrics(data);
}, [data]);
if (loading && !data) {
if (!data) {
return <HydrogenOverviewSkeleton />;
}
if (error && !data) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-sm text-red-700">
<div className="font-bold"></div>
<p className="mt-1 text-xs text-red-600">{error}</p>
<button
type="button"
onClick={() => void load(true)}
className="mt-3 inline-flex items-center rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-slate-700 shadow-sm hover:bg-slate-50"
>
</button>
</div>
);
}
const selectedStationName = data?.stations.find((s) => s.id === effectiveStationId)?.name ?? null;
const yearProfitFmt = data ? formatYuan(data.kpi.yearProfit) : { value: '0', unit: '元' };
const yearRevenueFmt = data ? formatYuan(data.kpi.yearRevenue) : { value: '0', unit: '元' };
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
const {
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
profitYield,
stationAvgKg,
monthlyDual,
} = deriveOverviewMetrics(data);
const yearProfitFmt = fmtYuan(kpi.yearProfit);
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
return (
<>
<div className="flex flex-col gap-3 relative">
<OverviewHeader
activeYear={activeYear}
availableYears={DEFAULT_AVAILABLE_YEARS}
vehicleScope={vehicleScope}
verifyScope={verifyScope}
latestLedgerTime={data?.latestLedgerTime ?? null}
availableYears={availableYears}
lastRefreshAt={lastRefreshAt}
refreshing={refreshing}
onSelectYear={(y) => setActiveYear(y)}
onVehicleScopeChange={(vs) => setVehicleScope(vs)}
onVerifyScopeChange={setVerifyScope}
onRefresh={() => void load(true)}
onSelectYear={setYear}
onRefresh={() => void load(year, true)}
/>
{data && derived ? (
<section className="ehb-host" aria-label="经营总览">
<KpiSection
kpi={data.kpi}
scope={scope}
scopeLabel={selectedStationName}
onDrillRequest={handleDrillRequest}
/>
<KpiSection kpi={kpi} />
<InsightCards
monthAvgKg={derived.monthAvgKg}
bestMonth={derived.bestMonth}
latestMonth={derived.latestMonth}
monthMomentum={derived.monthMomentum}
top5Share={derived.top5Share}
customerGrossMarginPct={derived.customerGrossMarginPct}
stationAvgKg={derived.stationAvgKg}
stationCount={data.stations.length}
monthAvgKg={monthAvgKg}
bestMonth={bestMonth}
latestMonth={latestMonth}
monthMomentum={monthMomentum}
top5Share={top5Share}
profitYield={profitYield}
stationAvgKg={stationAvgKg}
stationCount={stations.length}
yearProfitValue={yearProfitFmt.value}
yearProfitUnit={yearProfitFmt.unit}
yearRevenueValue={yearRevenueFmt.value}
yearRevenueUnit={yearRevenueFmt.unit}
stations={data.stations}
onSelectStation={(stId) => {
setInternalStationId(stId);
onSelectStation?.(stId);
}}
onDrillRequest={handleDrillRequest}
/>
<div className="ehb-overview-charts">
<MonthlyCharts
activeYear={activeYear}
monthly={data.monthly}
monthlyDual={derived.monthlyDual}
scope={scope}
scopeLabel={selectedStationName}
onDrillRequest={handleDrillRequest}
/>
<DistributionCharts
top5={data.top5}
regions={data.regions}
stations={data.stations}
yearKg={data.kpi.yearKg}
onSelectStation={(stId) => {
setInternalStationId(stId);
onSelectStation?.(stId);
}}
scope={scope}
scopeLabel={selectedStationName}
onDrillRequest={handleDrillRequest}
/>
</div>
<StationSummaryTable
stations={data.stations}
onSelectStation={(stId) => {
setInternalStationId(stId);
onSelectStation?.(stId);
}}
scope={scope}
scopeLabel={selectedStationName}
onDrillRequest={handleDrillRequest}
/>
<CustomerSummaryTable
customers={data.customers}
scope={scope}
scopeLabel={selectedStationName}
onDrillRequest={handleDrillRequest}
/>
</section>
) : null}
{drillTree && (
<OverviewDrillTreeDialog
title={drillTree.title}
initialVehicleScope={vehicleScope}
initialSelection={drillTree.selection}
load={handleDrillLoad}
onClose={() => setDrillTree(null)}
/>
)}
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
<StationSummaryTable stations={stations} />
<CustomerSummaryTable customers={customers} />
<RotatingFooterHint />
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
</>
</div>
);
}
-186
View File
@@ -1,186 +0,0 @@
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>
);
}
-562
View File
@@ -1,562 +0,0 @@
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);
}
function onlyStationsWithHydrogenRecords(result: HydrogenStationBoardResponse): HydrogenStationBoardResponse {
const stations = result.stations.filter(station => station.recordCount > 0);
return {
...result,
stations,
summary: {
...result.summary,
stationCount: stations.length,
activeStationCount: stations.length,
},
};
}
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);
// A new request invalidates the previous range immediately. Never leave
// stale figures visible while the current API request is pending or failed.
setData(null);
setError(null);
try {
const result = await fetchHydrogenStationBoard({
startDate: dateRange.start,
endDate: dateRange.end,
stationId: selectedStationId,
force,
});
setData(onlyStationsWithHydrogenRecords(result));
setError(null);
} catch (reason) {
setData(null);
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>;
}
+3 -25
View File
@@ -1,34 +1,12 @@
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
import HydrogenSettlement from './HydrogenSettlement';
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
export type HydrogenSubTab = 'daily' | 'overview' | 'settlement';
export type HydrogenSubTab = 'daily' | 'overview';
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,
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} />;
export default function HydrogenView({ sub }: Props) {
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
}
-14
View File
@@ -1,14 +0,0 @@
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',
);
});
+3 -146
View File
@@ -4,10 +4,6 @@ import type {
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick,
HydrogenStationBoardResponse,
HydrogenDailyDetailResponse,
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailResponse,
} from './types';
const BASE = '/api/energy';
@@ -21,74 +17,20 @@ export interface HydrogenOverviewResponse {
stations: HydrogenStationFull[];
availableYears: number[];
year: number;
latestLedgerTime: string | null;
filter: {
stationId: number | null;
vehicleScope: HydrogenVehicleScope;
verifyScope: HydrogenVerifyScope;
};
}
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> {
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
const params = new URLSearchParams();
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');
if (year) params.set('year', String(year));
if (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;
verifyScope?: HydrogenVerifyScope;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
@@ -96,40 +38,12 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: 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.verifyScope === 'verified') q.set('verifyScope', 'verified');
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
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 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[];
latestChargeTime: string | null;
}
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
@@ -143,60 +57,3 @@ export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDail
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${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 type HydrogenSettlementRange = DateQuickPick | 'latest';
export interface HydrogenSettlementRow {
date: string;
stationId: number | null;
stationName: string;
amount: number;
paymentCount: number;
matchMode: 'exact' | 'manual' | 'group' | 'unmatched';
}
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.range) params.set('range', query.range);
if (query.startDate) params.set('startDate', query.startDate);
if (query.endDate) params.set('endDate', query.endDate);
return params.toString();
}
export function fetchHydrogenSettlement(query: HydrogenSettlementQuery = {}): Promise<HydrogenSettlementResponse> {
const q = buildHydrogenSettlementQuery(query);
return fetchJson<HydrogenSettlementResponse>(`${BASE}/hydrogen/settlement${q ? `?${q}` : ''}`);
}
@@ -1,67 +0,0 @@
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;
});
}
@@ -1,172 +1,92 @@
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react';
import { Truck } from 'lucide-react';
import { SurfaceCard } from '../../../components/ui/surface';
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 => (
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={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'
key={opt.id}
onClick={() => onQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{option.label}
{opt.label}
</button>
))}
<button
type="button"
onClick={onCustomPick}
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
? '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>
<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>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={event => onDateRangeChange('start', event.target.value)}
onInput={event => onDateRangeChange('start', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={event => onDateRangeChange('end', event.target.value)}
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
) : 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">
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(option => (
<button
key={option}
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'
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={13} />
<Truck size={14} />
{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>
</SurfaceCard>
);
}
@@ -1,177 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Calendar, ChevronRight, Fuel, TrendingUp, Truck, Wallet, X, Zap } from "lucide-react";
import { fetchH2BiDaily, fetchH2BiDrill, fetchH2BiMeta, fetchH2BiOverview } from "./api";
import { downloadExcelAoa } from "./prototype-download";
import { finiteNumber, formatNumber as number, formatScaled } from "./display-format";
import type { H2BiDailyResponse, H2BiDrillResponse, H2BiMetaResponse, H2BiOverviewResponse, H2BiQuery, H2BiVehicleScope, H2BiVerifyScope } from "./types";
import "./energy-operations-board.css";
const tons = (kg: unknown) => formatScaled(kg, 1000);
const wan = (yuan: unknown) => formatScaled(yuan, 10000);
const stamp = (value: string | null) => value ? value.replace("T", " ").slice(0, 19) : "—";
export const monthlyChange = (monthly: H2BiOverviewResponse["monthly"]) => {
const valid = monthly
.filter((item) => finiteNumber(item.totalKg) !== null)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-2);
const previous = finiteNumber(valid[0]?.totalKg);
const current = finiteNumber(valid[1]?.totalKg);
if (valid.length < 2 || previous === null || current === null || previous === 0) return { value: "—", detail: "暂不可用" };
const change = (current - previous) / previous * 100;
return {
value: `${change > 0 ? "+" : ""}${number(change, 1)}%`,
detail: `${Number(valid[1].month.slice(-2))}月较${Number(valid[0].month.slice(-2))}`,
};
};
type View = "overview" | "daily";
type Scope = "global" | "station";
type DrillLevel = "station" | "customer" | "vehicle" | "record";
type Drill = { title: string; level: DrillLevel; stationId?: string; stationName?: string; customerId?: number; customerName?: string; plateNo?: string } | null;
export default function EnergyOperationsBoard() {
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
const [overview, setOverview] = useState<H2BiOverviewResponse | null>(null);
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
const [scope, setScope] = useState<Scope>("global");
const [view, setView] = useState<View>("overview");
const [year, setYear] = useState(new Date().getFullYear());
const [stationId, setStationId] = useState<string>("");
const [vehicleScope, setVehicleScope] = useState<H2BiVehicleScope>("all");
const [verifyScope, setVerifyScope] = useState<H2BiVerifyScope>("all");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [refreshKey, setRefreshKey] = useState(0);
const [drill, setDrill] = useState<Drill>(null);
const [drillData, setDrillData] = useState<H2BiDrillResponse | null>(null);
useEffect(() => {
fetchH2BiMeta().then((result) => {
setMeta(result);
if (result.years.length && !result.years.some((item) => item.value === year)) setYear(result.years[0].value);
}).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "筛选项加载失败"));
}, []);
const query = useMemo<H2BiQuery>(() => ({
year,
stationId: scope === "station" && stationId ? stationId : null,
vehicleScope,
verifyScope,
}), [scope, stationId, vehicleScope, verifyScope, year]);
useEffect(() => {
let active = true;
setLoading(true);
setError("");
Promise.all([fetchH2BiOverview(query), fetchH2BiDaily(query)])
.then(([nextOverview, nextDaily]) => {
if (!active) return;
setOverview(nextOverview);
setDaily(nextDaily);
})
.catch((reason: unknown) => active && setError(reason instanceof Error ? reason.message : "能源数据加载失败"))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [query, refreshKey]);
useEffect(() => {
if (!drill) { setDrillData(null); return; }
fetchH2BiDrill({ ...query, stationId: drill.stationId ?? query.stationId, customerId: drill.customerId, plateNo: drill.plateNo, groupBy: drill.level, page: 1, pageSize: 100 })
.then(setDrillData)
.catch(() => setDrillData(null));
}, [drill, query]);
const kpi = overview?.kpis;
const kpis = [
{ label: "累计加氢量", value: tons(kpi?.totalKg), unit: "T", icon: Fuel, sub: [["我司承担", `${tons(kpi?.companyBearingKg)} T`], ["客户承担", `${tons(kpi?.customerBearingKg)} T`], ["其他", `${tons(kpi?.otherBearingKg)} T`]], drill: { title: "累计加氢量", level: "station" as const }, featured: true },
{ label: "累计加氢费", value: wan(kpi?.totalCost), unit: "万", prefix: "¥", icon: Wallet, sub: [["我司承担", `¥${wan(kpi?.companyCost)}`], ["客户承担", `¥${wan(kpi?.customerCost)}`], ["其他", `¥${wan(kpi?.otherCost)}`]], drill: { title: "累计加氢费", level: "station" as const } },
{ label: "加氢利润", value: wan(kpi?.customerGrossProfit), unit: "万", prefix: "¥", icon: TrendingUp, sub: `对客 ¥${wan(kpi?.customerRevenue)}万 · 成本 ¥${wan(kpi?.customerCost)}`, drill: { title: "加氢利润", level: "station" as const } },
{ label: "本月加氢量", value: tons(kpi?.monthKg), unit: "T", icon: Truck, sub: `加氢费 ¥${wan(kpi?.monthCost)} 万 · 占累计 ${number(kpi?.monthShareOfRange)}%`, drill: { title: "本月加氢量", level: "station" as const }, featured: true },
{ label: "今日加氢量", value: number(kpi?.todayKg), unit: "Kg", icon: Zap, sub: `加氢费 ¥${number(kpi?.todayCost)} · 占本月 ${number(kpi?.todayShareOfMonth)}%`, drill: { title: "今日加氢量", level: "station" as const } },
];
const displayMonthly = useMemo(() => {
const source = new Map((overview?.monthly ?? []).map((item) => [item.month, item]));
const finalMonth = overview?.range.endDate?.startsWith(String(year))
? Number(overview.range.endDate.slice(5, 7))
: 12;
return Array.from({ length: Math.max(finalMonth, 1) }, (_, index) => {
const month = `${year}-${String(index + 1).padStart(2, "0")}`;
const item = source.get(month);
return {
month,
totalKg: finiteNumber(item?.totalKg) ?? 0,
lingniuKg: finiteNumber(item?.lingniuKg) ?? 0,
externalKg: finiteNumber(item?.externalKg) ?? 0,
customerRevenue: finiteNumber(item?.customerRevenue) ?? 0,
cost: finiteNumber(item?.cost) ?? 0,
};
});
}, [overview, year]);
const maxMonth = Math.max(...displayMonthly.map((item) => item.totalKg), 1);
const maxDay = Math.max(...(daily?.trend.map((item) => finiteNumber(item.kg) ?? 0) ?? []), 1);
const topTotal = overview?.stations.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
const topFive = overview?.stations.slice().sort((a, b) => b.kg - a.kg).slice(0, 5) ?? [];
const topShare = topTotal ? number(topFive.reduce((sum, item) => sum + item.kg, 0) / topTotal * 100, 1) : "—";
const safeTotalKg = finiteNumber(kpi?.totalKg);
const safeProfit = finiteNumber(kpi?.customerGrossProfit);
const unitProfit = safeTotalKg && safeProfit !== null ? number(safeProfit / safeTotalKg) : "—";
const maxFinance = Math.max(...displayMonthly.flatMap((item) => [item.customerRevenue, item.cost]), 1);
const regionTotal = overview?.regions.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
const bearerTotal = finiteNumber(kpi?.totalKg) ?? 0;
const bearerPct = (value: unknown) => bearerTotal ? (finiteNumber(value) ?? 0) / bearerTotal * 100 : 0;
const monthChange = monthlyChange(overview?.monthly ?? []);
const exportOverview = () => overview && downloadExcelAoa([
["加氢站", "加氢量(Kg)", "成本(元)", "对客金额(元)", "流水笔数"],
...overview.stations.map((row) => [row.name, row.kg, row.cost, row.customerRevenue, row.recordCount]),
], `氢能经营看板_${year}.xlsx`, "氢能经营看板");
return (
<main className="eob" data-component="energy-operations-board-v1">
<header className="eob-hero">
<div className="eob-brand"><span><Fuel size={21} /></span><div><div className="eob-title-line"><h1></h1><b></b></div><p>{overview?.range.startDate ?? "—"} {overview?.range.endDate ?? "—"}</p></div></div>
<div className="eob-hero-nav">
<div className="eob-scope" aria-label="看板范围">
<button className={scope === "global" ? "is-active" : ""} onClick={() => setScope("global")}></button>
<button className={scope === "station" ? "is-active" : ""} onClick={() => setScope("station")}></button>
</div>
<div className="eob-tabs"><button className={view === "overview" ? "is-active" : ""} onClick={() => setView("overview")}></button><button className={view === "daily" ? "is-active" : ""} onClick={() => setView("daily")}></button></div>
</div>
</header>
<section className="eob-filters" aria-label="筛选条件">
<div className="eob-filter-left">
<label className="eob-year"><Calendar size={14}/><select aria-label="年份" value={year} onChange={(e) => setYear(Number(e.target.value))}>{(meta?.years ?? []).map((item) => <option value={item.value} key={item.value}>{item.value} </option>)}</select></label>
<div className="eob-vehicle-tabs" aria-label="车辆范围"><button className={vehicleScope === "all" ? "is-active" : ""} onClick={() => setVehicleScope("all")}></button><button className={vehicleScope === "lingniu" ? "is-active" : ""} onClick={() => setVehicleScope("lingniu")}><i/></button><button className={vehicleScope === "external" ? "is-active" : ""} onClick={() => setVehicleScope("external")}><i/></button></div>
{scope === "station" && <label><select value={stationId} onChange={(e) => setStationId(e.target.value)}><option value=""></option>{(meta?.stations ?? []).map((item) => <option value={String(item.id)} key={String(item.id)}>{item.name}</option>)}</select></label>}
</div>
<div className="eob-filter-right">
<select aria-label="核对状态" value={verifyScope} onChange={(e) => setVerifyScope(e.target.value as H2BiVerifyScope)}><option value="all"></option><option value="verified"></option></select>
</div>
</section>
{error && <div className="eob-state is-error">{error}<button onClick={() => setRefreshKey((value) => value + 1)}></button></div>}
{loading && <div className="eob-state"></div>}
{!error && !loading && view === "overview" && <>
<section className="eob-mobile-overview" aria-label="累计经营概览"><header><h2></h2><span>{year} </span></header><div className="eob-mobile-totals"><button onClick={() => setDrill({ title: "累计加氢量", level: "station" })}><span></span><strong>{tons(kpi?.totalKg)}<small>T</small></strong></button><button onClick={() => setDrill({ title: "累计成本金额", level: "station" })}><span></span><strong>¥{wan(kpi?.totalCost)}<small></small></strong></button></div><div className="eob-bearer-bar"><i style={{width:`${bearerPct(kpi?.companyBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.customerBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.otherBearingKg)}%`}}/></div><div className="eob-bearers"><span><strong>{tons(kpi?.companyBearingKg)}T</strong><small>{number(bearerPct(kpi?.companyBearingKg),1)}%</small></span><span><strong>{tons(kpi?.customerBearingKg)}T</strong><small>{number(bearerPct(kpi?.customerBearingKg),1)}%</small></span><span><strong>{tons(kpi?.otherBearingKg)}T</strong><small>{number(bearerPct(kpi?.otherBearingKg),1)}%</small></span></div></section>
<button className="eob-mobile-profit" onClick={() => setDrill({ title: "加氢利润", level: "station" })}><span><TrendingUp size={22}/></span><div><small></small><strong>¥{wan(kpi?.customerGrossProfit)}<i></i></strong></div><dl><div><dt></dt><dd>¥{wan(kpi?.customerRevenue)}</dd></div><div><dt></dt><dd>¥{wan(kpi?.customerCost)}</dd></div></dl></button>
<section className="eob-mobile-period"><button onClick={() => setDrill({ title: "本月加氢", level: "station" })}><span></span><strong>{tons(kpi?.monthKg)}<small>T</small></strong><p> ¥{wan(kpi?.monthCost)}</p></button><button onClick={() => setDrill({ title: "本日加氢", level: "station" })}><span></span><strong>{number(kpi?.todayKg)}<small>Kg</small></strong><p> ¥{number(kpi?.todayCost)}</p></button></section>
<section className="eob-kpis" aria-label="五项经营指标">{kpis.map(({ icon: Icon, ...item }) => <button key={item.label} className={`eob-kpi ${item.featured ? "is-featured" : ""}`} onClick={() => setDrill(item.drill)}><span className="eob-kpi-head"><span>{item.label}<small> </small></span><i><Icon size={17} /></i></span><strong>{item.value === "—" ? <b></b> : <>{item.prefix}<b>{item.value}</b><small>{item.unit}</small></>}</strong>{Array.isArray(item.sub) ? <div className="eob-kpi-breakdown">{item.sub.map(([label,value])=><span key={label}><small>{label}</small><b>{value}</b></span>)}</div> : <p>{item.sub}</p>}</button>)}</section>
<section className="eob-diagnosis-desktop" aria-label="经营诊断"><b></b><article><span></span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span></span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span></span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span></span><strong></strong><small></small></article></section>
<details className="eob-diagnosis" open={false}><summary> <span></span></summary><div><article><span></span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span></span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span></span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span></span><strong></strong><small></small></article></div></details>
<section className="eob-charts">
<article className="eob-panel"><header><h2>{year} </h2><span><i className="is-blue" /> <i className="is-light-blue" /> {overview?.range.startDate} {overview?.range.endDate} · Kg</span></header><div className="eob-bars">{displayMonthly.map((item) => <div key={item.month}><span>{number(item.totalKg / 1000, 1)}k</span><b style={{ height: `${item.totalKg ? Math.max(item.totalKg / maxMonth * 100, 2) : 0}%` }}><i className="is-light-blue" style={{ height: `${item.totalKg ? item.externalKg / item.totalKg * 100 : 0}%` }} /><i className="is-blue" /></b><small>{Number(item.month.slice(-2))}</small></div>)}</div></article>
<article className="eob-panel eob-finance"><header><h2>{year} </h2><span><i className="is-cyan" /> <i className="is-purple" /> {overview?.range.startDate} {overview?.range.endDate} · </span></header><div className="eob-finance-bars">{displayMonthly.map((item) => <div key={item.month}><span><i className="is-purple" style={{height:`${item.cost ? Math.max(item.cost/maxFinance*100,2) : 0}%`}}/><i className="is-cyan" style={{height:`${item.customerRevenue ? Math.max(item.customerRevenue/maxFinance*100,2) : 0}%`}}/></span><small>{Number(item.month.slice(-2))}</small></div>)}</div></article>
<article className="eob-panel"><header><h2> Top5</h2><button onClick={() => setDrill({ title: "加氢站排名", level: "station" })}></button></header><ol className="eob-ranking">{topFive.map((item, index) => <li key={String(item.id)}><b>{index + 1}</b><span>{item.name}</span><i><em style={{ width: `${finiteNumber(topFive[0]?.kg) ? (finiteNumber(item.kg) ?? 0) / (finiteNumber(topFive[0]?.kg) ?? 1) * 100 : 0}%` }} /></i><strong>{number(item.kg, 0)}</strong></li>)}</ol></article>
<article className="eob-panel eob-regions"><header><h2></h2><strong> {tons(regionTotal)} T</strong></header><div>{overview?.regions.map((item,index)=><article key={item.region}><b>{index+1}</b><span>{item.region || "未归属"}</span><i><em style={{width:`${regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : 0}%`}}/></i><strong>{number(finiteNumber(item.share) ?? (regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : null),1)}%</strong></article>)}{!overview?.regions.length&&<p></p>}</div></article>
</section>
</>}
{!error && !loading && view === "daily" && <section className="eob-panel eob-daily"><header><h2></h2><span>{daily?.range.startDate ?? "—"} {daily?.range.endDate ?? "—"}</span></header><div className="eob-bars">{daily?.trend.map((item) => <div key={item.date}><span>{number(item.kg, 0)}</span><b style={{ height: `${Math.max((finiteNumber(item.kg) ?? 0) / maxDay * 100, 2)}%` }}><i className="is-blue" /></b><small>{item.date.slice(5)}</small></div>)}</div><div className="eob-daily-table">{daily?.days.map((item) => <button key={item.date} onClick={() => setDrill({ title: `${item.date} 明细`, level: "station" })}><span>{item.date}</span><strong>{number(item.kg)} Kg</strong><small>¥{number(item.cost)} · {number(item.recordCount, 0)} </small></button>)}</div></section>}
{drill && <div className="eob-modal" role="dialog" aria-modal="true" data-drill-level={drill.level}><section><header><div><h2>{drill.title}</h2><p> </p></div><button onClick={() => setDrill(null)} aria-label="关闭"><X /></button></header><nav className="eob-drill-crumbs"><button onClick={() => setDrill({ title: drill.title, level: "station" })}></button>{drill.stationName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "customer", customerId: undefined, customerName: undefined, plateNo: undefined })}>{drill.stationName}</button></>}{drill.customerName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "vehicle", plateNo: undefined })}>{drill.customerName}</button></>}{drill.plateNo && <><ChevronRight size={14}/><span>{drill.plateNo}</span></>}</nav><div className="eob-modal-body">{!drillData ? <div className="eob-state"></div> : drill.level === "record" ? <table><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{drillData.records.map((row, index) => <tr key={String(row.id ?? index)}><td>{String(row.orderNo ?? row.id ?? "—")}</td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{String(row.verifyStatus ?? row.status ?? "—")}</td></tr>)}{drillData.records.length === 0 && <tr><td colSpan={4}></td></tr>}</tbody></table> : <table><thead><tr><th>{drill.level === "station" ? "站点" : drill.level === "customer" ? "客户" : "车辆"}</th><th></th><th></th><th></th></tr></thead><tbody>{drillData.groups.map((row) => <tr className="eob-drill-row" key={row.id} onClick={() => setDrill(drill.level === "station" ? { ...drill, level: "customer", stationId: row.id, stationName: row.name } : drill.level === "customer" ? { ...drill, level: "vehicle", customerId: Number(row.id), customerName: row.name } : { ...drill, level: "record", plateNo: row.name })}><td>{row.name}<ChevronRight size={15}/></td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{number(row.recordCount, 0)}</td></tr>)}{drillData.groups.length === 0 && <tr><td colSpan={4}></td></tr>}</tbody></table>}</div></section></div>}
</main>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-182
View File
@@ -1,182 +0,0 @@
import { fetchJson } from '../../../auth/api-client';
import type {
H2BiDailyResponse,
H2BiDailyTreeQuery,
H2BiDailyTreeResponse,
H2BiDrillReadOptions,
H2BiFullDrillOptions,
H2BiFullDrillResponse,
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 = {}, options?: RequestInit) {
const qs = queryString(query as Record<string, unknown>);
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
}
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).catch(async () => {
// The date-group drill is backed by the same read-only ledger and has a
// simpler query plan. Keep the date view usable when the aggregate daily
// endpoint times out/fails, without substituting mock data.
const drill = await request<H2BiDrillResponse>('drill', {
...query,
groupBy: 'date',
pageSize: 400,
});
const startDate = query.startDate ?? `${query.year}-01-01`;
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
const days = [] as H2BiDailyResponse['trend'];
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
const date = cursor.toISOString().slice(0, 10);
const row = byDate.get(date);
days.push({
date,
kg: Number(row?.kg ?? 0),
lingniuKg: Number(row?.lingniuKg ?? 0),
externalKg: Number(row?.externalKg ?? 0),
cost: Number(row?.cost ?? 0),
recordCount: Number(row?.recordCount ?? 0),
});
}
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
return {
range: { startDate, endDate },
watermark: { ledgerAt: null, paymentAt: null },
filters: query,
kpis: {
totalKg,
totalCost,
averageDailyKg: totalKg / Math.max(1, days.length),
stationCount: Number(drill.summary.stationCount ?? 0),
activeDays: days.filter((row) => row.kg > 0).length,
},
trend: days,
days: [...days].reverse(),
};
});
}
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
}
export async function fetchH2BiDrill(
query: H2BiDrillQuery,
options?: H2BiDrillReadOptions,
) {
const page = query.page ?? 1;
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
const response = await request<H2BiDrillResponse>(
'drill',
{ ...query, page, pageSize },
options,
);
const itemCount = query.groupBy === 'record'
? response.records.length
: response.groups.length;
// Older servers only set hasMore for record pages. A full page is therefore
// also treated as potentially incomplete; the final empty/short page proves
// completion without silently dropping grouped rows.
return {
...response,
page: {
...response.page,
page,
pageSize,
itemCount,
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
},
} satisfies H2BiDrillResponse;
}
function abortError() {
const error = new Error('已取消全量读取');
error.name = 'AbortError';
return error;
}
/**
* Reads every page only after a caller explicitly asks for a complete result.
* It never returns a partial collection: server errors, cancellation, and the
* page guard all reject before an export can be created.
*/
export async function fetchAllH2BiDrill(
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
options: H2BiFullDrillOptions = {},
): Promise<H2BiFullDrillResponse> {
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
const maxPages = Math.max(1, options.maxPages ?? 250);
const maxRows = Math.max(1, options.maxRows ?? 50_000);
const groups: H2BiDrillResponse['groups'] = [];
const records: H2BiDrillResponse['records'] = [];
let first: H2BiDrillResponse | null = null;
for (let page = 1; page <= maxPages; page += 1) {
if (options.signal?.aborted) throw abortError();
const result = await fetchH2BiDrill(
{ ...query, page, pageSize },
{ signal: options.signal },
);
if (!first) first = result;
groups.push(...result.groups);
records.push(...result.records);
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
if (itemCount > maxRows)
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
if (!result.page.hasMore) {
return {
...first,
groups,
records,
page: {
...result.page,
page: 1,
pageSize,
itemCount,
hasMore: false,
pagesRead: page,
complete: true,
},
};
}
}
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
}
/** Reusable contract for station-detail consumers that need every raw record. */
export function fetchAllH2BiDrillRecords(
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
options?: H2BiFullDrillOptions,
) {
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
}
@@ -1,18 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { bearingLabels } from "./bearing-labels";
test("records display their ledger bearing type", () => {
assert.deepEqual(bearingLabels({ settlementType: 1 }).map(x => x.label), ["客户承担"]);
assert.deepEqual(bearingLabels({ settlementType: "2" }).map(x => x.label), ["我司承担"]);
assert.deepEqual(bearingLabels({ settlementType: 3 }).map(x => x.label), ["客户自行结算"]);
});
test("groups display every distinct bearing type, including unknown", () => {
assert.deepEqual(bearingLabels({ settlementTypes: "1,2,3,unknown,1" }).map(x => x.label),
["客户承担", "我司承担", "客户自行结算", "未明确"]);
});
test("missing and unrecognized values do not imply an actual payer", () => {
for (const settlementType of [null, undefined, "", 4, "all"]) {
assert.deepEqual(bearingLabels({ settlementType }).map(x => x.label), ["未明确"]);
}
});
@@ -1,13 +0,0 @@
const labels: Record<string, { label: string; className: string }> = {
"1": { label: "客户承担", className: "is-cust" },
"2": { label: "我司承担", className: "is-lingniu" },
"3": { label: "客户自行结算", className: "is-other" },
};
// Use ledger settlement types, never the selected filter or monetary amounts.
export function bearingLabels(row: { settlementTypes?: unknown; settlementType?: unknown }) {
const types = String(row.settlementTypes ?? row.settlementType ?? "")
.split(",").map((value) => value.trim());
const results = types.map((value) => labels[value] ?? { label: "未明确", className: "is-other" });
return [...new Map(results.map((item) => [item.label, item])).values()];
}
@@ -1,23 +0,0 @@
import type { ReactNode } from "react";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
export function DailyTreeButton({ open, label, children, onClick }: {
open: boolean; label: string; children: ReactNode; onClick: () => void;
}) {
const Icon = open ? ChevronDown : ChevronRight;
return <button type="button" className="ehb-daily-disclosure" aria-expanded={open}
aria-label={`${open ? "收起" : "展开"}${label}`} onClick={onClick}>
<Icon size={15} aria-hidden="true" /><span>{children}</span>
</button>;
}
export function DailyBranchState({ columns, error, empty, onRetry }: {
columns: number; error?: string; empty?: boolean; onRetry: () => void;
}) {
return <tr className="ehb-daily-branch-state"><td colSpan={columns}>
<div role={error ? "alert" : "status"}>
{error || (empty ? "当前范围暂无明细" : "正在加载明细…")}
{error ? <button type="button" onClick={onRetry}><RefreshCw size={14} aria-hidden="true" /></button> : null}
</div>
</td></tr>;
}
@@ -1,15 +0,0 @@
import type { H2BiDailyResponse } from "./types";
export function formatDailyChange(value: unknown): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
return `环比 ${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
}
/** Export the entire selected range, independently of which branches were opened. */
export function dailySummaryRows(daily: H2BiDailyResponse): Array<Array<string | number>> {
return [
["日期", "加氢站数", "加氢量(Kg)", "成本(元)"],
["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost],
...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]),
];
}
@@ -1,52 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
import type { H2BiDailyResponse } from "./types";
test("每日环比区分真实零值、缺失值和非数值", () => {
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
assert.equal(formatDailyChange(0), "环比 0.00%");
assert.equal(formatDailyChange(-12.5), "环比 -12.50%");
assert.equal(formatDailyChange(12.5), "环比 +12.50%");
});
test("日期汇总导出包含全部日期,不依赖已展开明细并保留真零", () => {
const daily = {
kpis: { stationCount: 2, totalKg: 15.25, totalCost: 450 },
days: [
{ date: "2026-09-03", stationCount: 2, kg: 15.25, cost: 450 },
{ date: "2026-09-02", stationCount: 0, kg: 0, cost: 0 },
],
} as H2BiDailyResponse;
const rows = dailySummaryRows(daily);
assert.equal(rows.length, 4);
assert.deepEqual(rows[1], ["区间合计", 2, 15.25, 450]);
assert.deepEqual(rows[3], ["2026-09-02", 0, 0, 0]);
});
test("层级按钮包含键盘原生语义与明确展开状态", () => {
for (const open of [false, true]) {
const html = renderToStaticMarkup(createElement(DailyTreeButton, {
open, label: "测试站客户明细", children: "测试站", onClick() {},
}));
assert.match(html, /<button type="button"/);
assert.ok(html.includes(`aria-expanded="${open}"`));
assert.ok(html.includes(`${open ? "收起" : "展开"}测试站客户明细`));
}
});
test("展开状态区分加载、空数据、失败及重试入口", () => {
const render = (props: Partial<Parameters<typeof DailyBranchState>[0]>) => renderToStaticMarkup(createElement(DailyBranchState, {
columns: 3, onRetry() {}, ...props,
}));
assert.match(render({}), /正在加载明细/);
assert.match(render({ empty: true }), /当前范围暂无明细/);
const error = render({ error: "站点明细加载失败,请重试" });
assert.match(error, /role="alert"/);
assert.match(error, /<button type="button"/);
assert.match(error, /colSpan="3"/i);
assert.doesNotMatch(error, /正在加载明细/);
});
@@ -1,16 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { devMockResponse } from "./dev-mock-api";
test("开发 mock 覆盖健康检查与氢能 v2 只读接口", () => {
for (const path of [
"/api/health",
"/api/energy/h2/v2/meta",
"/api/energy/h2/v2/overview",
"/api/energy/h2/v2/daily",
"/api/energy/h2/v2/daily-tree",
"/api/energy/h2/v2/drill",
]) assert.ok(devMockResponse(path), path);
assert.equal(devMockResponse("/api/unknown"), undefined);
});
@@ -1,175 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
const station = {
id: "101",
name: "本地验收加氢站",
province: "浙江省",
city: "嘉兴市",
kg: 12345.67,
lingniuKg: 10000,
externalKg: 2345.67,
cost: 23456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
recordCount: 42,
customerCount: 1,
share: 100,
};
const customer = {
id: 1,
name: "本地验收客户",
kg: 12345.67,
customerBearingKg: 10000,
companyBearingKg: 2000,
otherBearingKg: 345.67,
bearer: "both",
cost: 23456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
recordCount: 42,
};
const range = { startDate: "2026-01-01", endDate: "2026-08-31" };
const watermark = { ledgerAt: "2026-08-31 16:00:00", paymentAt: null };
const kpis = {
totalKg: 12345.67,
totalCost: 23456.78,
customerBearingKg: 10000,
companyBearingKg: 2000,
otherBearingKg: 345.67,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
totalRevenue: 34567.89,
customerGrossProfit: 16567.89,
monthKg: 3456.78,
monthCost: 6789.01,
todayKg: 123.45,
todayCost: 234.56,
monthShareOfRange: 28,
todayShareOfMonth: 3.57,
recordCount: 42,
stationCount: 1,
};
const overview = {
range,
watermark,
filters: {},
kpis,
monthly: [{
month: "2026-08",
totalKg: 12345.67,
lingniuKg: 10000,
externalKg: 2345.67,
cost: 23456.78,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerGrossProfit: 16567.89,
}],
topStations: [station],
regions: [{ region: "嘉兴市", kg: 12345.67, share: 100 }],
stations: [station],
customers: [customer],
};
const dailyPoint = {
date: "2026-08-31",
kg: 123.45,
lingniuKg: 100,
externalKg: 23.45,
cost: 234.56,
recordCount: 2,
stationCount: 1,
};
export function devMockResponse(pathname: string) {
if (pathname === "/api/health") return { status: "ok", source: "dev-mock" };
if (pathname.endsWith("/meta")) return {
years: [{ value: 2026, startDate: range.startDate, endDate: range.endDate }],
stations: [{ id: station.id, name: station.name }],
watermark,
};
if (pathname.endsWith("/overview")) return overview;
if (pathname.endsWith("/daily-tree")) return {
date: dailyPoint.date,
stations: [{
id: station.id,
name: station.name,
kg: dailyPoint.kg,
cost: dailyPoint.cost,
recordCount: 2,
customers: [{ id: customer.id, name: customer.name, kg: dailyPoint.kg, cost: dailyPoint.cost, recordCount: 2 }],
}],
};
if (pathname.endsWith("/daily")) return {
range,
watermark,
filters: {},
kpis: { totalKg: 12345.67, totalCost: 23456.78, averageDailyKg: 823.04, stationCount: 1, activeDays: 15 },
trend: [dailyPoint],
days: [dailyPoint],
};
if (pathname.endsWith("/drill")) return {
groupBy: "record",
amountScope: "all",
filters: {},
summary: { recordCount: 1, kg: 123.45, cost: 4000, revenue: 4320 },
groups: [{ ...station, stationCount: 1, customerCount: 1 }],
records: [{
id: 1,
time: "2026-08-31 12:00:00",
orderNo: "DEV-001",
stationId: station.id,
stationName: station.name,
customerId: customer.id,
customerName: customer.name,
plateNo: "浙FDEV01",
source: "dev-mock",
verifyStatus: "verified",
vehicleId: 1,
kg: 123.45,
unitPrice: 35,
cost: 4000,
revenue: 4320,
}],
page: { page: 1, pageSize: 100, hasMore: false },
};
return undefined;
}
function sendJson(response: ServerResponse, body: unknown) {
response.statusCode = 200;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.end(JSON.stringify(body));
}
export function energyDevMockApi(): Plugin {
return {
name: "energy-dev-mock-api",
configureServer(server) {
server.middlewares.use((request: IncomingMessage, response: ServerResponse, next) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
if (pathname === "/favicon.ico") {
response.statusCode = 204;
return response.end();
}
const body = devMockResponse(pathname);
if (body === undefined) return next();
sendJson(response, body);
});
},
};
}
@@ -1,13 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finiteNumber, formatNumber, formatScaled } from "./display-format";
test("能源看板格式化边界区分真实零值与不可用值", () => {
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
assert.equal(finiteNumber(value), null);
assert.equal(formatNumber(value), "—");
assert.equal(formatScaled(value, 1000), "—");
}
assert.equal(formatNumber(0), "0.00");
assert.equal(formatScaled(0, 1000), "0.00");
});
@@ -1,17 +0,0 @@
export const finiteNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
export const formatNumber = (value: unknown, digits = 2): string => {
const safe = finiteNumber(value);
return safe === null
? "—"
: safe.toLocaleString("zh-CN", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
};
export const formatScaled = (value: unknown, divisor: number, digits = 2) => {
const safe = finiteNumber(value);
return safe === null ? "—" : formatNumber(safe / divisor, digits);
};
@@ -1,143 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchAllH2BiDrill, fetchAllH2BiDrillRecords } from "./api";
import type { H2BiDrillResponse } from "./types";
const query = {
year: 2026,
vehicleScope: "all" as const,
verifyScope: "all" as const,
};
function response(overrides: Partial<H2BiDrillResponse> = {}): H2BiDrillResponse {
return {
groupBy: "station",
amountScope: "all",
filters: {},
summary: {},
groups: [],
records: [],
page: { page: 1, pageSize: 2, hasMore: false },
...overrides,
};
}
async function withFetch(
handler: (url: URL) => H2BiDrillResponse | Promise<H2BiDrillResponse>,
run: () => Promise<void>,
) {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = new URL(String(input), "http://ln-bi.local");
return new Response(JSON.stringify(await handler(url)), { status: 200 });
}) as typeof fetch;
try {
await run();
} finally {
globalThis.fetch = original;
}
}
test("完整分组读取跨页,并以短页而非旧服务 hasMore 字段确认结束", async () => {
const pages: number[] = [];
await withFetch((url) => {
const page = Number(url.searchParams.get("page"));
pages.push(page);
return response({
groups: page === 1
? ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }))
: [{ id: "3", name: "丙", province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }],
});
}, async () => {
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 });
assert.deepEqual(result.groups.map((row) => row.name), ["甲", "乙", "丙"]);
assert.equal(result.page.complete, true);
assert.equal(result.page.pagesRead, 2);
});
assert.deepEqual(pages, [1, 2]);
});
test("完整记录读取跨页后保留全部订单", async () => {
await withFetch((url) => {
const page = Number(url.searchParams.get("page"));
return response({
groupBy: "record",
records: page === 1
? [{ id: "a" }, { id: "b" }]
: [{ id: "c" }],
});
}, async () => {
const result = await fetchAllH2BiDrillRecords(query, { pageSize: 2 });
assert.deepEqual(result.records.map((row) => row.id), ["a", "b", "c"]);
assert.equal(result.page.hasMore, false);
});
});
test("全量读取在中途请求失败时拒绝,不返回部分结果", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const page = new URL(String(input), "http://ln-bi.local").searchParams.get("page");
if (page === "2") return new Response("failed", { status: 502, statusText: "Bad Gateway" });
return new Response(JSON.stringify(response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
})), { status: 200 });
}) as typeof fetch;
try {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 }),
/API error: 502/,
);
} finally {
globalThis.fetch = original;
}
});
test("空结果是完整结果而不是加载失败", async () => {
await withFetch(() => response(), async () => {
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" });
assert.deepEqual(result.groups, []);
assert.equal(result.page.pagesRead, 1);
});
});
test("取消的全量读取不会发起请求或生成部分数据", async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal }),
(error: Error) => error.name === "AbortError",
);
});
test("全量读取在第一页完成后也会响应取消,不会请求下一页", async () => {
const controller = new AbortController();
let calls = 0;
await withFetch(() => {
calls += 1;
controller.abort();
return response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
});
}, async () => {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal, pageSize: 2 }),
(error: Error) => error.name === "AbortError",
);
});
assert.equal(calls, 1);
});
test("maxRows 和 maxPages 均拒绝不完整的全量读取", async () => {
await withFetch(() => response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
}), async () => {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxRows: 1 }),
/超过 1 条保护上限/,
);
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxPages: 1 }),
/超过 1 页保护上限/,
);
});
});
@@ -1,687 +0,0 @@
.ehb-drill-modal--unified .ehb-modal-body { padding: 18px; background: #f6f8fb; }
.ehb-drill-modal--unified .ehb-drill-root-tabs { display: flex; justify-content: flex-end; margin: 0 0 12px; }
.ehb-drill-modal--unified .ehb-modal-meta-bar {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0;
padding: 0; overflow: hidden; border: 1px solid #dfe7f0; border-radius: 8px; background: #fff;
}
.ehb-drill-modal--unified .ehb-modal-meta-item { min-width: 0; padding: 12px 16px; border-right: 1px solid #dfe7f0; }
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; }
.ehb-drill-modal--unified .ehb-modal-meta-val { color: #172238; font-family: var(--bi-font-mono); font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-filter-row {
gap: 8px; padding: 10px 12px; margin: 12px 0; border: 1px solid #dfe7f0;
border-radius: 8px; background: #fff;
}
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
.ehb-drill-modal--unified .ehb-modal-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
/* Reserve space for pagination instead of clipping it below a fixed-height table. */
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) {
display: flex;
flex-direction: column;
min-height: 0;
}
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > * {
flex-shrink: 0;
}
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > .ehb-modal-table-wrap {
flex: 1 1 auto;
min-height: 100px;
}
.ehb-drill-page-controls {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.ehb-drill-page-controls .ehb-btn {
flex: 0 0 auto;
min-width: 72px;
width: 72px !important;
min-height: 40px;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-modal-table th {
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
background: #f0f3f8; color: #52627a;
}
.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td {
height: 48px; padding-block: 10px; border-bottom-color: #e5ebf2; color: #334155;
}
.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; }
.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; }
.ehb-drill-modal--unified .ehb-tree-toggle {
display: inline-grid; width: 24px; min-width: 24px; height: 24px; place-items: center;
padding: 0; border: 0; border-radius: 5px; background: transparent; color: #2f6bff;
font-weight: 800; cursor: pointer;
}
.ehb-drill-modal--unified .ehb-tree-toggle:hover,
.ehb-drill-modal--unified .ehb-tree-toggle:focus-visible { background: #eaf1ff; outline: none; }
.ehb-drill-modal--unified .ehb-tree-drill-link {
margin-left: auto; padding: 0; border: 0; background: transparent; color: #2f6bff;
font: inherit; font-size: 12px; font-weight: 700; white-space: nowrap; cursor: pointer;
}
.ehb-drill-modal--unified .ehb-tree-drill-link:hover,
.ehb-drill-modal--unified .ehb-tree-drill-link:focus-visible { color: #174ebc; text-decoration: underline; outline: none; }
.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] {
color: #334155; font-family: var(--bi-font-mono); font-variant-numeric: tabular-nums;
}
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume { color: #2f6bff !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income { color: #2c8a78 !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-cost { color: #e28a24 !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-profit { color: #2f6bff !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-external { color: #7ea5ec !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-summary-volume,
.ehb-drill-modal--unified .ehb-summary-profit { color: #2f6bff; }
.ehb-drill-modal--unified .ehb-summary-income { color: #2c8a78; }
.ehb-drill-modal--unified .ehb-summary-cost { color: #e28a24; }
.ehb-drill-modal--unified .ehb-flat-drill-table tbody tr:nth-child(even) td { background: #fafbfd; }
.ehb-drill-modal--unified .ehb-drill-group-row--date td { background: #fff; }
.ehb-drill-modal--unified .ehb-day-change.is-up { color: #18a67a; font-weight: 700; }
.ehb-drill-modal--unified .ehb-day-change.is-down { color: #e26464; font-weight: 700; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-cust { color: #b86606; border: 1px solid #f2d26c; background: #fffbea; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu { color: #2f6bff; border: 1px solid #bdd0fb; background: #eef4ff; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-other { color: #64748b; border: 1px solid #cbd5e1; background: #f8fafc; }
.ehb-drill-modal--unified .ehb-tag--source-api,
.ehb-drill-modal--unified .ehb-tag--source-station,
.ehb-drill-modal--unified .ehb-tag--source-lingniu {
border: 1px solid #cbd9e8; background: #eef4f9; color: #526b88;
}
.ehb-drill-local-error {
display: flex;
min-height: 180px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
color: #475569;
text-align: center;
}
.ehb-drill-local-error strong { color: #b42318; font-size: 16px; }
.ehb-drill-local-error span { max-width: 520px; line-height: 1.6; }
.ehb-drill-loading {
position: relative;
min-height: 300px;
overflow: hidden;
background: #fff;
animation: ehb-drill-fade-in .18s ease-out both;
}
.ehb-drill-loading__progress {
position: absolute;
inset: 0 auto auto 0;
width: 38%;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #2f6bff 45%, #67b5ff, transparent);
animation: ehb-drill-progress 1.15s ease-in-out infinite;
}
.ehb-drill-loading__label {
display: flex;
align-items: center;
gap: 12px;
padding: 22px 24px 18px;
border-bottom: 1px solid #e7edf5;
color: #1e293b;
}
.ehb-drill-loading__label > span:last-child {
display: flex;
flex-direction: column;
gap: 3px;
}
.ehb-drill-loading__label strong { font-size: 14px; }
.ehb-drill-loading__label small { color: #7b8aa0; font-size: 12px; }
.ehb-drill-loading__spinner {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: 2px solid #dbe7fb;
border-top-color: #2f6bff;
border-radius: 50%;
animation: ehb-drill-spin .72s linear infinite;
}
.ehb-drill-loading__rows { padding: 4px 18px 18px; }
.ehb-drill-loading__row {
display: grid;
grid-template-columns: minmax(180px, 2.2fr) repeat(3, minmax(90px, 1fr));
gap: 28px;
align-items: center;
min-width: 680px;
height: 48px;
border-bottom: 1px solid #eef2f7;
}
.ehb-drill-loading__row i {
display: block;
height: 11px;
border-radius: 999px;
background: linear-gradient(100deg, #edf2f8 20%, #f8fafc 42%, #e7eef8 64%);
background-size: 220% 100%;
animation: ehb-drill-shimmer 1.25s ease-in-out infinite;
}
.ehb-drill-loading__row i:nth-child(2) { width: 72%; }
.ehb-drill-loading__row i:nth-child(3) { width: 58%; }
.ehb-drill-loading__row i:nth-child(4) { width: 82%; }
.ehb-modal-table-wrap.is-ready > .ehb-modal-table {
animation: ehb-drill-content-in .24s ease-out both;
}
@keyframes ehb-drill-spin { to { transform: rotate(360deg); } }
@keyframes ehb-drill-progress {
0% { transform: translateX(-110%); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateX(370%); opacity: 0; }
}
@keyframes ehb-drill-shimmer { to { background-position: -220% 0; } }
@keyframes ehb-drill-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ehb-drill-content-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.ehb-drill-loading,
.ehb-drill-loading__progress,
.ehb-drill-loading__spinner,
.ehb-drill-loading__row i,
.ehb-modal-table-wrap.is-ready > .ehb-modal-table { animation: none; }
}
@media (max-width: 760px) {
.ehb-drill-modal--unified .ehb-modal-meta-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; }
}
.ehb-real-drill-filter-summary {
display: none;
}
@media (max-width: 767px) {
.ehb-drill-modal--unified .ehb-modal-head {
gap: 8px;
padding: 10px 12px;
}
.ehb-drill-modal--unified .ehb-modal-head__title-group {
min-width: 0;
}
.ehb-drill-modal--unified .ehb-modal-head__title {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-modal-head__sub {
font-size: 11px;
}
.ehb-drill-modal--unified .ehb-modal-head__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger {
position: static;
min-width: 76px;
height: 32px;
padding-inline: 8px;
}
.ehb-drill-modal--unified .ehb-modal-body {
padding: 10px;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
padding: 9px 10px;
}
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
width: 235px;
min-width: 235px;
}
/* 宽表左右移动时首列保留完整的站点/客户名称、层级三角和同省标识。 */
.ehb-drill-modal--unified .ehb-modal-table-wrap { overflow: auto !important; }
.ehb-drill-modal--unified .ehb-modal-table { min-width: max-content; }
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
position: sticky;
left: 0;
z-index: 3;
background: #fff;
box-shadow: 6px 0 10px -10px #0f172a;
}
.ehb-drill-modal--unified .ehb-modal-table thead th:first-child {
z-index: 5;
background: #f0f3f8;
}
.ehb-drill-modal--unified .ehb-drill-group-row--station > td:first-child { background: #edf3fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td:first-child { background: #f5f7fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td:first-child { background: #fafbfd; }
.ehb-real-drill-filter-summary {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin: 0 0 8px;
padding: 10px 12px;
border: 1px solid #dbe4f1;
border-radius: 10px;
background: #f8fafc;
color: #64748b;
text-align: left;
}
.ehb-real-drill-filter-summary > span {
display: inline-flex;
align-items: center;
gap: 5px;
color: #2f6bff;
font-weight: 700;
}
.ehb-real-drill-filter-summary strong {
overflow: hidden;
color: #1e293b;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-real-drill-filter-summary > svg {
transition: transform .18s ease;
}
.ehb-real-drill-filter-summary > svg.is-open {
transform: rotate(180deg);
}
.ehb-real-drill-filter-panel:not(.is-open) {
display: none !important;
}
.ehb-real-drill-filter-panel.is-open {
display: flex !important;
}
.ehb-drill-modal--unified .ehb-modal-hint-text {
width: 100%;
overflow: visible !important;
white-space: normal !important;
text-overflow: clip !important;
line-height: 1.45;
}
.ehb-drill-modal--unified .ehb-modal-search-input,
.ehb-drill-modal--unified .ehb-modal-search-input input {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
}
/* 小屏横向空间充足时,把摘要和筛选压成一行,优先留高度给下钻表格。 */
@media (max-height: 500px) and (orientation: landscape) {
.ehb-drill-modal--unified .ehb-modal-head { padding-block: 6px; }
.ehb-drill-modal--unified .ehb-modal-head__sub { display: inline; margin-left: 8px; }
.ehb-drill-modal--unified .ehb-modal-body { padding: 6px 8px 8px; }
.ehb-drill-modal--unified .ehb-drill-root-tabs { margin-bottom: 6px; }
.ehb-drill-modal--unified .ehb-modal-meta-bar {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 6px;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
padding: 6px 10px;
border-right: 1px solid #dfe7f0;
border-bottom: 0 !important;
}
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-filter-row { margin-bottom: 6px; padding: 6px; }
.ehb-drill-modal--unified .ehb-modal-hint-text { display: none; }
.ehb-drill-modal--unified .ehb-drill-loading { min-height: 150px; }
}
/* “横屏查看”是页面布局模式:不依赖手机方向锁定。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] {
display: flex;
flex-direction: column;
overflow: hidden !important;
padding: 0 !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
flex: 0 0 auto;
padding: 7px 10px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
padding: 7px 8px 8px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar {
grid-template-columns: repeat(4, minmax(150px, 1fr));
margin-bottom: 6px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-item {
padding: 6px 9px;
border-right: 1px solid #dfe7f0;
border-bottom: 0;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row {
flex: 0 0 auto;
margin-bottom: 6px;
padding: 6px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
min-height: 0;
flex: 1 1 auto;
overflow: auto !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-hint-text,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary {
display: none;
}
/* 下钻路径是导航,不是装饰:允许直接回到任一上级。 */
.ehb-drill-breadcrumbs {
display: flex;
flex: 0 0 auto;
min-width: 0;
align-items: center;
gap: 5px;
padding: 9px 18px;
overflow-x: auto;
border-bottom: 1px solid #dfe7f0;
background: #fff;
scrollbar-width: none;
}
.ehb-drill-breadcrumbs::-webkit-scrollbar { display: none; }
.ehb-drill-breadcrumb { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.ehb-drill-breadcrumb i { color: #9aabc0; font-style: normal; }
.ehb-drill-breadcrumb button {
max-width: 210px;
padding: 4px 7px;
overflow: hidden;
border: 0;
border-radius: 7px;
background: transparent;
color: #3564a5;
font: 650 12px/1.3 var(--bi-font);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.ehb-drill-breadcrumb button:not(:disabled):hover { background: #edf4ff; color: #1f5fe0; }
.ehb-drill-breadcrumb button[aria-current="page"] {
background: #edf3fc;
color: #24344e;
cursor: default;
}
@media (max-width: 767px) {
.ehb-drill-modal--unified {
display: flex !important;
flex-direction: column !important;
}
.ehb-drill-modal--unified .ehb-modal-head { flex: 0 0 auto !important; }
.ehb-drill-modal--unified .ehb-modal-close-btn { display: none !important; }
.ehb-drill-modal--unified .ehb-modal-back-btn {
min-width: 108px !important;
min-height: 42px !important;
justify-content: center;
border-color: rgb(255 255 255 / 28%) !important;
background: rgb(255 255 255 / 10%) !important;
color: #fff !important;
font-weight: 700 !important;
}
.ehb-drill-breadcrumbs {
padding: 8px 10px;
box-shadow: 0 3px 10px rgb(32 55 89 / 6%);
}
.ehb-drill-breadcrumb button { max-width: 150px; min-height: 30px; font-size: 11px; }
.ehb-drill-modal--unified .ehb-modal-body {
height: auto !important;
min-height: 0 !important;
flex: 1 1 auto !important;
}
.ehb-drill-modal--unified .ehb-tree-drill-link { font-size: 10px !important; }
.ehb-drill-modal--unified .ehb-drill-group-row { min-height: 58px; }
.ehb-drill-modal--unified .ehb-drill-group-row > td:first-child { min-width: 220px !important; }
.ehb-drill-modal--unified .ehb-tree-node-title { display: flex; align-items: center; gap: 4px; }
.ehb-drill-modal--unified .ehb-tree-node-name {
display: inline-flex;
min-width: 0;
flex: 1 1 240px;
flex-wrap: wrap;
align-items: baseline;
gap: 4px;
}
.ehb-drill-modal--unified .ehb-tree-node-name strong { min-width: 0; overflow-wrap: anywhere; }
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 0 !important; background: #f8fbff; }
.ehb-drill-modal--unified .ehb-tree-expanded-content { padding: 8px 14px 10px 38px; border-bottom: 1px solid #dbe7f5; }
.ehb-drill-modal--unified .ehb-tree-expanded-content ul { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 5px 10px; margin: 0; padding: 0; list-style: none; }
.ehb-drill-modal--unified .ehb-tree-expanded-content li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 7px; color: #31578c; }
.ehb-drill-modal--unified .ehb-tree-expanded-all { margin: 8px 7px 0; padding: 3px 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
.ehb-drill-modal--unified .ehb-tree-expanded-all:hover { text-decoration: underline; }
.ehb-drill-modal--unified .ehb-tree-expanded-content small { color: #64748b; white-space: nowrap; }
}
@media (max-width: 767px) and (orientation: landscape) {
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child { width: 300px; min-width: 300px; }
}
/* 竖屏宽表保持原方向;表格容器负责横向滚动,绝不旋转整个页面。 */
@media (max-width: 767px) and (orientation: portrait) {
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback {
display: flex !important;
flex-direction: column !important;
padding: 0 !important;
overflow: auto !important;
background: #f4f7fb !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table-wrap {
width: 100% !important;
min-width: 0 !important;
min-height: 240px !important;
border-radius: 8px !important;
overflow: auto !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table {
min-width: 960px !important;
font-size: 10px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td {
min-width: 112px !important;
height: 38px !important;
min-height: 38px !important;
padding: 6px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th:first-child,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td:first-child {
width: 190px !important;
min-width: 190px !important;
max-width: 190px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback::after {
right: 8px !important;
bottom: 8px !important;
padding: 4px 8px !important;
font-size: 10px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback * {
transform: none !important;
}
}
/* 原生横屏与旋转兼容统一进入专注阅读状态。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-breadcrumbs,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-root-tabs,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-usage-guide {
display: none !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
min-height: 42px !important;
height: 42px !important;
padding: 4px 142px 4px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__sub {
display: none !important;
}
/* 专注阅读仍是可导航的下钻页面;必须能逐层返回,不能要求先退出宽表。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn {
display: inline-flex !important;
width: 44px !important;
min-width: 44px !important;
min-height: 44px !important;
padding: 0 !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
padding: 2px 88px 2px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn > span {
display: none;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group {
display: grid !important;
grid-template-columns: 44px minmax(0, 1fr);
width: 100%;
min-width: 0;
align-items: center;
gap: 7px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group > div {
min-width: 0;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title {
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
width: 80px !important;
min-width: 80px !important;
}
@media (max-width: 767px) {
/* 让三角、名称和轻量下钻提示在同一行网格内分配空间;长名称仅在名称格内换行。 */
.ehb-drill-modal--unified .ehb-tree-node-title {
display: grid !important;
grid-template-columns: 28px minmax(0, 1fr) 24px;
align-items: start;
gap: 3px;
width: 100%;
}
.ehb-drill-modal--unified .ehb-tree-toggle { width: 28px; min-width: 28px; height: 28px; }
.ehb-drill-modal--unified .ehb-tree-node-name {
display: block;
min-width: 0;
flex: none;
overflow-wrap: anywhere;
}
.ehb-drill-modal--unified .ehb-tree-node-name .ehb-tree-node-sub { margin-left: 4px; }
.ehb-drill-modal--unified .ehb-tree-drill-link {
display: inline-grid !important;
width: 24px !important;
min-width: 24px !important;
height: 28px !important;
place-items: center;
margin: 0 !important;
overflow: hidden;
color: transparent !important;
font-size: 0 !important;
text-decoration: none !important;
}
.ehb-drill-modal--unified .ehb-tree-drill-link::after { content: "" !important; color: #2563eb; font-size: 20px; line-height: 1; }
/* 普通弹层的筛选和宽表入口各占一个网格列;退出宽表后不保留绝对定位层。 */
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .ehb-real-drill-primary-actions {
position: static !important;
display: grid !important;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: stretch;
gap: 8px;
width: 100%;
}
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline {
position: static !important;
inset: auto !important;
width: 112px !important;
min-width: 112px !important;
min-height: 48px !important;
}
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline::before {
display: none;
}
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions {
position: absolute !important;
z-index: 280 !important;
top: 5px !important;
right: 8px !important;
display: block !important;
width: 126px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
position: static !important;
display: inline-flex !important;
width: 126px !important;
min-width: 126px !important;
height: 32px !important;
min-height: 32px !important;
border-radius: 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
display: flex !important;
min-height: 0 !important;
flex: 1 1 auto !important;
padding: 5px 6px 6px !important;
gap: 0 !important;
overflow: hidden !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
width: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
max-height: none !important;
flex: 1 1 auto !important;
margin: 0 !important;
overflow: auto !important;
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("能源氢费 BI 入口与独立验收地址复用同一看板", () => {
const entry = readFileSync(new URL("../HydrogenModule.tsx", import.meta.url), "utf8");
const app = readFileSync(new URL("../../../App.tsx", import.meta.url), "utf8");
assert.match(entry, /vendor\/lnbi-8113-exact\/prototypes\/energy-h2-bi-board\/EnergyBiBoardApp/);
assert.match(entry, /return <EnergyBiBoardApp \/>/);
assert.match(app, /vendor\/lnbi-8113-exact\/prototypes\/energy-h2-bi-board\/EnergyBiBoardApp/);
});
@@ -1,19 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer } from "vite";
import type { H2BiOverviewResponse } from "./types";
const point = (month: string, totalKg: number) => ({ month, totalKg }) as H2BiOverviewResponse["monthly"][number];
test("月度环比使用最近两个有效月份且不伪造不可用值", async () => {
const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", optimizeDeps: { noDiscovery: true } });
try {
const { monthlyChange } = await vite.ssrLoadModule("/src/modules/energy/hydrogen-bi-v2/EnergyOperationsBoard.tsx");
assert.deepEqual(monthlyChange([point("2026-06", 80), point("2026-08", 120), point("2026-07", 100)]), { value: "+20.0%", detail: "8月较7月" });
assert.deepEqual(monthlyChange([point("2026-07", 0), point("2026-08", 120)]), { value: "—", detail: "暂不可用" });
assert.deepEqual(monthlyChange([point("2026-08", 120)]), { value: "—", detail: "暂不可用" });
assert.deepEqual(monthlyChange([point("2026-07", 100), point("2026-08", Number.NaN)]), { value: "—", detail: "暂不可用" });
} finally {
await vite.close();
}
});
@@ -1,70 +0,0 @@
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);
}
@@ -1,12 +0,0 @@
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);
}
@@ -1,766 +0,0 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Download, RefreshCw, Truck } from "lucide-react";
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "./api";
import { downloadExcelAoa } from "./prototype-download";
import "./real-daily-mobile.css";
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,
stationId = null,
onRefresh,
refreshToken = 0,
onLoadingChange,
}: {
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";
stationId?: string | number | null;
onRefresh: () => void;
refreshToken?: number;
onLoadingChange?: (loading: boolean) => void;
}) {
const query = useMemo<H2BiQuery>(
() => ({
year: Number(startDate.slice(0, 4)),
startDate,
endDate,
vehicleScope: toScope(fleetScope),
verifyScope,
stationId,
}),
[endDate, fleetScope, startDate, stationId, verifyScope],
);
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [isLoading, setIsLoading] = useState(false);
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 [detailMode, setDetailMode] = useState<"key" | "full">("key");
const [branchErrors, setBranchErrors] = useState<Record<string, string>>({});
const requestGeneration = useRef(0);
const pendingBranches = useRef(new Set<string>());
const tableWrapRef = useRef<HTMLDivElement>(null);
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
useEffect(() => {
let alive = true;
requestGeneration.current += 1;
pendingBranches.current.clear();
setBranchErrors({});
let finishTimer: number | undefined;
const loadingStartedAt = Date.now();
setIsLoading(true);
onLoadingChange?.(true);
// The selected range is a new data contract. Clear the former response so
// an API error can never be mistaken for fresh data or business zeroes.
setDaily(null);
setError(null);
setTrees({});
setExpandedDate(null);
setExpandedStation({});
setExpandedCustomer({});
setExpandedStationLists({});
setExpandedCustomerLists({});
setExpandedRecordLists({});
setCustomerRecords({});
void fetchH2BiDaily(query)
.then((result) => alive && setDaily(result))
.catch(
(reason: unknown) => {
if (!alive) return;
setDaily(null);
setError(
reason instanceof Error ? reason.message : "按日数据加载失败",
);
},
)
.finally(() => {
if (!alive) return;
const remaining = Math.max(0, 500 - (Date.now() - loadingStartedAt));
finishTimer = window.setTimeout(() => {
if (!alive) return;
setIsLoading(false);
onLoadingChange?.(false);
}, remaining);
});
return () => {
alive = false;
requestGeneration.current += 1;
if (finishTimer !== undefined) window.clearTimeout(finishTimer);
};
}, [query, reloadToken, refreshToken, onLoadingChange]);
const handleRefresh = () => {
setReloadToken((value) => value + 1);
onRefresh();
};
const ensureDateTree = (date: string) => {
if (trees[date] || pendingBranches.current.has(date)) return;
const generation = requestGeneration.current;
pendingBranches.current.add(date);
setBranchErrors((items) => ({ ...items, [date]: "" }));
void fetchH2BiDailyTree(date, {
vehicleScope: query.vehicleScope,
verifyScope,
stationId,
}).then((tree) => {
if (generation === requestGeneration.current) setTrees((items) => ({ ...items, [date]: tree }));
}).catch(() => {
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [date]: "站点明细加载失败,请重试" }));
}).finally(() => {
if (generation === requestGeneration.current) pendingBranches.current.delete(date);
});
};
const openDate = (date: string, scrollIntoDate = false) => {
setExpandedDate(date);
ensureDateTree(date);
if (!scrollIntoDate) return;
setHighlightedDate(date);
window.setTimeout(() => {
dateRowRefs.current[date]?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "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 loadCustomer = (
date: string,
stationId: string | number,
customerId: number,
) => {
const key = `${date}:${stationId}:${customerId}`;
if (customerRecords[key] || pendingBranches.current.has(key)) return;
const generation = requestGeneration.current;
pendingBranches.current.add(key);
setBranchErrors((items) => ({ ...items, [key]: "" }));
void fetchH2BiDrill({
...query,
date,
stationId,
customerId,
groupBy: "record",
page: 1,
pageSize: 200,
}).then((result) => {
if (generation === requestGeneration.current) setCustomerRecords((items) => ({ ...items, [key]: result }));
}).catch(() => {
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [key]: "车辆明细加载失败,请重试" }));
}).finally(() => {
if (generation === requestGeneration.current) pendingBranches.current.delete(key);
});
};
const toggleCustomer = (date: string, station: string | number, customer: number) => {
const key = `${date}:${station}:${customer}`;
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
loadCustomer(date, station, customer);
};
const exportRows = () => {
if (!daily) return;
downloadExcelAoa(
dailySummaryRows(daily),
`每日加氢日期汇总_${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">
{isLoading && !daily ? (
<div className="ehb-live-data-state is-loading" role="status" aria-live="polite">
<span className="ehb-live-data-spinner" aria-hidden />
<strong></strong>
<span></span>
</div>
) : null}
<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={handleRefresh}
disabled={isLoading}
aria-busy={isLoading}
>
<RefreshCw size={14} className={isLoading ? "is-spinning" : ""} />
{isLoading ? "加载中…" : "刷新"}
</button>
</div>
</div>
</section>
{error ? <div className="ehb-empty">{error}</div> : null}
{daily ? <>
<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>
</> : null}
<section className="ehb-daily-table-card ehb-real-daily-detail" data-detail-mode={detailMode}>
<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}
disabled={!daily || isLoading || !daily.days.length}
title="导出所选区间的全部日期汇总,不含客户和车辆流水"
>
<Download size={14} />
</button>
</div>
<div className="ehb-daily-detail-toolbar">
<div className="ehb-daily-view-switch" role="group" aria-label="明细显示方式">
{([['key', '重点指标'], ['full', '完整表格']] as const).map(([mode, label]) =>
<button type="button" key={mode} aria-pressed={detailMode === mode} onClick={() => {
setDetailMode(mode);
if (tableWrapRef.current) tableWrapRef.current.scrollLeft = 0;
}}>{label}</button>)}
</div>
<label className="ehb-daily-date-jump">
<span></span>
<select value={expandedDate ?? ""} disabled={!daily?.days.length} onChange={(event) => {
if (event.target.value) openDate(event.target.value, true);
}}>
<option value=""></option>
{(daily?.days ?? []).map(day => <option key={day.date} value={day.date}>{day.date}</option>)}
</select>
</label>
<button type="button" className="ehb-daily-collapse" disabled={!expandedDate} onClick={() => {
setExpandedDate(null); setExpandedStation({}); setExpandedCustomer({});
setExpandedStationLists({}); setExpandedCustomerLists({}); setExpandedRecordLists({});
}}></button>
</div>
<div
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
aria-hidden="true"
>
{detailMode === "key" ? "点击名称逐层查看:日期 → 站点 → 客户 → 车辆" : "首列已固定 · 左右滑动查看全部指标"}
</div>
{!daily ? <div className="ehb-daily-detail-empty" role={error ? "alert" : "status"}>
{error ? <><button type="button" onClick={handleRefresh}></button></> : "正在加载日期明细…"}
</div> : !daily.days.length ? <div className="ehb-daily-detail-empty" role="status"></div> :
<div ref={tableWrapRef} className="ehb-table-wrap" role="region" aria-label="每日加氢明细,可左右滚动" tabIndex={0}>
<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={{
background: open ? "#f0f9ff" : undefined,
}}
>
<td>
<DailyTreeButton open={open} label={`${day.date}加氢站明细`} onClick={() => toggleDate(day.date)}>
{day.date}{" "}
<span className="ehb-title-sub">
({day.stationCount ?? 0} )
</span>
</DailyTreeButton>
</td>
<td></td>
<td>{format(day.kg)}</td>
<td>
<strong className="ehb-daily-cost">{format(day.cost)}</strong>
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
</td>
<td></td>
</tr>
{open && (!tree || tree.stations.length === 0) ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} error={branchErrors[day.date]}
empty={!!tree} onRetry={() => ensureDateTree(day.date)} /> : null}
{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={{
background: "#f8fafc",
}}
>
<td className="ehb-tree-cell-l1">
<DailyTreeButton open={stationOpen} label={`${station.name}客户明细`} onClick={() =>
setExpandedStation(items => ({ ...items, [stationKey]: !items[stationKey] }))}>
<small className="ehb-daily-level-label"></small>{station.name}
</DailyTreeButton>
</td>
<td></td>
<td>{format(station.kg)}</td>
<td>¥{format(station.cost)}</td>
<td></td>
</tr>
{stationOpen && station.customers.length === 0 ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} empty onRetry={() => {}} /> : null}
{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>
<td className="ehb-tree-cell-l2">
<DailyTreeButton open={customerOpen} label={`${customer.name}车辆明细`}
onClick={() => toggleCustomer(day.date, station.id, customer.id)}>
<small className="ehb-daily-level-label"></small>{customer.name}{" "}
<span className="ehb-title-sub">
({customer.recordCount} )
</span>
</DailyTreeButton>
</td>
<td></td>
<td>{format(customer.kg)}</td>
<td>¥{format(customer.cost)}</td>
<td></td>
</tr>
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} error={branchErrors[customerKey]}
empty={!!customerRecords[customerKey]} onRetry={() => loadCustomer(day.date, station.id, customer.id)} /> : null}
{customerOpen &&
records.map((record) => (
<tr key={String(record.id)}>
<td className="ehb-tree-cell-l3">
<small className="ehb-daily-level-label"> · {String(record.time || "—").slice(11, 16)}</small>
<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={detailMode === "key" ? 3 : 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={detailMode === "key" ? 3 : 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={detailMode === "key" ? 3 : 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>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,73 +0,0 @@
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
@@ -1,33 +0,0 @@
{
"directory": {
"title": "能源氢费经营看板",
"nodes": [
{
"id": "overview",
"title": "产品需求说明(PRD",
"type": "markdown",
"path": ".spec/requirements-prd.md",
"description": "口令 lingniu · 顶栏全局/单站 · 无关联入口卡 · 默认全部车辆·KPI/图表/钻取跟随筛选 · 无车牌归外部车辆 · 外部主数据见 energy-h2-external-* · 分流 own→氢费明细 external→仅 BI"
},
{
"id": "board-app",
"title": "氢能经营看板",
"type": "route",
"path": "/prototypes/energy-h2-bi-board",
"description": "顶栏全局/单站;单站=日报起止查询·行内加氢量占比·按量降序"
},
{
"id": "energy-board-plan",
"title": "能源 BI 看板方案",
"type": "markdown",
"path": "../../resources/prd/energy-board-plan-20260806.md"
},
{
"id": "host-ref",
"title": "宿主 overview 视觉参考",
"type": "markdown",
"path": "../../resources/prd/energy-bi-host-ref/content.md"
}
]
}
}
@@ -1,283 +0,0 @@
import type {
CostDim,
FleetScope,
H2OrderRow,
LeaseKind,
OpsKind,
} from '../types';
import { COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types';
export function filterOrders(
rows: H2OrderRow[],
year: number,
verifyScope: 'all' | 'verified',
fleetScope: FleetScope,
): H2OrderRow[] {
return rows.filter((r) => {
if (!r.occurredAt.startsWith(String(year))) return false;
if (verifyScope === 'verified' && r.verifyStatus !== 'verified') return false;
if (fleetScope === 'own' && r.fleet !== 'own') return false;
if (fleetScope === 'external' && r.fleet !== 'external') return false;
return true;
});
}
export function sumAmount(rows: H2OrderRow[]): number {
return rows.reduce((s, r) => s + r.amount, 0);
}
export function sumKg(rows: H2OrderRow[]): number {
return rows.reduce((s, r) => s + r.quantityKg, 0);
}
export function companyCostRows(rows: H2OrderRow[]): H2OrderRow[] {
return rows.filter((r) => r.borneBy === 'company');
}
export function dimAmount(rows: H2OrderRow[], dim: CostDim): number {
return sumAmount(companyCostRows(rows).filter((r) => r.costDim === dim));
}
export function leaseSubAmount(rows: H2OrderRow[], kind: LeaseKind): number {
return sumAmount(
companyCostRows(rows).filter((r) => r.costDim === 'lease' && r.leaseKind === kind),
);
}
export function opsSubAmount(rows: H2OrderRow[], kind: OpsKind): number {
return sumAmount(
companyCostRows(rows).filter((r) => r.costDim === 'ops' && r.opsKind === kind),
);
}
export interface DimCard {
key: CostDim;
label: string;
amount: number;
subs: { key: string; label: string; amount: number }[];
}
export function costDimCards(rows: H2OrderRow[]): DimCard[] {
return [
{
key: 'lease',
label: COST_DIM_LABEL.lease,
amount: dimAmount(rows, 'lease'),
subs: [
{ key: 'company_borne', label: LEASE_KIND_LABEL.company_borne, amount: leaseSubAmount(rows, 'company_borne') },
{ key: 'package_h2', label: LEASE_KIND_LABEL.package_h2, amount: leaseSubAmount(rows, 'package_h2') },
],
},
{
key: 'logistics',
label: COST_DIM_LABEL.logistics,
amount: dimAmount(rows, 'logistics'),
subs: [],
},
{
key: 'ops',
label: COST_DIM_LABEL.ops,
amount: dimAmount(rows, 'ops'),
subs: [
{ key: 'abnormal', label: OPS_KIND_LABEL.abnormal, amount: opsSubAmount(rows, 'abnormal') },
{ key: 'transfer', label: OPS_KIND_LABEL.transfer, amount: opsSubAmount(rows, 'transfer') },
],
},
];
}
export function pendingAmount(rows: H2OrderRow[]): number {
return dimAmount(rows, 'pending');
}
export function unverified(rows: H2OrderRow[]): { amount: number; count: number } {
const list = rows.filter((r) => r.verifyStatus === 'unverified');
return { amount: sumAmount(list), count: list.length };
}
export function formatYuan(n: number): string {
return `¥${n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
}
export function formatKg(n: number): string {
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} kg`;
}
export function costDimLabel(row: H2OrderRow): string {
if (row.costDim === 'lease' && row.leaseKind) {
return `${COST_DIM_LABEL.lease} · ${LEASE_KIND_LABEL[row.leaseKind]}`;
}
if (row.costDim === 'ops' && row.opsKind) {
return `${COST_DIM_LABEL.ops} · ${OPS_KIND_LABEL[row.opsKind]}`;
}
return COST_DIM_LABEL[row.costDim];
}
export type DimFilter =
| { dim: CostDim; sub?: string }
| null;
export function applyDimFilter(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
if (!filter) return rows;
return rows.filter((r) => {
if (r.borneBy !== 'company') return false;
if (r.costDim !== filter.dim) return false;
if (!filter.sub) return true;
if (filter.dim === 'lease') return r.leaseKind === filter.sub;
if (filter.dim === 'ops') return r.opsKind === filter.sub;
return true;
});
}
/** 统计/明细共用:维度筛后的我司成本行;无维度筛则全部我司行 */
export function companyRowsForStats(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
if (!filter) return companyCostRows(rows);
return applyDimFilter(rows, filter);
}
export interface StationMonthRow {
stationId: string;
stationName: string;
month: string;
amount: number;
quantityKg: number;
unverifiedAmount: number;
}
export function stationMonthAgg(rows: H2OrderRow[]): StationMonthRow[] {
const map = new Map<string, H2OrderRow[]>();
rows.forEach((r) => {
const month = r.occurredAt.slice(0, 7);
const key = `${r.stationId}|${month}`;
const list = map.get(key) ?? [];
list.push(r);
map.set(key, list);
});
return Array.from(map.entries())
.map(([key, list]) => {
const [stationId, month] = key.split('|');
return {
stationId,
stationName: list[0].stationName,
month,
amount: sumAmount(list),
quantityKg: sumKg(list),
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
};
})
.sort((a, b) => b.amount - a.amount);
}
export interface CustomerAttrRow {
customerId: string;
customerName: string;
borneLabel: string;
quantityKg: number;
companyCost: number;
unverifiedAmount: number;
}
export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] {
const map = new Map<string, H2OrderRow[]>();
rows.forEach((r) => {
const list = map.get(r.customerId) ?? [];
list.push(r);
map.set(r.customerId, list);
});
return Array.from(map.entries())
.map(([customerId, list]) => {
const company = list.filter((x) => x.borneBy === 'company');
const customer = list.filter((x) => x.borneBy === 'customer');
let borneLabel = '混合';
if (company.length && !customer.length) borneLabel = '我司';
else if (customer.length && !company.length) borneLabel = '客户';
return {
customerId,
customerName: list[0].customerName,
borneLabel,
quantityKg: sumKg(list),
companyCost: sumAmount(company),
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
};
})
.sort((a, b) => b.companyCost - a.companyCost || b.quantityKg - a.quantityKg);
}
export const SOURCE_LABEL: Record<H2OrderRow['source'], string> = {
api: 'API',
manual: '补录',
fence: '围栏',
};
/** 总览 KPI:在宿主示意量级上按当前筛选(年/车辆/核对)等比缩放,保证卡片跟随顶栏筛选 */
export function computeHostKpi(
filtered: H2OrderRow[],
year: number,
allOrders: H2OrderRow[],
base: {
totalKgT: number;
companyKgT: number;
customerKgT: number;
totalFeeWan: number;
companyFeeWan: number;
customerFeeWan: number;
profitWan: number;
incomeWan: number;
costWan: number;
monthKgT: number;
monthFeeWan: number;
monthYearPct: number;
dayKg: number;
dayFee: number;
dayMonthPct: number;
},
) {
const round2 = (n: number) => Math.round(n * 100) / 100;
const baseline = filterOrders(allOrders, year, 'all', 'all');
const baseKg = sumKg(baseline) || 1;
const fKg = sumKg(filtered);
const ratio = fKg / baseKg;
const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company'));
const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer'));
const split = companyKg + customerKg || 1;
const companyShare = companyKg / split;
const customerShare = customerKg / split;
const monthRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08`));
const dayRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08-08`));
const monthKg = sumKg(monthRows);
const dayKgVal = sumKg(dayRows);
const monthAmt = sumAmount(monthRows);
const dayAmt = sumAmount(dayRows);
const yearKg = fKg || 1;
const monthKgShare = monthKg / yearKg;
const dayMonthShare = monthKg > 0 ? dayKgVal / monthKg : 0;
const totalKgT = round2(base.totalKgT * ratio);
const totalFeeWan = round2(base.totalFeeWan * ratio);
const incomeWan = round2(base.incomeWan * ratio);
const costWan = round2(base.costWan * ratio);
const profitWan = round2(base.profitWan * ratio);
const monthKgT = round2(totalKgT * monthKgShare);
const monthFeeWan = round2(totalFeeWan * monthKgShare);
return {
totalKgT,
companyKgT: round2(totalKgT * companyShare),
customerKgT: round2(totalKgT * customerShare),
totalFeeWan,
companyFeeWan: round2(totalFeeWan * companyShare),
customerFeeWan: round2(totalFeeWan * customerShare),
profitWan,
incomeWan,
costWan,
monthKgT,
monthFeeWan,
monthYearPct: round2(monthKgShare * 100),
dayKg: round2(dayKgVal > 0 ? dayKgVal : base.dayKg * ratio * Math.max(dayMonthShare, 0.01)),
dayFee: Math.round(dayAmt > 0 ? dayAmt : base.dayFee * ratio * Math.max(dayMonthShare, 0.01)),
dayMonthPct: round2((dayMonthShare || base.dayMonthPct / 100) * 100),
profitRatePct: incomeWan > 0 ? round2((profitWan / incomeWan) * 100) : 0,
};
}
@@ -1,182 +0,0 @@
import type { H2OrderRow, StationPrepaid } from '../types';
/** 辅助生成 200 条逼真高质量订单明细假数据 */
function generate200MockOrders(): H2OrderRow[] {
const stations = [
{ id: 'st-ln', name: '佛山南海羚牛加氢站' },
{ id: 'st-dp', name: '东鹏大道甲醇制氢一体站' },
{ id: 'st-jx', name: '嘉兴中石化滨海加氢站' },
{ id: 'st-jj', name: '嘉兴嘉锦加氢站' },
{ id: 'st-cd', name: '成都中石化天府机场高速北站加氢站' },
{ id: 'st-gz', name: '广州黄埔高新区氢能示范加氢站' },
{ id: 'st-sh', name: '上海安亭加氢站' },
];
const ownPlates = [
'粤A99887', '粤B12001', '浙A52088', '浙F77881', '浙A88888F',
'浙A66666', '浙F11223', '浙F33445', '川A77889', '川B99001',
'沪A33219', '粤B88102', '浙F99812', '浙F66521', '粤A11029',
];
const extPlates = [
'粤A77661', '浙F33211', '川A55432', '沪B98765', '粤B66554',
'浙A22334', '粤A99102', '川B88761', '无车牌(散车)',
];
const customers = [
{ id: 'c-ln', name: '羚牛自营', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
{ id: 'c-bao', name: '包氢专线项目', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-lease-a', name: '嘉兴智奇供应链', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-log', name: '嘉兴益顺冷链', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
{ id: 'c-log2', name: '四川群彬物流', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
{ id: 'c-lease-b', name: '无锡铭康物流', type: 'internal', deptId: 'd-lease', deptName: '租赁业务二部' },
{ id: 'c-ops', name: '运维调拨车辆', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
{ id: 'c-pend', name: '待归属样本', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-ext-a', name: '广东氢动力', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-b', name: '广东清运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-c', name: '东展供应链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-d', name: '顺丰冷运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-e', name: '极兔速递冷链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
];
const list: H2OrderRow[] = [];
// 生成 200 条记录
for (let i = 1; i <= 200; i++) {
const padId = String(i).padStart(3, '0');
// 年份分配: 1~145 (2026年), 146~185 (2025年), 186~200 (2024年)
let year = 2026;
let month = Math.floor((i % 8)) + 1; // 1~8月
if (i > 145 && i <= 185) {
year = 2025;
month = Math.floor((i % 12)) + 1;
} else if (i > 185) {
year = 2024;
month = Math.floor((i % 12)) + 1;
}
const day = (i * 7 % 28) + 1;
const hour = (i * 3 % 14) + 7;
const minute = (i * 11 % 50) + 5;
const mm = month < 10 ? `0${month}` : `${month}`;
const dd = day < 10 ? `0${day}` : `${day}`;
const hh = hour < 10 ? `0${hour}` : `${hour}`;
const min = minute < 10 ? `0${minute}` : `${minute}`;
const occurredAt = `${year}-${mm}-${dd} ${hh}:${min}`;
const station = stations[i % stations.length];
const customer = customers[i % customers.length];
const isOwn = customer.type === 'internal';
const fleet = isOwn ? 'own' : 'external';
const plateNo = isOwn
? ownPlates[i % ownPlates.length]
: extPlates[i % extPlates.length];
// 单价 & 加氢量
const unitPrice = [28, 30, 32, 35][i % 4];
const quantityKg = Math.round((18 + (i * 3.7 % 85)) * 100) / 100;
const amount = Math.round(quantityKg * unitPrice);
// 成本维度与费用承担
let borneBy: 'company' | 'customer' = 'company';
let costDim: 'lease' | 'logistics' | 'ops' | 'pending' = 'lease';
let leaseKind: 'company_borne' | 'package_h2' | undefined = undefined;
let opsKind: 'abnormal' | 'transfer' | undefined = undefined;
if (customer.id === 'c-ext-a' || customer.id === 'c-ext-d') {
borneBy = 'customer';
}
const dimType = i % 5;
if (dimType === 0) {
costDim = 'lease';
leaseKind = 'company_borne';
} else if (dimType === 1) {
costDim = 'lease';
leaseKind = 'package_h2';
} else if (dimType === 2) {
costDim = 'logistics';
} else if (dimType === 3) {
costDim = 'ops';
opsKind = i % 2 === 0 ? 'abnormal' : 'transfer';
} else {
costDim = 'pending';
}
// 核对状态与数据来源
const verifyStatus = isOwn ? (i % 4 === 0 ? 'unverified' : 'verified') : 'unverified';
const source = isOwn
? (i % 3 === 0 ? 'manual' : i % 3 === 1 ? 'fence' : 'api')
: (i % 2 === 0 ? 'api' : 'manual');
list.push({
id: `HO-${String(year).slice(2)}${mm}-${padId}`,
occurredAt,
stationId: station.id,
stationName: station.name,
plateNo,
customerId: customer.id,
customerName: customer.name,
deptId: customer.deptId,
deptName: customer.deptName,
amount,
quantityKg,
unitPrice,
borneBy,
costDim,
leaseKind,
opsKind,
verifyStatus,
source,
fleet,
});
}
return list;
}
/** 假数:200 条订单明细(三维度成本拆分) */
export const MOCK_ORDERS: H2OrderRow[] = generate200MockOrders();
export const MOCK_PREPAID: StationPrepaid[] = [
{
stationId: 'st-jx',
stationName: '嘉兴中石化滨海加氢站',
openingBalance: 120000,
openingAnchorLabel: '2025 年末财务期末',
recharge: 80000,
consume: 95000,
},
{
stationId: 'st-jj',
stationName: '嘉兴嘉锦加氢站',
openingBalance: null,
openingAnchorLabel: null,
recharge: 40000,
consume: 28000,
},
];
/** 宿主总览 KPI(与 zip content 同量级示意,只读壳) */
export const HOST_KPI = {
totalKgT: 697.16,
companyKgT: 468.28,
customerKgT: 228.88,
totalFeeWan: 2093.71,
companyFeeWan: 1362.85,
customerFeeWan: 730.87,
profitWan: 13.01,
incomeWan: 743.88,
costWan: 2093.71,
monthKgT: 16.67,
monthFeeWan: 50.36,
monthYearPct: 2.4,
dayKg: 181.78,
dayFee: 6533,
dayMonthPct: 1.1,
};
export const DEFAULT_YEAR = 2026;
File diff suppressed because it is too large Load Diff
@@ -1,50 +0,0 @@
/**
* @name
* @description bi-next #hydrogen/overview · OneOS V2 · lingniu
*/
import React, { useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import { EnergyBiAccessGate, isEnergyBiAuthed } from './EnergyBiAccessGate';
import { EnergyBiBoardApp } from './EnergyBiBoardApp';
import annotationSourceDocument from './annotation-source.json';
function AuthedEnergyBiBoard() {
const [ok, setOk] = useState(() => isEnergyBiAuthed());
if (!ok) return <EnergyBiAccessGate onOk={() => setOk(true)} />;
return <EnergyBiBoardApp />;
}
export default function EnergyH2BiBoardEntry() {
useEffect(() => {
clearHostPrototypeRouteInfo();
}, []);
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({ title: '能源氢费经营看板' }),
[],
);
return (
<PrototypeAnnotationHost
source={annotationSourceDocument as unknown as AnnotationSourceDocument}
options={annotationOptions}
>
<AuthedEnergyBiBoard />
</PrototypeAnnotationHost>
);
}
if (typeof document !== 'undefined') {
const container = document.getElementById('root');
if (container && !container.dataset.energyH2BiBoardMounted) {
container.dataset.energyH2BiBoardMounted = '1';
const root = createRoot(container);
root.render(<EnergyH2BiBoardEntry />);
}
}
@@ -1,44 +0,0 @@
{
"version": 1,
"format": "axhub-published-source",
"sourceRoot": "source",
"entry": "index.tsx",
"files": [
{
"path": "annotation-source.json",
"kind": "source"
},
{
"path": "data/aggregates.ts",
"kind": "source"
},
{
"path": "data/mockBoard.ts",
"kind": "source"
},
{
"path": "data/mockDaily.ts",
"kind": "source"
},
{
"path": "EnergyBiAccessGate.tsx",
"kind": "source"
},
{
"path": "EnergyBiBoardApp.tsx",
"kind": "source"
},
{
"path": "index.tsx",
"kind": "entry"
},
{
"path": "styles/energy-bi-board.css",
"kind": "source"
},
{
"path": "types.ts",
"kind": "source"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -1,64 +0,0 @@
/** 能源 BI · 氢能总览嵌入功能 · 类型(宿主 bi-next #hydrogen/overview,非 OneOS V2 */
/** 我司成本三维度(本尊 2026-08-07 */
export type CostDim = 'lease' | 'logistics' | 'ops' | 'pending';
/** 租赁成本二级 */
export type LeaseKind = 'company_borne' | 'package_h2';
/** 运维成本二级 */
export type OpsKind = 'abnormal' | 'transfer';
export type VerifyStatus = 'verified' | 'unverified';
export type BorneBy = 'company' | 'customer';
export type FleetScope = 'own' | 'external' | 'all';
/** 按日 | 总览(站日报 / 现结登记已拆独立模块) */
export type HostView = 'daily' | 'overview';
export interface H2OrderRow {
id: string;
occurredAt: string;
stationId: string;
stationName: string;
plateNo: string;
customerId: string;
customerName: string;
deptId: string;
deptName: string;
amount: number;
quantityKg: number;
unitPrice: number;
borneBy: BorneBy;
costDim: CostDim;
leaseKind?: LeaseKind;
opsKind?: OpsKind;
verifyStatus: VerifyStatus;
source: 'api' | 'manual' | 'fence';
fleet: 'own' | 'external';
}
export interface StationPrepaid {
stationId: string;
stationName: string;
openingBalance: number | null;
openingAnchorLabel: string | null;
recharge: number;
consume: number;
}
export const COST_DIM_LABEL: Record<CostDim, string> = {
lease: '租赁成本',
logistics: '物流成本',
ops: '运维成本',
pending: '待归属',
};
export const LEASE_KIND_LABEL: Record<LeaseKind, string> = {
company_borne: '我司承担',
package_h2: '包氢项目',
};
export const OPS_KIND_LABEL: Record<OpsKind, string> = {
abnormal: '异动',
transfer: '调拨',
};
@@ -1,905 +0,0 @@
/* Exact station-overview primitives carried from the supplied prototype. */
.ehb-shell--station-daily,
.sd-embedded {
--sd-ink: #0f172a;
--sd-muted: #64748b;
--sd-tertiary: #94a3b8;
--sd-line: rgba(15, 23, 42, 0.08);
--sd-cyan: #2563eb;
--sd-cyan-soft: #eff6ff;
--sd-surface: #fff;
--sd-font:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
"Microsoft YaHei", "Noto Sans SC", sans-serif;
--sd-mono:
"JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", ui-monospace,
monospace;
color: #1e293b;
font-family: var(--sd-font);
}
.sd-embedded {
margin-top: 4px;
width: 100%;
min-width: 0;
}
.sd-topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.sd-topbar--embedded {
margin-bottom: 14px;
}
.sd-topbar__updated {
margin: 4px 0 0;
font-size: 12px;
font-weight: 500;
color: #94a3b8;
font-variant-numeric: tabular-nums;
font-family: var(--sd-mono);
}
.sd-topbar__tools {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.sd-date {
position: relative;
display: inline-block;
}
.sd-date--right .sd-date__popover {
left: auto;
right: 0;
}
.sd-date__trigger {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
height: 30px;
padding: 0 10px 0 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: var(--sd-ink);
cursor: pointer;
}
.sd-date__trigger:hover,
.sd-date__trigger.is-open {
border-color: #2563eb;
background: #fff;
box-shadow: 0 0 0 2px #2563eb1f;
}
.sd-date__trigger:focus-visible,
.sd-btn:focus-visible,
.sd-hero-kpi--click:focus-visible,
.sd-share-seg:focus-visible,
.sd-share-legend__btn:focus-visible,
.sd-station-row:focus-visible,
.sd-board-mode > button[role="tab"]:focus-visible,
.sd-board-station-select:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.72);
outline-offset: 2px;
}
.sd-date__label {
font-size: 12px;
font-weight: 500;
color: var(--sd-muted);
}
.sd-date__value {
font-size: 12px;
font-weight: 600;
font-family: var(--sd-mono);
font-variant-numeric: tabular-nums;
color: #0f172a;
}
.sd-date__icon {
color: #94a3b8;
flex-shrink: 0;
}
.sd-date__popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 40;
width: 300px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: #fff;
box-shadow:
0 18px 40px -18px #0f172a47,
0 8px 16px -10px #0284c72e;
}
.sd-date__header { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
.sd-date__title { font-size:14px; font-weight:800; color:var(--sd-ink); letter-spacing:-.02em; }
.sd-date__nav { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border:none; border-radius:8px; background:#f1f5f9; color:#475569; cursor:pointer; }
.sd-date__week { display:grid; grid-template-columns:repeat(7,1fr); margin-bottom:6px; text-align:center; font-size:11px; font-weight:700; color:#94a3b8; }
.sd-date__grid { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }
.sd-date__day { display:inline-flex; align-items:center; justify-content:center; height:32px; border:none; border-radius:8px; background:transparent; color:#334155; font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; cursor:pointer; }
.sd-date__day.is-empty { cursor:default; pointer-events:none; }.sd-date__day.is-selected { background:var(--sd-cyan); color:#fff; font-weight:800; box-shadow:0 6px 14px -6px #0284c78c; }.sd-date__day.is-in-range:not(.is-selected) { background:#dbeafe; color:#1e40af; border-radius:0; }
.sd-date__range-tabs { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-bottom:10px; }.sd-date__range-tabs button { min-height:32px; border:1px solid #e2e8f0; border-radius:8px; background:#fff; color:#64748b; font-size:11px; font-weight:600; font-family:var(--sd-mono); cursor:pointer; padding:4px 6px; }.sd-date__range-tabs button.is-on { border-color:#2563eb; color:#1d4ed8; background:#eff6ff; box-shadow:0 0 0 2px #2563eb1a; }
.sd-date__today { border:none; background:transparent; color:var(--sd-cyan); font-size:12px; font-weight:700; cursor:pointer; padding:4px 6px; border-radius:6px; }.sd-date__apply { margin-left:auto; }
.sd-date__shortcuts {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.sd-date__shortcuts button {
flex: 1;
height: 28px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #f8fafc;
color: #334155;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.sd-date__shortcuts button:hover {
border-color: #93c5fd;
color: #1d4ed8;
background: #eff6ff;
}
.sd-date__range-fields {
display: grid;
gap: 8px;
}
.sd-date__range-fields label {
display: grid;
grid-template-columns: 62px 1fr;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 12px;
font-weight: 600;
}
.sd-date__range-fields input {
height: 30px;
border: 1px solid #cbd5e1;
border-radius: 7px;
padding: 0 7px;
color: #0f172a;
background: #fff;
font: 600 12px var(--sd-mono);
}
.sd-date__footer {
display: flex;
justify-content: flex-end;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #eef2f7;
}
.sd-date__footer--range {
justify-content: space-between;
align-items: center;
color: #64748b;
font: 500 11px var(--sd-mono);
}
/* Station detail: kept in the same DOM/class hierarchy as the supplied prototype. */
.sd-detail-top { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; flex-wrap:wrap; margin-bottom:16px; }
.sd-detail-top__lead { display:flex; align-items:flex-start; gap:12px; }
.sd-detail-top__title { margin:0; font-size:16px; font-weight:700; color:var(--sd-ink); letter-spacing:-.01em; }
.sd-detail-top__meta { margin:4px 0 0; font-size:12px; color:var(--sd-muted); font-family:var(--sd-mono); font-variant-numeric:tabular-nums; }
.sd-detail-top__updated { margin:4px 0 0; font-size:12px; font-weight:500; color:#94a3b8; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); }
.sd-detail-top__tools { display:flex; align-items:flex-end; gap:8px; flex-wrap:wrap; }
.sd-hero-kpis--detail { margin-bottom:16px; }
.sd-unit { margin-left:2px; font-size:11px; font-weight:500; color:var(--sd-muted); font-family:var(--sd-font); }
.sd-panel { background:#fff; border:1px solid var(--sd-line); border-radius:10px; padding:12px 14px; box-shadow:none; min-width:0; }
.sd-panel--block { width:100%; margin-top:16px; }
.sd-panel__title { margin:0; font-size:14px; font-weight:700; color:var(--sd-ink); letter-spacing:-.01em; }
.sd-panel__head-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; }
.sd-table-scroll { overflow-x:auto; overflow-y:visible; margin-top:8px; max-width:100%; -webkit-overflow-scrolling:touch; }
.sd-table-scroll--matrix { border:1px solid #e2e8f0; border-radius:8px; background:#fff; }
.sd-bi-table { width:100%; border-collapse:separate; border-spacing:0; text-align:left; font-size:13px; min-width:0; }
.sd-bi-table th { font-size:12px; font-weight:500; color:var(--sd-muted); padding:8px 12px; border-bottom:1px solid var(--sd-line); white-space:nowrap; background:#f8fafc; text-align:left; }
.sd-bi-table td { font-size:13px; font-weight:400; color:#334155; padding:8px 12px; border-bottom:1px solid var(--sd-line); white-space:nowrap; vertical-align:middle; }
.sd-bi-table tr:last-child td { border-bottom:none; }
.sd-bi-table td.is-num,.sd-bi-table th.is-num { text-align:right; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); font-size:12px; }
.sd-bi-table tr:hover td { background:#f8fafc; }
.sd-bi-table tr.is-total td { font-weight:700; background:#f8fafc; color:#0f172a; }
.sd-table-more { padding:8px !important; text-align:center; background:#f8fafc; }
.sd-table-more button { border:1px dashed #bfdbfe; border-radius:5px; padding:4px 12px; background:#fff; color:#0284c7; font-size:12px; font-weight:700; cursor:pointer; }
.sd-table-more button:hover { background:#eff6ff; }
.sd-bi-table .is-mono { font-family:var(--sd-mono); font-size:12px; color:#475569; }
.sd-bi-table--fill { width:100%; min-width:100%; }
.sd-bi-table--matrix { width:max(100%,1080px); min-width:100%; table-layout:auto; }
.sd-bi-table--matrix th:first-child,.sd-bi-table--matrix td:first-child { position:sticky; left:0; z-index:2; background:#fff; min-width:168px; max-width:220px; white-space:normal; word-break:break-word; box-shadow:4px 0 8px -6px #0f172a2e; }
.sd-bi-table--matrix thead th:first-child { z-index:3; background:#f8fafc; }
.sd-bi-table--matrix tr.is-total td:first-child,.sd-bi-table--matrix tr:hover td:first-child { background:#f8fafc; }
.sd-bi-table--matrix th.is-num,.sd-bi-table--matrix td.is-num { min-width:72px; padding-left:6px; padding-right:8px; text-align:right; }
.sd-bi-table--matrix.is-amount th.is-num,.sd-bi-table--matrix.is-amount td.is-num { min-width:92px; font-size:12px; }
.is-stock-up { color:#ef4444 !important; font-weight:700; }
.is-stock-down { color:#10b981 !important; font-weight:700; }
.is-stock-flat { color:#64748b; }
.sd-delta { display:inline-block; margin-left:3px; font-size:10px; line-height:1; vertical-align:middle; }
.sd-trend { position:relative; margin-top:8px; }
.sd-trend-legend-chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:500; color:#64748b; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sd-trend-legend-dot { width:8px; height:8px; border-radius:2px; background:#2563eb; flex:0 0 auto; display:inline-block; }
.sd-trend--fill { display:flex; align-items:flex-end; gap:6px; width:100%; min-height:220px; padding:12px 4px 4px; box-sizing:border-box; }
.sd-trend__col { flex:1 1 0; min-width:0; display:flex; flex-direction:column; align-items:center; gap:6px; }
.sd-trend__val { font-size:11px; color:#64748b; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); }
.sd-trend__bar-wrap { width:100%; height:140px; display:flex; align-items:flex-end; justify-content:center; }
.sd-trend__bar { width:min(42px,70%); border-radius:4px 4px 2px 2px; background:linear-gradient(180deg,#60a5fa,#2563eb); }
.sd-trend__date { font-size:10px; color:#94a3b8; font-family:var(--sd-mono); font-variant-numeric:tabular-nums; white-space:nowrap; letter-spacing:-.02em; }
.sd-trend__col.is-hover .sd-trend__bar { filter:brightness(1.08); outline:2px solid rgba(37,99,235,.35); }
.sd-trend-tip { position:absolute; top:8px; right:12px; z-index:5; min-width:220px; max-width:320px; padding:10px 12px; border-radius:8px; border:1px solid #e2e8f0; background:#fffffff5; box-shadow:0 8px 20px #0f172a1f; pointer-events:none; }
.sd-trend-tip__date { font-size:12px; font-weight:700; color:#0f172a; font-family:var(--sd-mono); margin-bottom:6px; }
.sd-trend-tip__row { display:flex; align-items:center; gap:6px; font-size:12px; color:#334155; }
.sd-trend-tip__name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sd-trend-tip__row strong { font-family:var(--sd-mono); font-variant-numeric:tabular-nums; color:#0f172a; white-space:nowrap; }
.sd-trend-tip__sub { margin-top:4px; font-size:11px; color:#94a3b8; }
.sd-msel { position:relative; flex:0 0 auto; }
.sd-msel__trigger { display:inline-flex; align-items:center; gap:8px; height:30px; max-width:280px; padding:0 10px; border:1px solid #cbd5e1; border-radius:8px; background:#fff; cursor:pointer; color:#0f172a; }
.sd-msel.is-open .sd-msel__trigger,.sd-msel__trigger:hover { border-color:#2563eb; box-shadow:0 0 0 2px #2563eb1f; }
.sd-msel__label { font-size:12px; font-weight:500; color:#64748b; flex:0 0 auto; }
.sd-msel__value { font-size:12px; font-weight:600; color:#0f172a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:160px; }
.sd-msel__chev { color:#94a3b8; flex:0 0 auto; }
.sd-msel__panel { position:absolute; top:calc(100% + 6px); right:0; z-index:40; width:min(320px,80vw); max-height:320px; display:flex; flex-direction:column; background:#fff; border:1px solid #e2e8f0; border-radius:10px; box-shadow:0 12px 28px #0f172a1f; overflow:hidden; }
.sd-msel__search { padding:10px 10px 6px; }
.sd-msel__search input { width:100%; height:32px; border:1px solid #e2e8f0; border-radius:8px; padding:0 10px; font-size:12px; box-sizing:border-box; }
.sd-msel__actions { display:flex; gap:8px; padding:0 10px 8px; }
.sd-msel__actions button { border:none; background:#f1f5f9; color:#334155; font-size:12px; font-weight:600; height:26px; padding:0 10px; border-radius:999px; cursor:pointer; }
.sd-msel__list { list-style:none; margin:0; padding:0 0 8px; overflow:auto; flex:1; }
.sd-msel__opt { width:100%; display:flex; align-items:flex-start; gap:8px; padding:8px 12px; border:none; background:transparent; cursor:pointer; text-align:left; }
.sd-msel__opt:hover { background:#f8fafc; }.sd-msel__opt.is-on { background:#eff6ff; }
.sd-msel__check { width:16px; height:16px; border-radius:4px; border:1px solid #cbd5e1; display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto; margin-top:1px; color:#fff; background:#fff; }
.sd-msel__opt.is-on .sd-msel__check { background:#2563eb; border-color:#2563eb; }
.sd-msel__name { font-size:12px; color:#334155; line-height:1.35; }.sd-msel__empty { padding:16px; text-align:center; color:#94a3b8; font-size:12px; }
.sd-dual { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:12px; margin-top:16px; }
.sd-dual--cash { grid-template-columns:minmax(0,1fr) minmax(0,1fr); }
.sd-panel--grow { min-width:0; }
.sd-table-scroll--cash-lines { max-height:420px; overflow:auto; }
.sd-bi-table--ledger { width:100%; min-width:520px; }
.sd-bi-table--ledger td:first-child,.sd-bi-table--ledger th:first-child { white-space:normal; word-break:break-word; max-width:160px; }
.sd-more-btn { display:inline-flex; align-items:center; justify-content:center; gap:4px; width:100%; margin-top:10px; height:32px; border:1px dashed #cbd5e1; border-radius:8px; background:#f8fafc; color:#475569; font-size:12px; font-weight:600; cursor:pointer; }
.sd-more-btn:hover { border-color:#93c5fd; color:#2563eb; background:#eff6ff; }
.sd-pending-row td { height:110px; color:#94a3b8; text-align:center; font-size:12px; background:#fff; }
@media (max-width:767px) { .sd-detail-top__tools{width:100%;}.sd-detail-top__tools .sd-date{flex:1;}.sd-detail-top__tools .sd-date__trigger{width:100%;justify-content:space-between;}.sd-panel__head-row{flex-direction:column;align-items:stretch;}.sd-msel__trigger{max-width:none;width:100%;}.sd-msel__panel{left:0;right:0;width:auto;}.sd-trend__date{font-size:9px;} }
@media (max-width:1024px) { .sd-dual--cash { grid-template-columns:minmax(0,1fr); } }
.sd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 30px;
min-height: 30px;
padding: 0 11px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--sd-line);
background: #fff;
color: #1e293b;
}
.sd-btn--ghost {
background: #fff;
border-color: var(--sd-line);
color: var(--sd-muted);
}
.sd-btn--ghost:hover {
border-color: #2563eb59;
color: #2563eb;
}
.sd-hero-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.sd-hero-kpi {
position: relative;
background: #fff;
border: 1px solid var(--sd-line);
border-radius: 10px;
padding: 10px 12px;
box-shadow: none;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.sd-hero-kpi--click {
width: 100%;
text-align: left;
cursor: pointer;
}
.sd-hero-kpi--click:hover,
.sd-station-row:hover {
border-color: #93c5fd;
box-shadow: 0 0 0 2px #2563eb14;
}
.sd-hero-kpi__label {
font-size: 12px;
font-weight: 500;
color: var(--sd-muted);
margin-bottom: 4px;
}
.sd-hero-kpi__value {
font-size: 20px;
font-weight: 700;
color: #0f172a;
font-family: var(--sd-mono);
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.02em;
margin-bottom: 6px;
display: flex;
align-items: baseline;
gap: 2px;
}
.sd-hero-kpi__sub {
margin-top: auto;
font-size: 11px;
color: var(--sd-muted);
font-family: var(--sd-mono);
font-variant-numeric: tabular-nums;
background: #f8fafc;
border-radius: 6px;
padding: 4px 8px;
}
.sd-share-panel {
background: #fff;
border: 1px solid var(--sd-line);
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 12px;
}
.sd-share-panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sd-share-panel__title,
.sd-section-title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #1e293b;
}
.sd-share-panel__title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.sd-share-panel__range {
font-size: 12px;
font-weight: 500;
color: var(--sd-muted);
font-family: var(--sd-mono);
font-variant-numeric: tabular-nums;
}
.sd-share-panel__total {
font-size: 11px;
color: var(--sd-muted);
font-family: var(--sd-mono);
}
.sd-share-panel__total strong {
color: var(--sd-ink);
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.sd-share-track {
display: flex;
gap: 2px;
min-height: 44px;
border-radius: 8px;
overflow: hidden;
}
.sd-share-seg {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 8px;
min-width: 48px;
padding: 8px 12px;
border: none;
cursor: pointer;
color: #fff;
text-align: left;
}
.sd-share-seg--0 {
background: #2563eb;
}
.sd-share-seg--1 {
background: #0ea5e9;
}
.sd-share-seg--2 {
background: #6366f1;
}
.sd-share-seg--3 {
background: #8b5cf6;
}
.sd-share-seg--4 {
background: #f59e0b;
}
.sd-share-seg__pct {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
font-family: var(--sd-mono);
line-height: 1;
flex: 0 0 auto;
}
.sd-share-seg__name {
font-size: 12px;
font-weight: 500;
opacity: 0.92;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sd-share-legend {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 0;
row-gap: 6px;
}
.sd-share-legend > li:nth-child(odd) {
padding-right: 18px;
border-right: 1px solid var(--sd-line);
}
.sd-share-legend > li:nth-child(even) {
padding-left: 18px;
}
.sd-share-legend__btn {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
border-radius: 8px;
}
.sd-share-legend__swatch {
width: 10px;
height: 10px;
border-radius: 3px;
flex: 0 0 auto;
}
.sd-share-legend__rank {
min-width: 12px;
color: var(--sd-muted);
font-family: var(--sd-mono);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.sd-share-legend__name {
flex: 0 1 auto;
min-width: 0;
max-width: min(22vw, 240px);
font-size: 12px;
font-weight: 600;
color: #334155;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sd-share-legend__val {
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: var(--sd-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.sd-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.sd-board-mode {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sd-board-mode > button[role="tab"] {
height: 28px;
min-height: 28px;
padding: 0 12px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
color: #64748b;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.sd-board-mode > button[role="tab"].is-on {
border-color: #2563eb;
color: #1d4ed8;
background: #eff6ff;
}
.sd-board-station-select {
height: 28px;
min-height: 28px;
border: 1px solid #cbd5e1;
border-radius: 7px;
padding: 0 8px;
font-size: 12px;
color: #0f172a;
background: #fff;
max-width: 180px;
}
.sd-station-single-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.sd-station-groups {
display: flex;
flex-direction: column;
gap: 18px;
}
.sd-station-group {
min-width: 0;
}
.sd-station-group__head {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
}
.sd-station-group__head h3 {
margin: 0;
color: #334155;
font-size: 13px;
font-weight: 700;
}
.sd-station-group__head span {
color: #94a3b8;
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sd-station-group__empty {
display: flex;
align-items: center;
min-height: 52px;
margin: 0;
padding: 0 14px;
border: 1px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: #94a3b8;
font-size: 12px;
font-weight: 500;
}
.sd-station-row {
width: 100%;
border: 1px solid var(--sd-line);
border-radius: 10px;
background: #fff;
padding: 12px 14px;
cursor: pointer;
text-align: left;
}
.sd-station-row__main {
display: grid;
grid-template-columns:
auto minmax(120px, 1.2fr) minmax(100px, 0.8fr) minmax(280px, 2fr)
auto;
gap: 12px 16px;
align-items: center;
}
.sd-station-card__icon {
flex: 0 0 auto;
width: 34px;
height: 34px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #eff6ff;
color: #2563eb;
}
.sd-station-card__name {
font-size: 14px;
font-weight: 700;
color: var(--sd-ink);
line-height: 1.35;
}
.sd-station-card__region {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 2px;
font-size: 12px;
font-weight: 500;
color: var(--sd-muted);
}
.sd-spark {
display: flex;
align-items: flex-end;
gap: 3px;
height: 48px;
padding: 4px 2px 0;
}
.sd-spark--empty {
align-items: center;
justify-content: center;
border-radius: 6px;
background: #f8fafc;
border: 1px dashed #e2e8f0;
color: #94a3b8;
font-size: 11px;
font-weight: 500;
}
.sd-spark--empty:after {
content: "本区间无加氢量";
}
.sd-spark__col {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
}
.sd-spark__bar {
width: 100%;
border-radius: 3px 3px 1px 1px;
background: linear-gradient(180deg, #60a5fa, #2563eb);
min-height: 3px;
}
.sd-station-row__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.sd-station-row__metrics > div {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sd-station-card__m-label {
font-size: 11px;
font-weight: 500;
color: var(--sd-muted);
margin-bottom: 2px;
}
.sd-station-row__metrics strong {
font-size: 13px;
font-weight: 700;
font-family: var(--sd-mono);
font-variant-numeric: tabular-nums;
color: #0f172a;
}
.sd-station-row__metrics strong span {
margin-left: 2px;
font-size: 11px;
font-weight: 500;
color: var(--sd-muted);
font-family: var(--sd-font);
}
.sd-station-card__share {
font-size: 12px;
font-weight: 700;
color: #2563eb;
font-variant-numeric: tabular-nums;
font-family: var(--sd-mono);
}
.sd-station-card__go {
width: 32px;
height: 32px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--sd-cyan);
background: var(--sd-cyan-soft);
}
@media (max-width: 900px) {
.sd-hero-kpis {
grid-template-columns: 1fr 1fr;
}
.sd-station-row__main {
display: flex;
flex-wrap: wrap;
}
.sd-station-row__main > .sd-spark {
flex: 1 1 140px;
}
.sd-station-row__metrics {
flex: 1 1 100%;
}
}
@media (max-width: 560px) {
.sd-hero-kpis {
grid-template-columns: 1fr;
}
.sd-hero-kpi__value {
font-size: 18px;
}
.sd-station-row__metrics {
grid-template-columns: 1fr 1fr;
}
.sd-share-legend {
grid-template-columns: 1fr;
}
}
/* 触屏窄屏:保留 2×2 指标密度,并将时间与站点操作变成明确的纵向分组。 */
@media (max-width: 560px) {
.sd-topbar,
.sd-topbar--embedded {
align-items: stretch;
gap: 10px;
}
.sd-topbar__updated {
margin: 0;
font-size: 11px;
}
.sd-topbar__tools {
width: 100%;
align-items: center;
gap: 8px;
}
.sd-date {
min-width: 0;
flex: 1 1 220px;
}
.sd-date__trigger {
width: 100%;
min-width: 0;
min-height: 40px;
height: 40px;
justify-content: flex-start;
padding: 0 8px;
}
.sd-date__label {
display: none;
}
.sd-date__value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.sd-date__popover,
.sd-date--right .sd-date__popover {
position: fixed;
right: 12px;
/* Shell 的移动端底部导航为固定层,日期弹层必须避开它。 */
bottom: calc(72px + env(safe-area-inset-bottom));
left: 12px;
top: auto;
width: auto;
max-width: none;
max-height: calc(100dvh - 84px);
overflow: auto;
}
.sd-date__shortcuts button,
.sd-date__range-fields input,
.sd-btn {
min-height: 40px;
height: 40px;
}
.sd-hero-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.sd-hero-kpi {
min-height: 88px;
padding: 10px;
}
.sd-hero-kpi__label,
.sd-hero-kpi__sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sd-hero-kpi__value {
font-size: 18px;
}
.sd-share-panel {
padding: 12px;
}
.sd-share-panel__head {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.sd-share-panel__total {
align-self: flex-end;
}
.sd-section-head {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.sd-board-mode {
width: 100%;
}
.sd-board-mode > button[role="tab"] {
flex: 0 0 auto;
min-height: 40px;
}
.sd-board-station-select {
min-height: 40px;
max-width: 100%;
flex: 1 1 140px;
}
.sd-station-row {
min-height: 56px;
padding: 12px;
}
.sd-station-card__name {
font-size: 13px;
}
}
@media (hover: none) and (pointer: coarse) {
.sd-date__trigger:active,
.sd-btn:active,
.sd-hero-kpi--click:active,
.sd-share-seg:active,
.sd-share-legend__btn:active,
.sd-station-row:active,
.sd-board-mode > button[role="tab"]:active {
transform: scale(0.985);
filter: brightness(0.98);
}
}
@media (prefers-reduced-motion: reduce) {
.sd-date__trigger,
.sd-btn,
.sd-hero-kpi--click,
.sd-share-seg,
.sd-share-legend__btn,
.sd-station-row,
.sd-board-mode > button[role="tab"] {
transition: none !important;
animation: none !important;
}
}
@media (max-width: 359px) {
.sd-hero-kpis {
grid-template-columns: 1fr;
}
}
@@ -1,180 +0,0 @@
/* Scoped to the live daily table; keep the other boards and drill dialogs intact. */
.ehb-real-daily-detail {
min-width: 0;
}
.ehb-daily-detail-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 16px;
margin: 12px 0;
}
.ehb-daily-view-switch {
display: inline-flex;
padding: 3px;
background: #f1f5f9;
border-radius: 9px;
}
.ehb-real-daily-detail button,
.ehb-real-daily-detail select { font: inherit; }
.ehb-daily-view-switch button,
.ehb-daily-collapse,
.ehb-daily-branch-state button,
.ehb-daily-detail-empty button {
min-height: 40px;
border: 0;
padding: 8px 12px;
border-radius: 7px;
background: transparent;
color: #475569;
cursor: pointer;
font-size: 13px;
}
.ehb-daily-view-switch button[aria-pressed="true"] {
background: #fff;
color: #1d4ed8;
box-shadow: 0 1px 4px #0f172a14;
font-weight: 600;
}
.ehb-daily-date-jump { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #64748b; }
.ehb-daily-date-jump select { min-height: 40px; border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; color: #334155; }
.ehb-real-daily-detail button:disabled { opacity: .45; cursor: default; }
.ehb-real-daily-detail :is(button, select, [tabindex]):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.ehb-real-daily-detail .ehb-daily-disclosure {
display: flex;
align-items: flex-start;
gap: 5px;
width: 100%;
min-height: 44px;
padding: 6px 0;
border: 0;
background: none;
color: inherit;
text-align: left;
line-height: 1.5;
cursor: pointer;
}
.ehb-daily-disclosure > svg { flex-shrink: 0; margin-top: 3px; color: #64748b; }
.ehb-daily-disclosure > span { min-width: 0; }
.ehb-daily-disclosure[aria-expanded="true"] { font-weight: 600; }
.ehb-daily-disclosure[aria-expanded="true"] > svg { color: #2563eb; }
.ehb-daily-level-label,
.ehb-daily-change { display: block; font-size: 11px; font-weight: 400; color: #64748b; line-height: 1.6; }
.ehb-daily-cost { font-weight: 500; }
.ehb-daily-branch-state > td > div { display: flex; align-items: center; gap: 8px; }
.ehb-daily-branch-state button { display: inline-flex; align-items: center; gap: 5px; color: #1d4ed8; background: #eff6ff; }
.ehb-daily-detail-empty { padding: 24px 16px; text-align: center; color: #64748b; font-size: 13px; }
.ehb-daily-detail-empty button { display: block; margin: 8px auto 0; color: #1d4ed8; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):is(:nth-child(2), :nth-child(5)) { display: none; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):first-child:not([colspan]) { width: 36%; }
.ehb-real-daily-detail .ehb-table td:not(:first-child) { text-align: right; }
.ehb-real-daily-detail .ehb-table th:not(:first-child) { text-align: right; }
.ehb-real-daily-detail .ehb-table-wrap {
isolation: isolate;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.ehb-real-daily-detail .ehb-table {
table-layout: fixed;
min-width: 940px;
}
.ehb-real-daily-detail .ehb-table tr {
background-color: #fff;
}
.ehb-real-daily-detail .ehb-table th:first-child,
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
position: sticky;
left: 0;
z-index: 2;
width: 260px;
background-color: inherit;
box-shadow: 1px 0 0 #e2e8f0, 5px 0 8px -6px #64748b;
overflow-wrap: anywhere;
white-space: normal;
}
.ehb-real-daily-detail .ehb-table th:first-child {
z-index: 4;
background-color: #f8fafc;
}
.ehb-real-daily-detail .ehb-table th {
z-index: 3;
}
.ehb-real-daily-detail .ehb-table td:not(:first-child) {
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px), (max-width: 1024px) and (max-height: 500px) {
.ehb-real-daily-detail.ehb-daily-table-card {
padding: 12px 0 0;
overflow: hidden;
}
.ehb-real-daily-detail .ehb-daily-table-head {
padding: 0 12px;
gap: 8px;
align-items: flex-start;
}
.ehb-real-daily-detail .ehb-daily-table-title {
font-size: 15px;
line-height: 1.5;
}
.ehb-real-daily-detail .ehb-daily-table-title .ehb-title-sub {
display: none;
}
.ehb-real-daily-detail .ehb-export-btn {
display: inline-flex;
flex-shrink: 0;
min-height: 44px;
padding: 6px 8px;
font-size: 12px;
}
.ehb-real-daily-detail .ehb-daily-detail-toolbar { padding: 0 12px; gap: 8px; }
.ehb-real-daily-detail .ehb-daily-view-switch { width: 100%; }
.ehb-real-daily-detail .ehb-daily-view-switch button { flex: 1; min-height: 44px; }
.ehb-real-daily-detail .ehb-daily-date-jump { flex: 1; }
.ehb-real-daily-detail .ehb-daily-date-jump select { min-height: 44px; }
.ehb-real-daily-detail .ehb-daily-collapse { min-height: 44px; padding: 6px; font-size: 12px; }
.ehb-real-daily-detail .ehb-daily-table-scroll-hint {
display: block;
padding: 8px 12px;
font-size: 11px;
color: #64748b;
background: #f8fafc;
}
.ehb-real-daily-detail .ehb-table-wrap {
max-height: 65dvh;
}
.ehb-real-daily-detail .ehb-table {
min-width: 760px;
font-size: 13px;
}
.ehb-real-daily-detail .ehb-table th:first-child,
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
width: 142px;
}
.ehb-real-daily-detail .ehb-table td {
height: 48px;
padding: 10px 12px;
line-height: 1.5;
box-sizing: border-box;
}
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; width: 100%; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td) { padding: 10px 8px; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table td:not(:first-child) { font-size: 12px; overflow-wrap: anywhere; }
.ehb-real-daily-detail .ehb-table td:first-child:has(.ehb-daily-disclosure) { padding-top: 4px; padding-bottom: 4px; }
.ehb-real-daily-detail .ehb-table th {
padding: 10px 12px;
white-space: normal;
line-height: 1.5;
}
.ehb-real-daily-detail .ehb-table td:first-child .ehb-title-sub {
display: block;
margin-left: 0;
}
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l1 { padding-left: 16px; }
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l2 { padding-left: 22px; }
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l3 { padding-left: 28px; }
.ehb-real-daily-detail .ehb-daily-record-tag {
display: inline-block;
margin: 2px 3px 0 0;
}
}
-297
View File
@@ -1,297 +0,0 @@
export type H2BiScope = "global" | "station";
export type H2BiView = "overview" | "daily";
export type H2BiVehicleScope = "all" | "lingniu" | "external";
export type H2BiVerifyScope = "all" | "verified" | "unverified";
/** 账本 settlement_type 的承担口径;下钻、KPI 与趋势图必须使用同一口径。 */
export type H2BiAmountScope = "all" | "customer" | "company" | "other";
export type H2BiRegionGranularity = "province" | "city";
export interface H2BiQuery {
year: number;
startDate?: string;
endDate?: string;
date?: string;
month?: string;
stationId?: string | number | null;
customerId?: number | null;
customerName?: string | null;
plateNo?: string | null;
region?: string | null;
regionGranularity?: H2BiRegionGranularity;
vehicleScope: H2BiVehicleScope;
verifyScope: H2BiVerifyScope;
}
export interface H2BiRange {
startDate: string | null;
endDate: string | null;
}
export interface H2BiWatermark {
ledgerAt: string | null;
paymentAt: string | null;
}
export interface H2BiStationOption {
id: string | number;
name: string;
}
export interface H2BiMetaResponse {
years: Array<{ value: number; startDate: string; endDate: string }>;
stations: H2BiStationOption[];
watermark: H2BiWatermark;
}
export interface H2BiKpis {
totalKg: number;
totalCost: number;
/** 加氢量:账本 settlement_type=1(客户承担)。 */
customerBearingKg: number;
/** 加氢量:账本 settlement_type=2(我司承担)。 */
companyBearingKg: number;
/** 加氢量:其他承担方式(含客户自行结算、未知)。 */
otherBearingKg: number;
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
customerRevenue: number;
/** 成本金额:账本 settlement_type=1 的 cost_total。 */
customerCost: number;
/** 成本金额:账本 settlement_type=2 的 cost_total。 */
companyCost: number;
/** 成本金额:其他承担方式的 cost_total。 */
otherCost: number;
totalRevenue: number;
customerGrossProfit: number;
monthKg: number;
monthCost: number;
todayKg: number;
todayCost: number;
monthShareOfRange: number;
todayShareOfMonth: number;
recordCount: number;
stationCount: number;
}
export interface H2BiMonthlyPoint {
month: string;
totalKg: number;
lingniuKg: number;
externalKg: number;
cost: number;
customerCost: number;
companyCost: number;
otherCost: number;
revenue: number;
customerRevenue: number;
customerGrossProfit: number;
}
export interface H2BiRegionRow {
region: string;
kg: number;
share: number;
}
export interface H2BiStationRow {
rank?: number;
id: string | number | null;
name: string;
province: string | null;
city: string | null;
kg: number;
lingniuKg: number;
externalKg: number;
cost: number;
revenue: number;
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
customerRevenue: number;
customerCost: number;
companyCost: number;
otherCost: number;
recordCount: number;
customerCount: number;
share: number;
}
export interface H2BiCustomerRow {
rank?: number;
id: number | null;
name: string;
kg: number;
/** 加氢订单中账本 settlement_type=1(客户承担)的量。 */
customerBearingKg: number;
/** 加氢订单中账本 settlement_type=2(我司承担)的量。 */
companyBearingKg: number;
/** 其他承担方式(含客户自行结算、未知)的量。 */
otherBearingKg: number;
bearer: "company" | "customer" | "both" | "other";
cost: number;
revenue: number;
customerRevenue: number;
customerCost: number;
companyCost: number;
otherCost: number;
recordCount: number;
}
export interface H2BiOverviewResponse {
range: H2BiRange;
watermark: H2BiWatermark;
filters: Partial<H2BiQuery>;
kpis: H2BiKpis;
monthly: H2BiMonthlyPoint[];
topStations: H2BiStationRow[];
regions: H2BiRegionRow[];
stations: H2BiStationRow[];
customers: H2BiCustomerRow[];
}
export interface H2BiDailyPoint {
date: string;
kg: number;
lingniuKg: number;
externalKg: number;
cost: number;
recordCount: number;
}
export interface H2BiDailyRow extends H2BiDailyPoint {
stationCount?: number;
}
export interface H2BiDailyKpis {
totalKg: number;
totalCost: number;
averageDailyKg: number;
stationCount: number;
activeDays: number;
}
export interface H2BiDailyResponse {
range: H2BiRange;
watermark: H2BiWatermark;
filters: Partial<H2BiQuery>;
kpis: H2BiDailyKpis;
trend: H2BiDailyPoint[];
days: H2BiDailyRow[];
}
export type H2BiDrillMetric =
| "totalKg"
| "totalCost"
| "totalRevenue"
| "customerGrossProfit"
| "monthKg"
| "todayKg"
| "station"
| "customer"
| "day";
export type H2BiDrillGroupBy =
"station" | "customer" | "date" | "vehicle" | "record";
export interface H2BiDrillQuery extends H2BiQuery {
groupBy: H2BiDrillGroupBy;
amountScope?: H2BiAmountScope;
customerId?: number | null;
plateNo?: string | null;
page?: number;
pageSize?: number;
}
export interface H2BiDrillSummary {
label?: string;
value?: number;
count?: number;
[key: string]: string | number | null | undefined;
}
export type H2BiDrillRecord = Record<
string,
string | number | boolean | null | undefined
>;
export interface H2BiDrillGroupRow {
/** Distinct ledger settlement types for this group; never the UI filter. */
settlementTypes?: string | null;
[key: string]: string | number | boolean | null | undefined;
id: string;
name: string;
province: string | null;
city: string | null;
recordCount: number;
stationCount: number;
customerCount: number;
kg: number;
cost: number;
revenue: number;
lingniuKg: number;
externalKg: number;
}
export interface H2BiDrillResponse {
groupBy: H2BiDrillGroupBy;
amountScope: H2BiAmountScope;
filters: Partial<H2BiQuery>;
summary: H2BiDrillSummary;
groups: H2BiDrillGroupRow[];
records: H2BiDrillRecord[];
page: {
page: number;
pageSize: number;
/** Number of rows returned on this page (groups or records). */
itemCount?: number;
hasMore: boolean;
};
}
/** Options shared by paged drill reads and explicit full exports. */
export interface H2BiDrillReadOptions {
signal?: AbortSignal;
}
/**
* A deliberately bounded, complete drill read. This is for exports and
* dedicated detail views only; normal drill tables should use one page.
*/
export interface H2BiFullDrillResponse extends H2BiDrillResponse {
page: H2BiDrillResponse["page"] & {
pagesRead: number;
complete: true;
};
}
export interface H2BiFullDrillOptions extends H2BiDrillReadOptions {
/** Protect the browser from an accidental unbounded export. Default: 250. */
maxPages?: number;
/** Reject instead of retaining an unexpectedly huge complete result. Default: 50000. */
maxRows?: number;
/** API page size, capped by the server at 200. Default: 200. */
pageSize?: number;
}
export interface H2BiDailyTreeCustomer {
id: number;
name: string;
kg: number;
cost: number;
recordCount: number;
}
export interface H2BiDailyTreeStation {
id: string | number;
name: string;
kg: number;
cost: number;
recordCount: number;
customers: H2BiDailyTreeCustomer[];
}
export interface H2BiDailyTreeResponse {
date: string;
stations: H2BiDailyTreeStation[];
}
export type H2BiDailyTreeQuery = Omit<
Partial<H2BiQuery>,
"date" | "startDate" | "endDate" | "month"
>;
@@ -1,375 +0,0 @@
import { useMemo, useState } from 'react';
import { ChevronRight, Download, RefreshCw } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import * as XLSX from 'xlsx';
import TrendBadge from '../../TrendBadge';
import type { HydrogenDailyDetailCustomer, HydrogenDailyDetailStation, HydrogenDailyRow } from '../../types';
import type { DailyDetailState } from '../../HydrogenDaily';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
type DailySortKey = 'date' | 'price' | 'kg' | 'fee' | 'chainPct';
interface DailyDetailTableProps {
rows: HydrogenDailyRow[];
totalKg: number;
totalFee: number;
expanded: Set<string>;
highlightedDate?: string | null;
details: Record<string, DailyDetailState>;
onToggle: (date: string) => void;
onRetryDetail: (date: string) => void;
}
export function DailyDetailTable({
rows,
totalKg,
totalFee,
expanded,
highlightedDate,
details,
onToggle,
onRetryDetail,
}: DailyDetailTableProps) {
const [sortKey, setSortKey] = useState<DailySortKey>('date');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => {
if (key === 'price') return row.totalKg > 0 ? row.totalFee / row.totalKg : 0;
return row[key === 'kg' ? 'totalKg' : key === 'fee' ? 'totalFee' : key];
}), [rows, sortDirection, sortKey]);
const changeSort = (nextKey: DailySortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
return (
<section className="overflow-hidden rounded-[14px] border border-slate-200/70 bg-white shadow-sm">
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
<h2 className="text-[14px] font-bold text-slate-800">
<span className="ml-1 text-[11px] font-normal text-slate-400"> /</span>
</h2>
<div className="flex shrink-0 items-center gap-2">
<span className="hidden text-[11px] font-medium text-slate-400 sm:inline">{rows.length} </span>
<button
type="button"
onClick={() => exportDailyRows(rows, details)}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 text-[11px] font-semibold text-slate-600 hover:bg-slate-50"
>
<Download size={13} /> Excel
</button>
</div>
</div>
<div className="overflow-x-auto">
<div className="min-w-[780px]">
<div className="grid grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 bg-slate-50 px-3 py-2 text-[11px] font-medium text-slate-500">
<span><SortableColumnHeader label="日期 / 加氢站" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></span>
<span className="text-right"><SortableColumnHeader label="单价 (元/Kg)" sortKey="price" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
<span className="text-right"><SortableColumnHeader label="加氢量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
<span className="text-right"><SortableColumnHeader label="成本 / 环比" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
<span className="text-right"></span>
</div>
<div className="grid grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 bg-blue-50/60 px-3 py-2 text-[12px] font-semibold text-blue-700">
<span></span>
<span />
<span className="text-right font-mono tabular-nums">{formatNumber(totalKg, 2)}</span>
<span className="text-right font-mono tabular-nums">¥{formatNumber(totalFee, 0)}</span>
<span className="text-right text-[10px] font-medium text-slate-400"></span>
</div>
{sortedRows.map(row => {
const open = expanded.has(row.date);
const highlighted = highlightedDate === row.date;
const abnormal = Math.abs(row.chainPct) >= 0.3;
const background = highlighted
? 'bg-sky-50 ring-1 ring-inset ring-sky-200'
: abnormal
? row.chainPct > 0 ? 'bg-emerald-50/35' : 'bg-red-50/35'
: '';
return (
<div key={row.date} id={`hydrogen-daily-row-${row.date}`} className={`border-t border-slate-100 ${background}`}>
<button
type="button"
onClick={() => onToggle(row.date)}
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60"
>
<span className="flex items-center gap-1.5 text-[12px] font-semibold text-slate-700">
<ChevronRight size={14} className={`text-sky-600 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="font-mono tabular-nums">{row.date}</span>
<span className="text-[10px] font-normal text-slate-400">({row.stations.length} )</span>
</span>
<span className="text-right text-[12px] text-slate-300"></span>
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-800">{formatNumber(row.totalKg, 2)}</span>
<span className="text-right"><TrendBadge value={row.chainPct} /></span>
<span className="text-right text-[10px] text-slate-400"></span>
</button>
<AnimatePresence initial={false}>
{open ? (
<StationRows
date={row.date}
fallbackStations={row.stations}
detail={details[row.date]}
onRetry={() => onRetryDetail(row.date)}
sortKey={sortKey}
sortDirection={sortDirection}
/>
) : null}
</AnimatePresence>
</div>
);
})}
</div>
</div>
</section>
);
}
interface StationRowsProps {
date: string;
fallbackStations: HydrogenDailyRow['stations'];
detail?: DailyDetailState;
onRetry: () => void;
sortKey: DailySortKey;
sortDirection: SortDirection;
}
function StationRows({ date, fallbackStations, detail, onRetry, sortKey, sortDirection }: StationRowsProps) {
const [openStations, setOpenStations] = useState<Set<string>>(new Set());
const [openCustomers, setOpenCustomers] = useState<Set<string>>(new Set());
const stations = detail?.data?.stations;
const sortedStations = useMemo(() => sortBy(stations ?? [], sortKey, sortDirection, (station, key) => dailyDetailValue(station, key)), [sortDirection, sortKey, stations]);
const toggleStation = (station: HydrogenDailyDetailStation) => {
const key = `${date}:${station.id}`;
setOpenStations(previous => toggleSet(previous, key));
};
const toggleCustomer = (station: HydrogenDailyDetailStation, customer: HydrogenDailyDetailCustomer) => {
const key = `${date}:${station.id}:${customer.id}:${customer.name}`;
setOpenCustomers(previous => toggleSet(previous, key));
};
return (
<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/60"
>
{detail?.loading ? <DetailStatus label="正在读取客户和车辆明细" /> : null}
{detail?.error ? (
<div className="flex items-center justify-between px-9 py-3 text-[11px] text-rose-600">
<span></span>
<button type="button" onClick={onRetry} className="inline-flex items-center gap-1 font-semibold"><RefreshCw size={12} /></button>
</div>
) : null}
{!detail ? <DetailStatus label={`正在准备 ${fallbackStations.length} 个站点明细`} /> : null}
{stations?.length === 0 ? (
<div className="px-9 py-3 text-[11px] text-slate-400"></div>
) : sortedStations.map((station, index) => {
const stationKey = `${date}:${station.id}`;
const stationOpen = openStations.has(stationKey);
return (
<div key={`${station.id}-${station.name}-${index}`} className="border-t border-slate-100 first:border-t-0">
<button
type="button"
onClick={() => toggleStation(station)}
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] items-center gap-3 px-3 py-2 pl-9 text-left hover:bg-slate-100/80"
>
<div className="flex min-w-0 items-center gap-1.5">
<ChevronRight size={13} className={`shrink-0 text-sky-600 transition-transform ${stationOpen ? 'rotate-90' : ''}`} />
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-slate-700" title={station.name}>{station.name}</div>
<div className="mt-0.5 text-[10px] text-slate-400">{station.customers.length} · {formatStationType(station.stationType)}</div>
</div>
</div>
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-600">{station.kg > 0 ? formatNumber(station.fee / station.kg, 2) : '—'}</span>
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-800">{formatNumber(station.kg, 2)}</span>
<span className="text-right font-mono text-[11px] font-semibold tabular-nums text-emerald-700">¥{formatNumber(station.fee, 0)}</span>
<span
className={`text-right font-mono text-[10px] tabular-nums ${station.balance === null ? 'text-slate-400' : 'font-semibold text-slate-700'}`}
title={station.balanceEffectiveTime ? `余额记录时间:${station.balanceEffectiveTime}` : '数据源暂无该站点余额记录'}
>
{station.balance === null ? '暂无记录' : `¥${formatNumber(station.balance, 2)}`}
</span>
</button>
<AnimatePresence initial={false}>
{stationOpen ? (
<CustomerRows
date={date}
station={station}
openCustomers={openCustomers}
onToggle={customer => toggleCustomer(station, customer)}
sortKey={sortKey}
sortDirection={sortDirection}
/>
) : null}
</AnimatePresence>
</div>
);
})}
</motion.div>
);
}
function CustomerRows({
date,
station,
openCustomers,
onToggle,
sortKey,
sortDirection,
}: {
date: string;
station: HydrogenDailyDetailStation;
openCustomers: Set<string>;
onToggle: (customer: HydrogenDailyDetailCustomer) => void;
sortKey: DailySortKey;
sortDirection: SortDirection;
}) {
const sortedCustomers = useMemo(() => sortBy(station.customers, sortKey, sortDirection, (customer, key) => dailyDetailValue(customer, key)), [sortDirection, sortKey, station.customers]);
return (
<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-white"
>
{sortedCustomers.map(customer => {
const customerKey = `${date}:${station.id}:${customer.id}:${customer.name}`;
const customerOpen = openCustomers.has(customerKey);
return (
<div key={customerKey} className="border-t border-slate-100">
<button
type="button"
onClick={() => onToggle(customer)}
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] items-center gap-3 px-3 py-2 pl-14 text-left hover:bg-sky-50/50"
>
<span className="flex min-w-0 items-center gap-1.5">
<ChevronRight size={12} className={`shrink-0 text-slate-400 transition-transform ${customerOpen ? 'rotate-90' : ''}`} />
<span className="truncate text-[11px] font-medium text-slate-700" title={customer.name}>{customer.name}</span>
<span className="shrink-0 text-[10px] text-slate-400">{customer.vehicles.length} </span>
</span>
<span className="text-right text-[11px] text-slate-400"></span>
<span className="text-right font-mono text-[11px] tabular-nums text-slate-700">{formatNumber(customer.kg, 2)}</span>
<span className="text-right font-mono text-[11px] tabular-nums text-emerald-700">¥{formatNumber(customer.fee, 0)}</span>
<span className="text-right text-[11px] text-slate-300"></span>
</button>
<AnimatePresence initial={false}>
{customerOpen ? <VehicleRows customer={customer} sortKey={sortKey} sortDirection={sortDirection} /> : null}
</AnimatePresence>
</div>
);
})}
</motion.div>
);
}
function VehicleRows({ customer, sortKey, sortDirection }: { customer: HydrogenDailyDetailCustomer; sortKey: DailySortKey; sortDirection: SortDirection }) {
const sortedVehicles = useMemo(() => sortBy(customer.vehicles, sortKey, sortDirection, (vehicle, key) => dailyDetailValue(vehicle, key)), [customer.vehicles, sortDirection, sortKey]);
return (
<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/80 px-3 py-1.5 pl-[74px]"
>
{sortedVehicles.map(vehicle => (
<div key={vehicle.id} className="grid grid-cols-[minmax(176px,1fr)_110px_120px_110px_100px] items-center gap-3 border-t border-slate-100 py-1.5 first:border-t-0">
<div className="flex min-w-0 items-center gap-2 text-[10px]">
<span className="font-mono tabular-nums text-slate-400">{vehicle.time}</span>
<span className="truncate font-semibold text-slate-700">{vehicle.plateNo}</span>
<span className={`rounded px-1 py-0.5 font-medium ${vehicle.vehicleScope === 'lingniu' ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-700'}`}>
{vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部'}
</span>
</div>
<div className="flex min-w-0 justify-end gap-1 text-[9px]">
<span className="max-w-[72px] truncate rounded bg-white px-1 py-0.5 text-slate-500" title={vehicle.source}>{vehicle.source}</span>
<span className="rounded bg-white px-1 py-0.5 text-slate-500">{formatVerifyStatus(vehicle.verifyStatus)}</span>
</div>
<span className="text-right font-mono text-[10px] font-semibold tabular-nums text-slate-700">{formatNumber(vehicle.kg, 3)}</span>
<span className="text-right font-mono text-[10px] tabular-nums text-emerald-700">¥{formatNumber(vehicle.fee, 2)}</span>
<span className="text-right text-[10px] text-slate-300"></span>
</div>
))}
</motion.div>
);
}
function dailyDetailValue(
row: HydrogenDailyDetailStation | HydrogenDailyDetailCustomer | HydrogenDailyDetailCustomer['vehicles'][number],
key: DailySortKey,
) {
if (key === 'date') return 'time' in row ? `${row.time} ${row.plateNo}` : 'name' in row ? row.name : '';
if (key === 'price') return row.kg > 0 ? row.fee / row.kg : 0;
if (key === 'chainPct') return row.kg;
return row[key];
}
function DetailStatus({ label }: { label: string }) {
return <div className="px-9 py-3 text-[11px] text-slate-400">{label}</div>;
}
function toggleSet(previous: Set<string>, key: string) {
const next = new Set(previous);
next.has(key) ? next.delete(key) : next.add(key);
return next;
}
function formatVerifyStatus(value: string) {
const normalized = value.toUpperCase();
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核验';
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
return '待核验';
}
function formatStationType(value: string) {
const normalized = value.toLowerCase();
if (normalized === 'self' || normalized === 'internal') return '自营站';
if (normalized === 'external' || normalized === 'partner') return '合作站';
return '加氢站';
}
function exportDailyRows(rows: HydrogenDailyRow[], details: Record<string, DailyDetailState>) {
const workbook = XLSX.utils.book_new();
const summaryRows = rows.flatMap(row => row.stations.length > 0
? row.stations.map(station => ({
日期: row.date,
加氢站: station.name,
单价元每Kg: station.pricePerKg,
加氢量Kg: station.kg,
成本元: station.fee,
日环比: row.chainPct,
}))
: [{ 日期: row.date, : '', 单价元每Kg: 0, 加氢量Kg: row.totalKg, 成本元: row.totalFee, 日环比: row.chainPct }]);
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(summaryRows), '日报汇总');
const recordRows = Object.values(details).flatMap(state => {
if (!state.data) return [];
return state.data.stations.flatMap(station => station.customers.flatMap(customer => (
customer.vehicles.map(vehicle => ({
日期: state.data?.date,
时间: vehicle.time,
加氢站: station.name,
客户: customer.name,
车牌: vehicle.plateNo,
车辆归属: vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部',
来源: vehicle.source,
核验状态: formatVerifyStatus(vehicle.verifyStatus),
单价元每Kg: vehicle.unitPrice,
加氢量Kg: vehicle.kg,
成本元: vehicle.fee,
}))
)));
});
if (recordRows.length > 0) XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(recordRows), '已下钻流水');
const start = rows.at(-1)?.date ?? '开始';
const end = rows[0]?.date ?? '结束';
XLSX.writeFile(workbook, `氢能按日_${start}_${end}.xlsx`);
}
function formatNumber(value: number, digits: number): string {
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
@@ -1,123 +0,0 @@
import { Fuel, TrendingUp, Truck, Zap } from 'lucide-react';
import type { HydrogenDailyVehicleScope } from '../model';
interface DailyKpiGridProps {
rangeLabel: string;
rangeText: string;
totalKg: number;
activeDays: number;
dayCount: number;
averageKg: number;
stationCount: number;
vehicleScope: HydrogenDailyVehicleScope;
lingniuKg: number;
externalKg: number;
selectedStationName?: string;
}
const VEHICLE_LABEL: Record<HydrogenDailyVehicleScope, string> = {
all: '全部车辆',
lingniu: '羚牛车辆',
external: '外部车辆',
};
export function DailyKpiGrid({
rangeLabel,
rangeText,
totalKg,
activeDays,
dayCount,
averageKg,
stationCount,
vehicleScope,
lingniuKg,
externalKg,
selectedStationName,
}: DailyKpiGridProps) {
return (
<section className="grid grid-cols-2 gap-2.5 md:grid-cols-4 md:gap-3">
<KpiCard
icon={Fuel}
title={`${rangeLabel}加氢量`}
value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })}
unit="Kg"
helper={rangeText}
tone="blue"
/>
<KpiCard
icon={Truck}
title="车辆结构"
value={VEHICLE_LABEL[vehicleScope]}
helper={vehicleScope === 'all'
? `羚牛 ${formatKg(lingniuKg)} · 外部 ${formatKg(externalKg)}`
: `仅统计${VEHICLE_LABEL[vehicleScope]}`}
tone="green"
smallValue
/>
<KpiCard
icon={TrendingUp}
title="有效天数"
value={activeDays}
unit="天"
helper={`区间 ${dayCount} 天 · 日均 ${averageKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`}
tone="amber"
/>
<KpiCard
icon={Zap}
title={selectedStationName ? '当前加氢站' : '涉及加氢站'}
value={selectedStationName ?? stationCount}
unit={selectedStationName ? undefined : '站'}
helper={selectedStationName ?? '按区间明细站点去重'}
tone="purple"
smallValue={Boolean(selectedStationName)}
/>
</section>
);
}
function formatKg(value: number): string {
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`;
}
function KpiCard({
icon: Icon,
title,
value,
unit,
helper,
tone,
smallValue = false,
}: {
icon: typeof Fuel;
title: string;
value: string | number;
unit?: string;
helper: string;
tone: 'blue' | 'green' | 'amber' | 'purple';
smallValue?: boolean;
}) {
const toneClass = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-emerald-50 text-emerald-600',
amber: 'bg-amber-50 text-amber-600',
purple: 'bg-violet-50 text-violet-600',
}[tone];
return (
<article className="min-w-0 rounded-[10px] border border-slate-200/70 bg-white px-3 py-3 shadow-sm md:px-4">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[12px] font-medium text-slate-500">{title}</span>
<span className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${toneClass}`}>
<Icon size={14} />
</span>
</div>
<div className="mt-1.5 flex min-w-0 items-baseline gap-1 font-mono tabular-nums text-slate-900">
<strong className={`${smallValue ? 'truncate text-[17px] md:text-[19px]' : 'text-[23px] md:text-[25px]'} leading-tight font-extrabold`}>
{value}
</strong>
{unit ? <span className="shrink-0 text-[11px] font-semibold text-slate-400">{unit}</span> : null}
</div>
<p className="mt-1 truncate font-mono text-[10px] font-medium text-slate-400" title={helper}>{helper}</p>
</article>
);
}
@@ -1,167 +0,0 @@
import {
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenDailyRow } from '../../types';
import type { HydrogenDailyTrendPoint } from '../model';
interface DailyTrendChartProps {
rows: HydrogenDailyTrendPoint[];
averageKg: number;
peakDay: HydrogenDailyRow | null;
lowDay: HydrogenDailyRow | null;
zeroDays: number;
selectedDate?: string | null;
onSelectDate?: (date: string) => void;
}
export function DailyTrendChart({
rows,
averageKg,
peakDay,
lowDay,
zeroDays,
selectedDate,
onSelectDate,
}: DailyTrendChartProps) {
const selectActiveDate = (state: { activeLabel?: string | number } | null) => {
if (state?.activeLabel) onSelectDate?.(String(state.activeLabel));
};
return (
<section className="rounded-[14px] border border-slate-200/70 bg-white p-3.5 shadow-sm md:p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h2 className="text-[14px] font-bold text-slate-800">
<span className="ml-1 text-[11px] font-normal text-slate-400"></span>
</h2>
</div>
<div className="flex items-center justify-between gap-4 md:justify-end">
<div className="flex items-center gap-3 text-[11px] font-medium text-slate-600">
<Legend color="#0284c7" label="羚牛车辆" />
<Legend color="#f59e0b" label="外部车辆" />
</div>
<span className="shrink-0 text-[10px] font-medium text-slate-400"> · Kg</span>
</div>
</div>
<div className="mt-3 flex gap-4 rounded-md bg-slate-50 px-3 py-2 text-[11px] text-slate-500">
<TrendFact label="峰值日" value={peakDay ? `${peakDay.date.slice(5)} · ${formatCompact(peakDay.totalKg)}` : '—'} />
<TrendFact label="低谷日" value={lowDay ? `${lowDay.date.slice(5)} · ${formatCompact(lowDay.totalKg)}` : '—'} />
<TrendFact label="零数日" value={`${zeroDays}`} valueClass={zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'} />
</div>
<div className="mt-2 overflow-x-auto pb-1">
<div className="h-[230px] min-w-[680px] md:min-w-0">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 680, height: 230 }}
>
<BarChart
data={rows}
margin={{ top: 25, right: 12, bottom: 0, left: -6 }}
onClick={selectActiveDate}
className="cursor-pointer"
>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 4" />
<XAxis
dataKey="date"
tickFormatter={(value: string) => value.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={{ stroke: '#e2e8f0' }}
interval={0}
/>
<YAxis
width={52}
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickFormatter={formatAxis}
/>
<Tooltip
formatter={(value, name) => [
`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`,
name === 'lingniuKg' ? '羚牛车辆' : '外部车辆',
]}
labelFormatter={date => `日期 ${date}`}
contentStyle={{ borderRadius: 8, borderColor: '#e2e8f0', fontSize: 12, boxShadow: '0 10px 30px rgba(15,23,42,.12)' }}
cursor={{ fill: 'rgba(2,132,199,.05)' }}
/>
{averageKg > 0 ? (
<ReferenceLine
y={averageKg}
stroke="#2563eb"
strokeDasharray="5 4"
label={{ value: `均值 ${formatCompact(averageKg)}`, position: 'insideTopLeft', fill: '#1d4ed8', fontSize: 10, fontWeight: 600 }}
/>
) : null}
<defs>
<linearGradient id="dailyLingniuBar" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#38bdf8" />
<stop offset="100%" stopColor="#0284c7" />
</linearGradient>
<linearGradient id="dailyExternalBar" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#fbbf24" />
<stop offset="100%" stopColor="#f59e0b" />
</linearGradient>
</defs>
<Bar dataKey="lingniuKg" stackId="daily" fill="url(#dailyLingniuBar)" maxBarSize={30}>
{rows.map(row => <Cell key={`lingniu-${row.date}`} fill={row.date === selectedDate ? '#0369a1' : 'url(#dailyLingniuBar)'} />)}
</Bar>
<Bar dataKey="externalKg" stackId="daily" fill="url(#dailyExternalBar)" radius={[4, 4, 0, 0]} maxBarSize={30}>
<LabelList
dataKey="totalKg"
position="top"
formatter={value => formatCompact(Number(value ?? 0))}
style={{ fill: '#64748b', fontSize: 9, fontWeight: 600 }}
/>
{rows.map(row => <Cell key={`external-${row.date}`} fill={row.date === selectedDate ? '#d97706' : 'url(#dailyExternalBar)'} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</section>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<span className="h-2 w-2 rounded-sm" style={{ backgroundColor: color }} />
{label}
</span>
);
}
function TrendFact({ label, value, valueClass = 'text-slate-800' }: { label: string; value: string; valueClass?: string }) {
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span>{label}</span>
<strong className={`truncate font-mono tabular-nums ${valueClass}`}>{value}</strong>
</span>
);
}
function formatAxis(value: number): string {
if (value >= 10_000) return `${(value / 10_000).toFixed(value % 10_000 === 0 ? 0 : 1)}`;
if (value >= 1_000) return `${Math.round(value / 1_000)}k`;
return `${Math.round(value)}`;
}
function formatCompact(value: number): string {
return value.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
}
@@ -1,39 +0,0 @@
import { Building2, Gauge, Wallet } from 'lucide-react';
import type { HydrogenDailyStationOption } from '../model';
interface StationDailyOverviewProps {
station: HydrogenDailyStationOption;
totalKg: number;
totalFee: number;
averagePrice: number;
}
export function StationDailyOverview({ station, totalKg, totalFee, averagePrice }: StationDailyOverviewProps) {
return (
<section className="flex flex-col gap-3 rounded-[14px] border border-sky-100 bg-sky-50/50 px-4 py-3 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] bg-white text-sky-600 shadow-sm ring-1 ring-sky-100">
<Building2 size={17} />
</span>
<div className="min-w-0">
<div className="text-[11px] font-semibold text-sky-700"></div>
<h2 className="truncate text-[15px] font-bold text-slate-900" title={station.name}>{station.name}</h2>
</div>
</div>
<div className="grid grid-cols-3 gap-2 md:min-w-[430px]">
<StationFact icon={Gauge} label="区间加氢量" value={`${totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} />
<StationFact icon={Wallet} label="区间成本" value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`} />
<StationFact icon={Gauge} label="成本均价" value={`${averagePrice.toFixed(2)} 元/Kg`} />
</div>
</section>
);
}
function StationFact({ icon: Icon, label, value }: { icon: typeof Gauge; label: string; value: string }) {
return (
<div className="min-w-0 rounded-lg bg-white/90 px-2.5 py-2 ring-1 ring-sky-100/80">
<div className="flex items-center gap-1 text-[10px] font-medium text-slate-400"><Icon size={11} />{label}</div>
<div className="mt-1 truncate font-mono text-[11px] font-bold tabular-nums text-slate-800" title={value}>{value}</div>
</div>
);
}
@@ -2,12 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenDailyRow } from '../types.js';
import {
buildHydrogenDailyTrend,
getQuickRange,
filterHydrogenRowsByStation,
getHydrogenDailyStations,
getRangeModeLabel,
mergeHydrogenDailyRows,
normalizeRange,
summarizeHydrogenRows,
} from './model.js';
@@ -28,43 +24,6 @@ test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天
});
});
test('全部车辆由两类真实日报按日期和站点合并', () => {
const lingniu: HydrogenDailyRow[] = [{
date: '2026-08-11', totalKg: 100, totalFee: 2_400, chainPct: 0, customerType: 'lingniu',
stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 }],
}];
const external: HydrogenDailyRow[] = [{
date: '2026-08-11', totalKg: 50, totalFee: 1_000, chainPct: 0, customerType: 'external',
stations: [{ id: 1, name: 'A站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 }],
}];
const merged = mergeHydrogenDailyRows(lingniu, external)!;
assert.equal(merged[0].totalKg, 150);
assert.equal(merged[0].totalFee, 3_400);
assert.equal(merged[0].stations[0].pricePerKg, 22.67);
assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'all', null), [{
date: '2026-08-11', totalKg: 150, lingniuKg: 100, externalKg: 50,
}]);
});
test('趋势数据同时遵守车辆归属和单站筛选', () => {
const lingniu: HydrogenDailyRow[] = [{
date: '2026-08-11', totalKg: 130, totalFee: 3_000, chainPct: 0, customerType: 'lingniu',
stations: [
{ id: 1, name: 'A站', kg: 80, fee: 1_920, pricePerKg: 24, chainPct: 0 },
{ id: 2, name: 'B站', kg: 50, fee: 1_080, pricePerKg: 21.6, chainPct: 0 },
],
}];
const external: HydrogenDailyRow[] = [{
date: '2026-08-11', totalKg: 20, totalFee: 400, chainPct: 0, customerType: 'external',
stations: [{ id: 1, name: 'A站', kg: 20, fee: 400, pricePerKg: 20, chainPct: 0 }],
}];
assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'lingniu', 1), [{
date: '2026-08-11', totalKg: 80, lingniuKg: 80, externalKg: 0,
}]);
});
test('自定义日期倒置时仅交换查询边界', () => {
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
start: '2026-08-01',
@@ -79,26 +38,23 @@ test('每日加氢统计保持排序、有效天、站点去重和峰谷口径',
{
date: '2026-08-12',
totalKg: 0,
totalFee: 0,
chainPct: -1,
customerType: 'lingniu',
stations: [{ id: 1, name: 'A站', kg: 0, fee: 0, pricePerKg: 0, chainPct: -1 }],
stations: [{ name: 'A站', kg: 0, pricePerKg: 0, chainPct: -1 }],
},
{
date: '2026-08-10',
totalKg: 100,
totalFee: 2_000,
chainPct: 0,
customerType: 'lingniu',
stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_000, pricePerKg: 20, chainPct: 0 }],
stations: [{ name: 'A站', kg: 100, pricePerKg: 20, chainPct: 0 }],
},
{
date: '2026-08-11',
totalKg: 300,
totalFee: 7_500,
chainPct: 2,
customerType: 'lingniu',
stations: [{ id: 2, name: 'B站', kg: 300, fee: 7_500, pricePerKg: 25, chainPct: 2 }],
stations: [{ name: 'B站', kg: 300, pricePerKg: 25, chainPct: 2 }],
},
];
@@ -106,10 +62,8 @@ test('每日加氢统计保持排序、有效天、站点去重和峰谷口径',
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
assert.equal(summary.totalKg, 400);
assert.equal(summary.totalFee, 9_500);
assert.equal(summary.activeDays, 2);
assert.equal(summary.avgKg, 200);
assert.equal(summary.avgPrice, 23.75);
assert.equal(summary.stationCount, 2);
assert.equal(summary.peakDay?.date, '2026-08-11');
assert.equal(summary.lowDay?.date, '2026-08-10');
@@ -120,41 +74,11 @@ test('空数据保持零值且没有峰谷日', () => {
assert.deepEqual(summarizeHydrogenRows(null), {
trendData: [],
totalKg: 0,
totalFee: 0,
activeDays: 0,
stationCount: 0,
avgKg: 0,
avgPrice: 0,
peakDay: null,
lowDay: null,
zeroDays: 0,
});
});
test('站点筛选按站点 ID 重算日报总量、费用和环比', () => {
const rows: HydrogenDailyRow[] = [
{
date: '2026-08-11', totalKg: 150, totalFee: 3_400, chainPct: 0, customerType: 'lingniu',
stations: [
{ id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 },
{ id: 2, name: 'B站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 },
],
},
{
date: '2026-08-10', totalKg: 80, totalFee: 1_760, chainPct: 0, customerType: 'lingniu',
stations: [{ id: 1, name: 'A站', kg: 80, fee: 1_760, pricePerKg: 22, chainPct: 0 }],
},
];
assert.deepEqual(getHydrogenDailyStations(rows), [
{ id: 1, name: 'A站', totalKg: 180, totalFee: 4_160 },
{ id: 2, name: 'B站', totalKg: 50, totalFee: 1_000 },
]);
const filtered = filterHydrogenRowsByStation(rows, 1)!;
assert.deepEqual(filtered.map(row => ({ date: row.date, kg: row.totalKg, fee: row.totalFee, chain: row.chainPct })), [
{ date: '2026-08-11', kg: 100, fee: 2_400, chain: 0.25 },
{ date: '2026-08-10', kg: 80, fee: 1_760, chain: 0 },
]);
assert.equal(summarizeHydrogenRows(filtered).totalFee, 4_160);
});
+3 -170
View File
@@ -14,10 +14,9 @@ export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
const totalFee = source.reduce((total, row) => total + row.totalFee, 0);
const activeDays = source.filter(row => row.totalKg > 0).length;
const stationIds = new Set<number>();
source.forEach(row => row.stations.forEach(station => stationIds.add(station.id)));
const stationNames = new Set<string>();
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
null,
@@ -32,177 +31,11 @@ export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
return {
trendData,
totalKg,
totalFee,
activeDays,
stationCount: stationIds.size,
stationCount: stationNames.size,
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
avgPrice: totalKg > 0 ? totalFee / totalKg : 0,
peakDay,
lowDay,
zeroDays: source.filter(row => row.totalKg === 0).length,
};
}
export interface HydrogenDailyStationOption {
id: number;
name: string;
totalKg: number;
totalFee: number;
}
export type HydrogenDailyVehicleScope = 'all' | 'lingniu' | 'external';
export type HydrogenDailyBoardScope = 'global' | 'station';
export interface HydrogenDailyTrendPoint {
date: string;
totalKg: number;
lingniuKg: number;
externalKg: number;
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/**
*
*
*/
export function mergeHydrogenDailyRows(
lingniuRows: HydrogenDailyRow[] | null,
externalRows: HydrogenDailyRow[] | null,
): HydrogenDailyRow[] | null {
if (lingniuRows === null || externalRows === null) return null;
const rowsByDate = new Map<string, HydrogenDailyRow[]>();
for (const row of [...lingniuRows, ...externalRows]) {
const rows = rowsByDate.get(row.date) ?? [];
rows.push(row);
rowsByDate.set(row.date, rows);
}
const merged = [...rowsByDate.entries()].map(([date, rows]) => {
const stationsById = new Map<number, HydrogenDailyRow['stations'][number]>();
for (const row of rows) {
for (const station of row.stations) {
const current = stationsById.get(station.id);
const kg = round2((current?.kg ?? 0) + station.kg);
const fee = round2((current?.fee ?? 0) + station.fee);
stationsById.set(station.id, {
id: station.id,
name: station.name || current?.name || `站点 #${station.id}`,
kg,
fee,
// 跨车辆归属聚合后展示实际成本加权均价。
pricePerKg: kg > 0 ? round2(fee / kg) : 0,
chainPct: 0,
});
}
}
const stations = [...stationsById.values()].sort((left, right) => right.kg - left.kg);
return {
date,
totalKg: round2(stations.reduce((sum, station) => sum + station.kg, 0)),
totalFee: round2(stations.reduce((sum, station) => sum + station.fee, 0)),
chainPct: 0,
customerType: 'lingniu' as const,
stations,
};
});
return recomputeHydrogenDailyChains(merged);
}
function recomputeHydrogenDailyChains(rows: HydrogenDailyRow[]): HydrogenDailyRow[] {
const ascending = [...rows]
.map(row => ({ ...row, stations: row.stations.map(station => ({ ...station })) }))
.sort((left, right) => left.date.localeCompare(right.date));
let previousTotalKg = 0;
const stationPreviousKg = new Map<number, number>();
for (const row of ascending) {
row.chainPct = previousTotalKg > 0 ? (row.totalKg - previousTotalKg) / previousTotalKg : 0;
previousTotalKg = row.totalKg;
for (const station of row.stations) {
const previousStationKg = stationPreviousKg.get(station.id) ?? 0;
station.chainPct = previousStationKg > 0 ? (station.kg - previousStationKg) / previousStationKg : 0;
stationPreviousKg.set(station.id, station.kg);
}
}
return ascending.sort((left, right) => right.date.localeCompare(left.date));
}
/** Build one stable station selector from the current date range. */
export function getHydrogenDailyStations(rows: HydrogenDailyRow[] | null): HydrogenDailyStationOption[] {
const stations = new Map<number, HydrogenDailyStationOption>();
for (const row of rows ?? []) {
for (const station of row.stations) {
const current = stations.get(station.id);
stations.set(station.id, {
id: station.id,
name: station.name,
totalKg: (current?.totalKg ?? 0) + station.kg,
totalFee: (current?.totalFee ?? 0) + station.fee,
});
}
}
return [...stations.values()].sort((left, right) => right.totalKg - left.totalKg || left.name.localeCompare(right.name));
}
/**
* Daily endpoint returns all stations in the selected date range. This keeps
* station drill-down local and recomputes each day's totals and chain change.
*/
export function filterHydrogenRowsByStation(
rows: HydrogenDailyRow[] | null,
stationId: number | null,
): HydrogenDailyRow[] | null {
if (rows === null || stationId === null) return rows;
const filtered = rows.map(row => {
const stations = row.stations.filter(station => station.id === stationId);
return {
...row,
totalKg: stations.reduce((sum, station) => sum + station.kg, 0),
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
stations,
};
});
return recomputeHydrogenDailyChains(filtered);
}
/** Build the prototype's blue/orange stacked daily series from real queries. */
export function buildHydrogenDailyTrend(
lingniuRows: HydrogenDailyRow[] | null,
externalRows: HydrogenDailyRow[] | null,
vehicleScope: HydrogenDailyVehicleScope,
stationId: number | null,
): HydrogenDailyTrendPoint[] {
if (lingniuRows === null || externalRows === null) return [];
const lingniu = filterHydrogenRowsByStation(lingniuRows, stationId) ?? [];
const external = filterHydrogenRowsByStation(externalRows, stationId) ?? [];
const byDate = new Map<string, HydrogenDailyTrendPoint>();
for (const row of lingniu) {
byDate.set(row.date, {
date: row.date,
lingniuKg: vehicleScope === 'external' ? 0 : row.totalKg,
externalKg: 0,
totalKg: vehicleScope === 'external' ? 0 : row.totalKg,
});
}
for (const row of external) {
const current = byDate.get(row.date) ?? {
date: row.date,
lingniuKg: 0,
externalKg: 0,
totalKg: 0,
};
const externalKg = vehicleScope === 'lingniu' ? 0 : row.totalKg;
current.externalKg = externalKg;
current.totalKg = round2(current.lingniuKg + externalKg);
byDate.set(row.date, current);
}
return [...byDate.values()].sort((left, right) => left.date.localeCompare(right.date));
}
@@ -10,16 +10,7 @@ import {
XAxis,
YAxis,
} from 'recharts';
import { useMemo, useState } from 'react';
import type { HydrogenRegionShare, HydrogenStationFull, HydrogenStationTop } from '../../types';
import {
buildRegionDrillPayload,
buildStationDrillPayload,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
@@ -51,123 +42,70 @@ function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
interface DistributionChartsProps {
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
stations: HydrogenStationFull[];
yearKg: number;
onSelectStation?: (stationId: number) => void;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function DistributionCharts({ top5, regions, stations, yearKg, onSelectStation, scope = 'global', scopeLabel, onDrillRequest }: DistributionChartsProps) {
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const provinceRegions = useMemo(() => {
const totals = new Map<string, number>();
for (const station of stations) {
const province = station.province?.trim() || '未归属';
totals.set(province, (totals.get(province) ?? 0) + station.kg);
}
return [...totals.entries()]
.map(([region, kg]) => ({ region, kg, share: kg / Math.max(1, yearKg) }))
.sort((a, b) => b.kg - a.kg);
}, [stations, yearKg]);
const visibleRegions = regionGranularity === 'province' ? provinceRegions : regions;
const openStation = (stationId: number) => {
const station = stations.find(item => item.id === stationId);
if (!station) return;
setSelectedStationId(station.id);
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
else setDrill(buildStationDrillPayload(station));
};
const openRegion = (region: HydrogenRegionShare) => {
setSelectedStationId(null);
if (onDrillRequest) onDrillRequest({ kind: 'region', key: `${regionGranularity}:${region.region}`, label: region.region });
else setDrill(buildRegionDrillPayload(region, stations, regionGranularity));
};
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
return (
<>
<div className="ehb-two-charts-row">
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title"> Top5{scopeText}</div>
<div className="ehb-chart-box-meta">Kg</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-slate-700"> Top5</span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<div className="ehb-chart-box-body h-[260px]">
<ResponsiveContainer width="100%" height={260} minWidth={0} initialDimension={{ width: 1, height: 260 }}>
<BarChart
data={top5}
layout="vertical"
margin={{ top: 0, right: 24, left: 16, bottom: 0 }}
barSize={10}
className="cursor-pointer"
onClick={(state) => {
const stationId = (state as { activePayload?: { payload?: HydrogenStationTop }[] } | undefined)?.activePayload?.[0]?.payload?.id;
if (stationId) openStation(stationId);
}}
>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
<XAxis type="number" hide />
<YAxis
dataKey="name"
type="category"
axisLine={false}
tickLine={false}
width={180}
dataKey="name"
width={188}
tick={<RankYAxisTick />}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: 'rgba(59, 130, 246, 0.04)' }}
contentStyle={{ borderRadius: '12px', fontSize: '12px', padding: '8px 12px', border: 'none', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}
formatter={(val: unknown) => [`${Number(val ?? 0).toLocaleString('zh-CN')} Kg`, '加氢量']}
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{top5.map((_, i) => (
<Cell key={i} fill="url(#topBarGrad)" />
))}
<LabelList
dataKey="kg"
position="right"
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
fill="#475569"
fontSize={11}
fontWeight={700}
/>
<Bar dataKey="kg" fill="#3b82f6" radius={[0, 4, 4, 0]}>
<LabelList dataKey="kg" position="right" formatter={(v: unknown) => {
const value = Number(v ?? 0);
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
}} fontSize={11} fontWeight={700} fill="#475569" />
</Bar>
<defs>
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{scopeText}</div>
<div className="ehb-mini-tabs">
<button
type="button"
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
onClick={() => setRegionGranularity('province')}
>
</button>
<button
type="button"
className={`ehb-mini-tab ${regionGranularity === 'city' ? 'is-active' : ''}`}
onClick={() => setRegionGranularity('city')}
>
</button>
</div>
</div>
<div className="ehb-chart-box-body h-[260px] flex items-center justify-center">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<span className="text-sm font-bold text-slate-700"></span>
<div className="flex items-center gap-2">
<div className="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={visibleRegions}
data={regions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
className="cursor-pointer outline-none"
onClick={(entry) => openRegion(entry as unknown as HydrogenRegionShare)}
>
{visibleRegions.map((_, i) => (
{regions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
@@ -180,22 +118,16 @@ export function DistributionCharts({ top5, regions, stations, yearKg, onSelectSt
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{visibleRegions.map((r, i) => (
<button key={r.region} type="button" onClick={() => openRegion(r)} className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
{regions.map((r, i) => (
<div key={r.region} className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
<span className="text-slate-600 truncate">{r.region}</span>
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
</button>
</div>
))}
</div>
</div>
</div>
</div>
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
@@ -1,13 +1,6 @@
import { Activity, ChevronDown, Shield, TrendingDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { HydrogenMonthlyPoint, HydrogenStationFull } from '../../types';
import {
buildStationDrillPayload,
formatKg as fmtKg,
type OverviewDrillPayload,
type OverviewDrillRequest,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatKg as fmtKg } from '../model';
interface InsightCardsProps {
monthAvgKg: number;
@@ -15,16 +8,13 @@ interface InsightCardsProps {
latestMonth: HydrogenMonthlyPoint | undefined;
monthMomentum: number | null;
top5Share: number;
customerGrossMarginPct: number;
profitYield: number;
stationAvgKg: number;
stationCount: number;
yearProfitValue: string;
yearProfitUnit: string;
yearRevenueValue: string;
yearRevenueUnit: string;
stations: HydrogenStationFull[];
onSelectStation: (stationId: number) => void;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function InsightCards({
@@ -33,144 +23,66 @@ export function InsightCards({
latestMonth,
monthMomentum,
top5Share,
customerGrossMarginPct,
profitYield,
stationAvgKg,
stationCount,
yearProfitValue,
yearProfitUnit,
yearRevenueValue,
yearRevenueUnit,
stations,
onSelectStation,
onDrillRequest,
}: InsightCardsProps) {
const [rankingOpen, setRankingOpen] = useState(false);
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const rankingRef = useRef<HTMLDivElement>(null);
const highestKg = stations[0]?.kg || 1;
useEffect(() => {
if (!rankingOpen) return;
const closeOnOutsideClick = (event: MouseEvent) => {
if (rankingRef.current && !rankingRef.current.contains(event.target as Node)) setRankingOpen(false);
};
document.addEventListener('mousedown', closeOnOutsideClick);
return () => document.removeEventListener('mousedown', closeOnOutsideClick);
}, [rankingOpen]);
const openStation = (station: HydrogenStationFull) => {
setRankingOpen(false);
setSelectedStationId(station.id);
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
else setDrill(buildStationDrillPayload(station));
};
return (
<>
<div className="ehb-insight" aria-label="经营洞察">
<div className="ehb-insight__card">
<div className="ehb-insight__icon is-down">
<TrendingDown size={18} aria-hidden />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="ehb-insight__title"></div>
<div className="ehb-insight__value is-neg">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
<div className="ehb-insight__desc">
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
</div>
<div
className={`ehb-insight__card ehb-insight__card--rank ${rankingOpen ? 'is-open' : ''}`}
ref={rankingRef}
onClick={() => setRankingOpen((v) => !v)}
style={{ cursor: 'pointer' }}
title="点击查看加氢站加氢量排名"
>
<div className="ehb-insight__icon">
<Shield size={18} aria-hidden />
</div>
<div className="ehb-insight__rank-body">
<div className="ehb-insight__title"></div>
<div className="ehb-insight__value">Top5 {top5Share.toFixed(1)}%</div>
<div className="ehb-insight__desc">
{stationCount} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} ·
</div>
</div>
<ChevronDown
size={14}
className={`ehb-insight__rank-chevron ${rankingOpen ? 'is-open' : ''}`}
aria-hidden
/>
{rankingOpen && (
<div
className="ehb-station-rank-dropdown"
onClick={(e) => e.stopPropagation()}
role="listbox"
aria-label="加氢站加氢量排名"
>
<div className="ehb-station-rank-dropdown__head">
<span></span>
<span className="ehb-station-rank-dropdown__meta">
· {stationCount}
</span>
</div>
<div className="ehb-station-rank-dropdown__list">
{stations.map((station, index) => (
<button
key={`${station.id}-${station.name}-${index}`}
type="button"
className="ehb-station-rank-item"
onClick={() => openStation(station)}
title="点击钻取该站明细"
>
<span className={`ehb-station-rank-item__rank ${index < 3 ? 'is-top' : ''}`}>
{index + 1}
</span>
<span className="ehb-station-rank-item__main">
<span className="ehb-station-rank-item__name">{station.name}</span>
<span className="ehb-station-rank-item__bar">
<span style={{ width: `${Math.max(2, station.kg / highestKg * 100)}%` }} />
</span>
</span>
<span className="ehb-station-rank-item__val">
{fmtKg(station.kg).value} {fmtKg(station.kg).unit}
</span>
<span className="ehb-station-rank-item__share">{(station.share * 100).toFixed(1)}%</span>
</button>
))}
{stations.length === 0 && (
<div className="ehb-station-rank-empty"></div>
)}
</div>
</div>
)}
</div>
<div className="ehb-insight__card">
<div className={`ehb-insight__icon ${customerGrossMarginPct >= 0 ? 'is-ok' : 'is-neg'}`}>
<Activity size={18} aria-hidden />
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="ehb-insight__title"></div>
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
{customerGrossMarginPct.toFixed(1)}%
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
</div>
<div className="ehb-insight__desc">
{yearProfitValue}{yearProfitUnit} · {yearRevenueValue}{yearRevenueUnit}
{customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'}
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{stationCount} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitValue}{yearProfitUnit} · {yearRevenueValue}{yearRevenueUnit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
</div>
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
@@ -1,76 +1,43 @@
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import type { ReactNode } from 'react';
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
import type { HydrogenKpi } from '../../types';
import {
buildMetricDrillPayload,
formatKg as fmtKg,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewMetricKey,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
interface KpiCardProps {
icon: ReactNode;
metricKey: OverviewMetricKey;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string }[];
tone: 'blue' | 'green' | 'amber' | 'purple';
valueClass?: string;
onOpen: (key: OverviewMetricKey) => void;
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
const TONE_CLASS = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-emerald-50 text-emerald-600',
amber: 'bg-amber-50 text-amber-600',
purple: 'bg-violet-50 text-violet-600',
} as const;
function KpiCard({ icon, metricKey, label, hero, rows, tone, onOpen }: KpiCardProps) {
const isYuan = hero.value.startsWith('¥');
const numValue = isYuan ? hero.value.replace('¥', '') : hero.value;
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<div
className={`ehb-kpi-dual is-${tone}`}
onClick={() => onOpen(metricKey)}
title="点击查看真实汇总及可用下钻明细"
>
<div className="ehb-kpi-dual__head">
<span className="ehb-kpi-dual__label">
{label}
<span className="ehb-kpi-drill-hint">
<Search size={10} />
</span>
</span>
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<div className="ehb-kpi-dual__val">
{isYuan && <span className="ehb-kpi-dual__symbol">¥</span>}
<span className="ehb-kpi-dual__num">{numValue}</span>
{hero.unit && <span className="ehb-kpi-dual__unit">{hero.unit}</span>}
<span className="text-[11px] font-bold text-slate-500">{label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
<div className="ehb-kpi-dual__deck">
{rows.map((row) => (
<span key={row.label}>{row.label} {row.value}</span>
))}
</div>
</div>
);
}
interface KpiSectionProps {
kpi: HydrogenKpi;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillRequest }: KpiSectionProps) {
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
@@ -83,22 +50,65 @@ export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillReques
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const openDrill = (key: OverviewMetricKey) => {
if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key });
else setDrill(buildMetricDrillPayload(k, key));
};
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
return (
<>
<div className="ehb-host-kpi" aria-label={`${scope === 'station' ? scopeLabel || '单站' : '全局'}经营指标`}>
<KpiCard metricKey="yearKg" icon={<Fuel size={14} />} tone="blue" label="累计加氢量" hero={yearKgFmt} rows={[{ label: '我司', value: `${ourYearKgFmt.value}${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value}${customerYearKgFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="yearFee" icon={<Wallet size={14} />} tone="blue" label="累计加氢费" hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }} rows={[{ label: '我司', value: `¥${fmtYuan(k.ourYearFee).value}${fmtYuan(k.ourYearFee).unit}` }, { label: '客户', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="yearProfit" icon={<Activity size={14} />} tone="green" label="客户单毛利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[{ label: '收入', value: `¥${yearRevenueFmt.value}${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="monthKg" icon={<Truck size={14} />} tone="amber" label="本月加氢" hero={monthKgFmt} rows={[{ label: '加氢费', value: `¥${monthFeeFmt.value}${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
<KpiCard metricKey="todayKg" icon={<Zap size={14} />} tone="purple" label="本日加氢" hero={todayKgFmt} rows={[{ label: '加氢费', value: `¥${todayFeeFmt.value}${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
<KpiCard
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
iconBg="bg-cyan-50"
accentClass="text-slate-800"
label="累计加氢量"
hero={yearKgFmt}
rows={[
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
]}
/>
<KpiCard
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
iconBg="bg-blue-50"
accentClass="text-slate-800"
label="累计加氢费"
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
rows={[
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
iconBg="bg-emerald-50"
accentClass={profitColor}
label="时享加氢获利"
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
rows={[
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
iconBg="bg-amber-50"
accentClass="text-amber-600"
label="本月加氢"
hero={monthKgFmt}
rows={[
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
<KpiCard
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
iconBg="bg-violet-50"
accentClass="text-violet-600"
label="本日加氢"
hero={todayKgFmt}
rows={[
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
</div>
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}
@@ -1,23 +1,15 @@
import {
Bar,
BarChart,
LabelList,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useState } from 'react';
import type { HydrogenMonthlyPoint } from '../../types';
import {
buildMonthDrillPayload,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
import { formatYuan as fmtYuan } from '../model';
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
@@ -25,36 +17,19 @@ interface MonthlyChartsProps {
activeYear: number;
monthly: HydrogenMonthlyPoint[];
monthlyDual: MonthlyChartPoint[];
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'global', scopeLabel, onDrillRequest }: MonthlyChartsProps) {
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const openMonthPoint = (point: MonthlyChartPoint | undefined) => {
if (!point) return;
if (onDrillRequest) onDrillRequest({ kind: 'month', key: point.month, label: `${point.month} 月度经营明细` });
else setDrill(buildMonthDrillPayload(point));
};
const openMonthFromChart = (state: unknown) => {
openMonthPoint((state as { activePayload?: { payload?: MonthlyChartPoint }[] } | undefined)?.activePayload?.[0]?.payload);
};
const openMonthFromBar = (entry: unknown) => {
openMonthPoint((entry as { payload?: MonthlyChartPoint } | undefined)?.payload);
};
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
return (
<>
{monthly.length > 0 && (
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{activeYear} {scopeText}</div>
<div className="ehb-chart-box-meta">Kg</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<div className="ehb-chart-box-body">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
<ResponsiveContainer width="100%" height={140}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
@@ -69,29 +44,30 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'globa
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Legend verticalAlign="top" height={24} iconSize={8} wrapperStyle={{ fontSize: 11, paddingBottom: 4 }} />
<Bar dataKey="lingniuKg" name="羚牛车辆" stackId="kg" fill="#4ba3df" radius={[0, 0, 0, 0]} onClick={openMonthFromBar} />
<Bar dataKey="externalKg" name="外部车辆" stackId="kg" fill="#f4bb45" radius={[4, 4, 0, 0]} onClick={openMonthFromBar}>
<LabelList dataKey="kg" position="top" formatter={value => {
const total = Number(value ?? 0);
return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total.toFixed(0);
}} fill="#475569" fontSize={10} fontWeight={700} />
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
{monthlyDual.map((_, i) => (
<Cell key={i} fill="url(#monthlyBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{monthly.length > 0 && (
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{activeYear} {scopeText}</div>
<div className="ehb-chart-box-meta"></div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<div className="ehb-chart-box-body">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
<ResponsiveContainer width="100%" height={180}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
@@ -114,14 +90,12 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'globa
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}
@@ -1,165 +0,0 @@
import { ChevronLeft, Database, ExternalLink, Search, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { OverviewDrillPayload } from '../model';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
interface OverviewDrillDialogProps {
payload: OverviewDrillPayload | null;
onClose: () => void;
onPrimaryAction?: () => void;
onGroupByChange?: (groupBy: 'station' | 'customer' | 'vehicle') => void;
onRowSelect?: (row: Record<string, string | number>) => void;
}
const TONE_CLASS = {
default: 'text-slate-900',
blue: 'text-sky-600',
green: 'text-emerald-600',
amber: 'text-amber-600',
red: 'text-rose-600',
} as const;
export function OverviewDrillDialog({ payload, onClose, onPrimaryAction, onGroupByChange, onRowSelect }: OverviewDrillDialogProps) {
const [sortKey, setSortKey] = useState('');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
useEffect(() => {
if (!payload) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', onKeyDown);
};
}, [onClose, payload]);
useEffect(() => {
if (!payload?.columns.length) return;
setSortKey(payload.columns[0].key);
setSortDirection('desc');
}, [payload]);
const sortedRows = useMemo(
() => payload ? sortBy(payload.rows, sortKey, sortDirection, (row, key) => row[key]) : [],
[payload, sortDirection, sortKey],
);
const changeSort = (nextKey: string) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
if (!payload || typeof document === 'undefined') return null;
return createPortal(
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/70 p-3 backdrop-blur-[8px] md:p-5"
onMouseDown={event => {
if (event.target === event.currentTarget) onClose();
}}
role="presentation"
>
<section
className="flex max-h-[90vh] w-full max-w-[1100px] flex-col overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.35)]"
role="dialog"
aria-modal="true"
aria-labelledby="overview-drill-title"
>
<header className="flex items-center justify-between gap-3 bg-slate-900 px-3 py-3 text-white md:px-5 md:py-4">
<div className="flex min-w-0 items-center gap-2.5">
<button type="button" onClick={onClose} className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-white/20 bg-white/10 px-2.5 text-[12px] font-bold text-slate-100 transition hover:border-sky-400 hover:bg-sky-400/15 hover:text-sky-300">
<ChevronLeft size={16} />
<span className="hidden sm:inline"></span>
</button>
<div className="min-w-0">
<h2 id="overview-drill-title" className="flex items-center gap-2 truncate text-[14px] font-bold text-slate-50 md:text-[16px]">
<Search size={16} className="shrink-0 text-sky-300" />
<span className="truncate">{payload.title}</span>
</h2>
<p className="mt-0.5 truncate text-[11px] font-medium text-slate-400 md:text-[12px]">{payload.subtitle}</p>
</div>
</div>
<button type="button" onClick={onClose} className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-slate-300 transition hover:bg-rose-500/80 hover:text-white" aria-label="关闭下钻弹层">
<X size={17} />
</button>
</header>
<div className="flex-1 overflow-y-auto bg-slate-50 p-3 md:p-5">
<div className="grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-white p-3 md:grid-cols-4 md:gap-4 md:px-[18px] md:py-[14px]">
{payload.metrics.map(metric => (
<div key={metric.label} className="min-w-0">
<div className="text-[11px] font-medium text-slate-500">{metric.label}</div>
<div className={`mt-1 truncate text-[16px] font-extrabold tabular-nums ${TONE_CLASS[metric.tone ?? 'default']}`} title={metric.value}>{metric.value}</div>
</div>
))}
</div>
{payload.groupByOptions?.length && onGroupByChange ? (
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
<div className="text-[11px] font-medium text-slate-500"></div>
<div className="flex rounded-md bg-slate-100 p-0.5">
{payload.groupByOptions.map(groupBy => (
<button
key={groupBy}
type="button"
onClick={() => onGroupByChange(groupBy)}
className={`rounded px-3 py-1.5 text-[11px] font-bold transition ${payload.groupBy === groupBy ? 'bg-white text-sky-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{groupBy === 'customer' ? '客户' : groupBy === 'station' ? '加氢站' : '车辆'}
</button>
))}
</div>
</div>
) : null}
{payload.columns.length > 0 && payload.rows.length > 0 ? (
<div className="mt-4 max-h-[440px] overflow-auto rounded-lg border border-slate-200 bg-white">
<table className="w-full min-w-[620px] border-collapse text-left">
<thead className="sticky top-0 z-10 bg-slate-100">
<tr>
{payload.columns.map(column => (
<th key={column.key} className={`border-b border-slate-200 px-3 py-2.5 text-[11px] font-bold text-slate-600 ${column.align === 'right' ? 'text-right' : 'text-left'}`}><SortableColumnHeader label={column.label} sortKey={column.key} activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align={column.align === 'right' ? 'right' : 'left'} /></th>
))}
</tr>
</thead>
<tbody>
{sortedRows.map((row, rowIndex) => (
<tr
key={rowIndex}
className={`border-b border-slate-100 last:border-0 hover:bg-slate-50 ${onRowSelect && payload.rowActionLabel ? 'cursor-pointer' : ''}`}
onClick={() => onRowSelect?.(row)}
title={onRowSelect && payload.rowActionLabel ? payload.rowActionLabel : undefined}
>
{payload.columns.map(column => (
<td key={column.key} className={`whitespace-nowrap px-3 py-2.5 text-[12px] text-slate-700 ${column.align === 'right' ? 'text-right font-semibold tabular-nums' : 'text-left'}`}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="mt-4 flex min-h-[180px] flex-col items-center justify-center rounded-lg border border-dashed border-slate-300 bg-white px-5 text-center">
<span className="grid h-10 w-10 place-items-center rounded-lg bg-sky-50 text-sky-600"><Database size={19} /></span>
<div className="mt-3 text-[13px] font-bold text-slate-700"></div>
<p className="mt-1 max-w-[620px] text-[11px] font-medium leading-5 text-slate-500">{payload.emptyMessage}</p>
</div>
)}
{payload.primaryActionLabel && onPrimaryAction ? (
<div className="mt-4 flex justify-end">
<button type="button" onClick={onPrimaryAction} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3.5 text-[12px] font-bold text-white shadow-sm transition hover:bg-sky-700">
{payload.primaryActionLabel}<ExternalLink size={14} />
</button>
</div>
) : null}
</div>
</section>
</div>,
document.body,
);
}
@@ -1,378 +0,0 @@
import { ChevronDown, ChevronRight, Database, Download, Search, Truck, X } from 'lucide-react';
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import * as XLSX from 'xlsx';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
import type {
HydrogenOverviewDetailGroup,
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailRecord,
HydrogenOverviewDetailResponse,
} from '../../types';
import type { HydrogenVehicleScope } from '../../api';
type Selection = {
stationId?: number | null;
customerId?: number | null;
customerName?: string | null;
plateNo?: string | null;
};
type TreeSortKey = 'name' | 'ownership' | 'source' | 'verify' | 'recordCount' | 'kg' | 'cost' | 'revenue';
interface OverviewDrillTreeDialogProps {
title: string;
initialVehicleScope: HydrogenVehicleScope;
initialSelection?: Selection;
load: (
groupBy: HydrogenOverviewDetailGroupBy | null,
selection: Selection,
vehicleScope: HydrogenVehicleScope,
includeAll?: boolean,
) => Promise<HydrogenOverviewDetailResponse>;
onClose: () => void;
}
const number = (value: number, digits = 2) => value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
const customerKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup) => `${stationId}:${customer.id}:${customer.name}`;
const vehicleKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => `${customerKey(stationId, customer)}:${vehicle.name}`;
/** 原型定义的四层账本树:加氢站 -> 客户 -> 车辆 -> 单笔订单与核对明细。 */
export function OverviewDrillTreeDialog({ title, initialVehicleScope, initialSelection = {}, load, onClose }: OverviewDrillTreeDialogProps) {
const [root, setRoot] = useState<HydrogenOverviewDetailResponse | null>(null);
const [customers, setCustomers] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
const [vehicles, setVehicles] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
const [orders, setOrders] = useState<Record<string, HydrogenOverviewDetailRecord[]>>({});
const [openStations, setOpenStations] = useState<Record<string, boolean>>({});
const [openCustomers, setOpenCustomers] = useState<Record<string, boolean>>({});
const [openVehicles, setOpenVehicles] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState<string | null>('root');
const [stationFilter, setStationFilter] = useState('all');
const [customerFilter, setCustomerFilter] = useState('all');
const [plateFilter, setPlateFilter] = useState('all');
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>(initialVehicleScope);
const [exporting, setExporting] = useState(false);
const [sortKey, setSortKey] = useState<TreeSortKey>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const loadRoot = useCallback(async (scope: HydrogenVehicleScope) => {
setLoading('root');
setCustomers({});
setVehicles({});
setOrders({});
setOpenStations({});
setOpenCustomers({});
setOpenVehicles({});
try {
setRoot(await load('station', initialSelection, scope));
} catch {
setRoot(null);
} finally {
setLoading(null);
}
}, [initialSelection, load]);
useEffect(() => { void loadRoot(vehicleScope); }, [loadRoot, vehicleScope]);
const toggleStation = async (station: HydrogenOverviewDetailGroup) => {
const key = String(station.id);
if (openStations[key]) {
setOpenStations(value => ({ ...value, [key]: false }));
return;
}
setOpenStations(value => ({ ...value, [key]: true }));
if (customers[key]) return;
setLoading(`station:${key}`);
try {
const data = await load('customer', { ...initialSelection, stationId: Number(station.id) || null }, vehicleScope);
setCustomers(value => ({ ...value, [key]: data.groups }));
} finally {
setLoading(null);
}
};
const toggleCustomer = async (stationId: number | string, customer: HydrogenOverviewDetailGroup) => {
const key = customerKey(stationId, customer);
if (openCustomers[key]) {
setOpenCustomers(value => ({ ...value, [key]: false }));
return;
}
setOpenCustomers(value => ({ ...value, [key]: true }));
if (vehicles[key]) return;
setLoading(`customer:${key}`);
try {
const data = await load('vehicle', {
...initialSelection,
stationId: Number(stationId) || null,
customerId: Number(customer.id) || 0,
customerName: customer.name,
}, vehicleScope);
setVehicles(value => ({ ...value, [key]: data.groups }));
} finally {
setLoading(null);
}
};
const toggleVehicle = async (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => {
const key = vehicleKey(stationId, customer, vehicle);
if (openVehicles[key]) {
setOpenVehicles(value => ({ ...value, [key]: false }));
return;
}
setOpenVehicles(value => ({ ...value, [key]: true }));
if (orders[key]) return;
setLoading(`vehicle:${key}`);
try {
const data = await load(null, {
...initialSelection,
stationId: Number(stationId) || null,
customerId: Number(customer.id) || 0,
customerName: customer.name,
plateNo: vehicle.name,
}, vehicleScope);
setOrders(value => ({ ...value, [key]: data.records }));
} finally {
setLoading(null);
}
};
const stationOptions = root?.groups ?? [];
const selectedStation = stationFilter === 'all' ? null : stationOptions.find(station => String(station.id) === stationFilter) ?? null;
const customerOptions = selectedStation ? customers[String(selectedStation.id)] ?? [] : [];
const selectedCustomer = customerFilter === 'all' ? null : customerOptions.find(customer => customerKey(selectedStation?.id ?? '', customer) === customerFilter) ?? null;
const plateOptions = selectedStation && selectedCustomer ? vehicles[customerKey(selectedStation.id, selectedCustomer)] ?? [] : [];
const visibleStations = useMemo(() => {
const filtered = stationFilter === 'all' ? stationOptions : stationOptions.filter(station => String(station.id) === stationFilter);
return sortTreeGroups(filtered, sortKey, sortDirection);
}, [sortDirection, sortKey, stationFilter, stationOptions]);
const selectStation = (value: string) => {
setStationFilter(value);
setCustomerFilter('all');
setPlateFilter('all');
const station = stationOptions.find(item => String(item.id) === value);
if (station && !openStations[String(station.id)]) void toggleStation(station);
};
const selectCustomer = (value: string) => {
setCustomerFilter(value);
setPlateFilter('all');
const customer = customerOptions.find(item => customerKey(selectedStation?.id ?? '', item) === value);
if (selectedStation && customer && !openCustomers[value]) void toggleCustomer(selectedStation.id, customer);
};
const changeVehicleScope = (scope: HydrogenVehicleScope) => {
if (scope === vehicleScope) return;
setVehicleScope(scope);
setStationFilter('all');
setCustomerFilter('all');
setPlateFilter('all');
};
const changeSort = (nextKey: TreeSortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const exportAll = async () => {
setExporting(true);
try {
const detail = await load(null, {
...initialSelection,
stationId: selectedStation ? Number(selectedStation.id) || null : null,
customerId: selectedCustomer ? Number(selectedCustomer.id) || 0 : null,
customerName: selectedCustomer?.name ?? null,
plateNo: plateFilter === 'all' ? null : plateFilter,
}, vehicleScope, true);
const rows = detail.records.map(record => ({
加氢时间: record.refuelTime,
加氢站: record.stationName,
客户: record.customerName,
车牌: record.plateNo,
车辆归属: record.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆',
数据来源: record.source,
核对状态: verifyText(record.verifyStatus),
订单编号: record.orderNo,
加氢量Kg: record.kg,
成本元: record.cost,
客户收入元: record.revenue,
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '穿透账单');
XLSX.writeFile(workbook, `${title.replaceAll(/[\\/:*?"<>|]/g, '_')}_穿透账单.xlsx`);
if (detail.truncated) window.alert('当前筛选范围超过 20,000 笔,导出已截取前 20,000 笔。请收窄筛选范围后再次导出。');
} finally {
setExporting(false);
}
};
if (typeof document === 'undefined') return null;
const summary = root?.summary;
return createPortal(
<div className="ehb-modal-overlay" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section className="ehb-modal-card" role="dialog" aria-modal="true" aria-label={`${title}穿透明细`}>
<header className="ehb-modal-head">
<div className="ehb-modal-head__title-group">
<div>
<h2 className="ehb-modal-head__title">{title}</h2>
<p className="ehb-modal-head__sub"> </p>
</div>
</div>
<button type="button" onClick={onClose} className="ehb-modal-close-btn" aria-label="关闭下钻弹层"><X size={17} /></button>
</header>
<div className="ehb-modal-body">
<div className="ehb-modal-meta-bar">
<Metric label="统计范围" value={title} />
<Metric label="加氢总量" value={`${number(summary?.kg ?? 0, 2)} Kg`} tone="text-sky-600" />
<Metric label="涉及加氢站" value={`${summary?.stationCount ?? 0}`} />
<Metric label="账本流水" value={`${summary?.recordCount ?? 0}`} />
</div>
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<SearchSelect allLabel="全部加氢站" value={stationFilter} onChange={selectStation} options={stationOptions.map(item => ({ value: String(item.id), label: item.name }))} />
<SearchSelect allLabel="全部客户" value={customerFilter} onChange={selectCustomer} disabled={!selectedStation} options={customerOptions.map(item => ({ value: customerKey(selectedStation?.id ?? '', item), label: item.name }))} />
<SearchSelect allLabel="全部车辆" value={plateFilter} onChange={setPlateFilter} disabled={!selectedCustomer} options={plateOptions.map(item => ({ value: item.name, label: item.name }))} />
<div className="flex h-8 overflow-hidden rounded-md border border-slate-300 bg-white text-[11px]">
{([{ key: 'all', label: '全部车辆' }, { key: 'lingniu', label: '仅羚牛车辆' }, { key: 'external', label: '仅外部车辆' }] as const).map(item => (
<button key={item.key} type="button" onClick={() => changeVehicleScope(item.key)} className={`inline-flex items-center gap-1 px-2.5 font-medium transition-colors ${vehicleScope === item.key ? 'bg-sky-50 text-sky-700 shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}>
{item.key !== 'all' ? <Truck size={13} /> : null}{item.label}
</button>
))}
</div>
<p className="ehb-modal-hint-text"></p>
</div>
<button type="button" onClick={() => void exportAll()} disabled={exporting} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 text-[11px] font-semibold text-slate-600 transition-colors hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700 disabled:opacity-60">
<Download size={14} />{exporting ? '正在导出…' : '导出 Excel 穿透账单'}
</button>
</div>
<p className="mb-2 text-center text-[11px] text-slate-400 md:hidden"></p>
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table min-w-[1080px]">
<thead className="sticky top-0 z-10">
<tr>
<th className="min-w-[240px] border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="加氢站 / 客户 / 车辆与凭证链路" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="类型 / 归属" sortKey="ownership" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="数据来源及凭证号" sortKey="source" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="核对状态" sortKey="verify" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢笔数" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢总量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="成本 (元)" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="客户收入 (元)" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody>
{visibleStations.map(station => {
const stationKey = String(station.id);
return <StationBranch key={`${stationKey}:${station.name}`} station={station} expanded={Boolean(openStations[stationKey])} loadingKey={loading} customers={customers[stationKey] ?? []} customerFilter={customerFilter} plateFilter={plateFilter} openCustomers={openCustomers} vehicles={vehicles} orders={orders} openVehicles={openVehicles} sortKey={sortKey} sortDirection={sortDirection} onStation={() => void toggleStation(station)} onCustomer={customer => void toggleCustomer(station.id, customer)} onVehicle={(customer, vehicle) => void toggleVehicle(station.id, customer, vehicle)} />;
})}
</tbody>
</table>
</div>
{!root && loading === null ? <div className="mt-4 flex items-center gap-2 rounded-lg border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500"><Database size={18} /></div> : null}
</div>
</section>
</div>, document.body,
);
}
function Metric({ label, value, tone = 'text-slate-900' }: { label: string; value: string; tone?: string }) {
return <div className="ehb-modal-meta-item"><div className="ehb-modal-meta-label">{label}</div><div className={`ehb-modal-meta-val ${tone}`} title={value}>{value}</div></div>;
}
function SearchSelect({ allLabel, value, onChange, options, disabled = false }: { allLabel: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[]; disabled?: boolean }) {
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const selected = options.find(option => option.value === value);
const filtered = options.filter(option => option.label.toLowerCase().includes(keyword.trim().toLowerCase()));
const pick = (next: string) => {
onChange(next);
setOpen(false);
setKeyword('');
};
return <div className={`ehb-bi-search-select w-[170px] ${disabled ? 'is-disabled' : ''}`}>
<button type="button" aria-label={allLabel} aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={`ehb-bi-search-select__trigger ${open ? 'is-open' : ''} ${selected ? 'has-value' : ''}`}>
<span className="ehb-bi-search-select__label">{selected?.label ?? allLabel}</span><ChevronDown size={14} className="ehb-bi-search-select__chevron" />
</button>
{open ? <div className="ehb-bi-search-select__dropdown">
<label className="ehb-bi-search-select__search"><Search size={13} /><input autoFocus value={keyword} onChange={event => setKeyword(event.target.value)} placeholder={`搜索${allLabel.replace('全部', '')}`} /></label>
<div className="ehb-bi-search-select__list">
<button type="button" onClick={() => pick('all')} className={`ehb-bi-search-select__item ${value === 'all' ? 'is-selected' : ''}`}>{allLabel}</button>
{filtered.map(option => <button key={option.value} type="button" onClick={() => pick(option.value)} className={`ehb-bi-search-select__item ${value === option.value ? 'is-selected' : ''}`}>{option.label}</button>)}
{filtered.length === 0 ? <p className="ehb-bi-search-select__empty"></p> : null}
</div>
</div> : null}
</div>;
}
function ExpandMark({ open }: { open: boolean }) { return open ? <ChevronDown size={15} className="shrink-0 text-sky-600" /> : <ChevronRight size={15} className="shrink-0 text-sky-600" />; }
function StationBranch({ station, expanded, loadingKey, customers, customerFilter, plateFilter, openCustomers, vehicles, orders, openVehicles, sortKey, sortDirection, onStation, onCustomer, onVehicle }: {
station: HydrogenOverviewDetailGroup; expanded: boolean; loadingKey: string | null; customers: HydrogenOverviewDetailGroup[]; customerFilter: string; plateFilter: string; openCustomers: Record<string, boolean>; vehicles: Record<string, HydrogenOverviewDetailGroup[]>; orders: Record<string, HydrogenOverviewDetailRecord[]>; openVehicles: Record<string, boolean>; sortKey: TreeSortKey; sortDirection: SortDirection; onStation: () => void; onCustomer: (customer: HydrogenOverviewDetailGroup) => void; onVehicle: (customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => void;
}) {
const stationKey = String(station.id);
const visibleCustomers = sortTreeGroups(customerFilter === 'all' ? customers : customers.filter(customer => customerKey(station.id, customer) === customerFilter), sortKey, sortDirection);
return <>
<TreeRow className={expanded ? 'bg-sky-50 font-bold' : 'bg-slate-50 font-bold'} onClick={onStation} label={<><ExpandMark open={expanded} />{station.name}<span className="ml-2 text-[11px] font-medium text-slate-400">({station.customerCount} )</span></>} ownership="-" source="全量自动归集" verify="-" group={station} />
{expanded && visibleCustomers.map(customer => {
const key = customerKey(stationKey, customer);
const childVehicles = vehicles[key] ?? [];
const visibleVehicles = sortTreeGroups(plateFilter === 'all' ? childVehicles : childVehicles.filter(vehicle => vehicle.name === plateFilter), sortKey, sortDirection);
return <Fragment key={key}>
<TreeRow className={openCustomers[key] ? 'bg-slate-100' : 'bg-white'} onClick={() => onCustomer(customer)} indent={1} label={<><ExpandMark open={Boolean(openCustomers[key])} /> {customer.name}</>} ownership="客户" source={`${childVehicles.length || '待'} 辆车挂载`} verify="-" group={customer} />
{openCustomers[key] && visibleVehicles.map(vehicle => {
const key = vehicleKey(stationKey, customer, vehicle);
return <Fragment key={key}>
<TreeRow className={openVehicles[key] ? 'bg-slate-100 text-[11px]' : 'bg-white text-[11px]'} onClick={() => onVehicle(customer, vehicle)} indent={2} label={<><ExpandMark open={Boolean(openVehicles[key])} /><strong>{vehicle.name}</strong><span className="ml-1 text-[10px] font-normal text-slate-400">({vehicle.recordCount} )</span></>} ownership={vehicle.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆'} source={vehicle.source ?? '未知来源'} verify={verifyText(vehicle.verifyStatus)} group={vehicle} />
{openVehicles[key] && <OrderRows orders={orders[key] ?? []} sortKey={sortKey} sortDirection={sortDirection} />}
</Fragment>;
})}
{openCustomers[key] && loadingKey === `customer:${key}` ? <LoadingRow text="正在读取车辆汇总…" /> : null}
</Fragment>;
})}
{expanded && loadingKey === `station:${stationKey}` ? <LoadingRow text="正在读取客户汇总…" /> : null}
</>;
}
function TreeRow({ label, ownership, source, verify, group, indent = 0, className, onClick }: { label: ReactNode; ownership: string; source: string; verify: string; group: HydrogenOverviewDetailGroup; indent?: number; className: string; onClick: () => void }) {
return <tr className={`${className} cursor-pointer border-b border-slate-100 transition-colors hover:bg-slate-50`} onClick={onClick}><td className="px-3 py-2.5" style={{ paddingLeft: `${12 + indent * 16}px` }}><span className="inline-flex items-center gap-1.5">{label}</span></td><td className="px-3 py-2.5 text-slate-500">{ownership}</td><td className="px-3 py-2.5 text-[11px] text-slate-500">{source}</td><td className="px-3 py-2.5">{verify === '-' ? <span className="text-slate-400">-</span> : <VerifyTag value={verify} />}</td><td className="px-3 py-2.5 text-right font-mono text-slate-600">{group.recordCount} </td><td className="px-3 py-2.5 text-right font-mono font-semibold text-sky-600">{number(group.kg, 3)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.cost)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.revenue)}</td></tr>;
}
function OrderRows({ orders, sortKey, sortDirection }: { orders: HydrogenOverviewDetailRecord[]; sortKey: TreeSortKey; sortDirection: SortDirection }) {
return <>{sortTreeOrders(orders, sortKey, sortDirection).map(order => <tr key={order.id} className="border-b border-slate-100 bg-slate-50 text-[11px]"><td className="px-3 py-2" style={{ paddingLeft: '76px' }}><span className="mr-1.5 text-slate-300"></span><span className="mr-1 text-[10px] text-slate-500"></span><span className="font-mono font-semibold text-sky-600">{order.orderNo || order.id}</span><span className="ml-1 text-[10px] text-slate-500">({order.refuelTime})</span></td><td className="px-3 py-2 text-[10px] text-slate-500"> ¥{order.costPrice.toFixed(2)}/Kg</td><td className="px-3 py-2">{order.source}</td><td className="px-3 py-2"><VerifyTag value={verifyText(order.verifyStatus)} /></td><td className="px-3 py-2 text-right font-mono text-slate-400">1 </td><td className="px-3 py-2 text-right font-mono text-sky-600">{number(order.kg, 3)}</td><td className="px-3 py-2 text-right font-mono">{number(order.cost)}</td><td className="px-3 py-2 text-right font-mono">{number(order.revenue)}</td></tr>)}</>;
}
function sortTreeGroups(rows: HydrogenOverviewDetailGroup[], sortKey: TreeSortKey, sortDirection: SortDirection) {
return sortBy(rows, sortKey, sortDirection, (row, key) => {
if (key === 'ownership') return row.vehicleScope ?? '';
if (key === 'verify') return row.verifyStatus ?? '';
return row[key === 'name' ? 'name' : key] ?? '';
});
}
function sortTreeOrders(rows: HydrogenOverviewDetailRecord[], sortKey: TreeSortKey, sortDirection: SortDirection) {
return sortBy(rows, sortKey, sortDirection, (row, key) => {
if (key === 'name') return row.orderNo || row.id;
if (key === 'ownership') return row.vehicleScope;
if (key === 'verify') return row.verifyStatus;
if (key === 'recordCount') return 1;
return row[key] ?? '';
});
}
function LoadingRow({ text }: { text: string }) { return <tr><td className="px-8 py-3 text-xs text-slate-400" colSpan={8}>{text}</td></tr>; }
function verifyText(value?: string) {
const normalized = (value ?? '').toUpperCase();
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核对';
if (normalized === 'PARTIAL') return '部分核对';
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
return '未核对';
}
function VerifyTag({ value }: { value: string }) {
const color = value === '已核对' ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : value === '部分核对' ? 'border-amber-100 bg-amber-50 text-amber-700' : value === '异常' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-slate-200 bg-slate-50 text-slate-500';
return <span className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-medium ${color}`}>{value}</span>;
}
@@ -1,174 +1,53 @@
import { ChevronDown, RefreshCw, Truck } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { HydrogenVehicleScope } from '../../api';
import { RefreshCw } from 'lucide-react';
import { formatRefreshTime } from '../model';
interface BiYearSelectProps {
value: number;
years: number[];
onChange: (year: number) => void;
}
function BiYearSelect({ value, years, onChange }: BiYearSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className="ehb-year-select-wrapper" ref={ref}>
<button
type="button"
className={`ehb-year-select-btn ${isOpen ? 'is-active' : ''}`}
onClick={() => setIsOpen(!isOpen)}
aria-label="年份选择"
>
<span className="ehb-year-text">{value} </span>
<ChevronDown
size={13}
style={{
transition: 'transform 0.2s ease',
transform: isOpen ? 'rotate(180deg)' : 'none',
color: '#64748b',
}}
/>
</button>
{isOpen && (
<div className="ehb-year-dropdown">
<div className="ehb-year-dropdown__header"></div>
<div className="ehb-year-dropdown__list">
{years.map((y) => (
<button
key={y}
type="button"
className={`ehb-year-dropdown__item ${y === value ? 'is-selected' : ''}`}
onClick={() => {
onChange(y);
setIsOpen(false);
}}
>
<span>{y} </span>
{y === value && <span className="ehb-year-check"></span>}
</button>
))}
</div>
</div>
)}
</div>
);
}
interface OverviewHeaderProps {
activeYear: number;
availableYears: number[];
vehicleScope: HydrogenVehicleScope;
verifyScope?: 'all' | 'verified';
onVerifyScopeChange?: (scope: 'all' | 'verified') => void;
selectedStationId?: number | null;
selectedStationName?: string | null;
stations?: { id: number; name: string }[];
latestLedgerTime?: string | null;
lastRefreshAt?: number;
refreshing?: boolean;
lastRefreshAt: number;
refreshing: boolean;
onSelectYear: (year: number) => void;
onVehicleScopeChange: (scope: HydrogenVehicleScope) => void;
onStationChange?: (stationId: number | null) => void;
onRefresh: () => void;
}
export function OverviewHeader({
activeYear,
availableYears,
vehicleScope,
verifyScope = 'all',
onVerifyScopeChange,
latestLedgerTime,
lastRefreshAt = 0,
refreshing = false,
lastRefreshAt,
refreshing,
onSelectYear,
onVehicleScopeChange,
onRefresh,
}: OverviewHeaderProps) {
return (
<section className="ehb-daily-filter-card" style={{ marginBottom: 12 }} aria-label="总览筛选工具栏">
<div className="ehb-daily-filter-row">
<div className="ehb-daily-filter-group">
<BiYearSelect value={activeYear} years={availableYears} onChange={onSelectYear} />
<div className="ehb-pill-tabs">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => {
const active = y === activeYear;
return (
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
onClick={() => onVerifyScopeChange?.('all')}
key={y}
onClick={() => onSelectYear(y)}
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
}`}
>
</button>
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
onClick={() => onVerifyScopeChange?.('verified')}
title="真实账本已返回全量有效订单"
>
{y}
</button>
);
})}
</div>
</div>
<div className="ehb-daily-filter-group">
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === 'all' ? 'is-active' : ''}`}
onClick={() => onVehicleScopeChange('all')}
>
</button>
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === 'lingniu' ? 'is-active' : ''}`}
onClick={() => onVehicleScopeChange('lingniu')}
>
<Truck size={14} />
</button>
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === 'external' ? 'is-active' : ''}`}
onClick={() => onVehicleScopeChange('external')}
>
<Truck size={14} />
</button>
</div>
<span className="ehb-chrome__clock" style={{ fontSize: 12, color: '#64748b' }}>
{latestLedgerTime ? `账本 ${latestLedgerTime.slice(5, 16)}` : lastRefreshAt ? formatRefreshTime(lastRefreshAt) : '已同步'}
</span>
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={onRefresh}
disabled={refreshing}
title="数据刷新"
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="手动刷新(绕过缓存)"
>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} aria-hidden />
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
<span className="text-[11px] font-bold"></span>
</button>
</div>
</div>
</section>
);
}
@@ -1,116 +1,55 @@
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
import { useMemo, useState } from 'react';
import { ChevronRight, Search } from 'lucide-react';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
import {
buildCustomerDrillPayload,
buildStationDrillPayload,
formatKg as fmtKg,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
export function StationSummaryTable({
stations,
onSelectStation,
scope = 'global',
scopeLabel,
onDrillRequest,
}: {
stations: HydrogenStationFull[];
onSelectStation?: (stationId: number) => void;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}) {
const [province, setProvince] = useState('all');
const [sortKey, setSortKey] = useState<'name' | 'province' | 'kg' | 'share' | 'revenue' | 'revenueShare'>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const provinces = useMemo(() => [...new Set(stations.map(station => station.province?.trim()).filter(Boolean) as string[])], [stations]);
const filteredStations = province === 'all'
? stations
: stations.filter(station => station.province === province);
const sortedStations = useMemo(() => sortBy(filteredStations, sortKey, sortDirection, (station, key) => station[key]), [filteredStations, sortDirection, sortKey]);
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const openStation = (station: HydrogenStationFull) => {
setSelectedStationId(station.id);
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
else setDrill(buildStationDrillPayload(station));
};
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
return (
<>
{stations.length > 0 && (
<div className="ehb-sum-table-card">
<div className="ehb-sum-table-card__head" style={{ flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div className="ehb-sum-table-card__title"><Search size={14} className="text-sky-600" />{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}</div>
<div className="ehb-mini-tabs">
<button type="button" onClick={() => setProvince('all')} className={`ehb-mini-tab ${province === 'all' ? 'is-active' : ''}`}></button>
{provinces.map(item => <button key={item} type="button" onClick={() => setProvince(item)} className={`ehb-mini-tab ${province === item ? 'is-active' : ''}`}>{item}</button>)}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> {stations.length} </span>
</div>
</div>
<div className="ehb-sum-table-card__meta">
{stations.length}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="加氢站" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="所属省份" sortKey="province" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="占比" sortKey="share" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="氢费收入" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="收入占比" sortKey="revenueShare" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 hidden sm:table-cell"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{sortedStations.map((s, i) => {
{stations.map((s, i) => {
const kgFmt = fmtKg(s.kg);
const revFmt = fmtYuan(s.revenue);
return (
<tr key={s.name + i} onClick={() => openStation(s)} style={{ cursor: 'pointer' }} title="查看加氢站汇总明细">
<td className="col-idx">{i + 1}</td>
<td style={{ fontWeight: 600, color: '#0284c7' }}>
{s.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}> </span>
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td>
<span style={{ fontSize: 11, color: '#0284c7', background: '#eff6ff', padding: '1px 6px', borderRadius: 4, fontWeight: 500 }}>
{s.province ?? '未归属'}
</span>
</td>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
</td>
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div className="ehb-mini-bar-fill is-blue" style={{ width: `${Math.min(100, s.share * 100 * 2.5)}%` }} />
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
</div>
<span className="ehb-ratio-text">{(s.share * 100).toFixed(1)}%</span>
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
</div>
</td>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div className="ehb-mini-bar-fill is-green" style={{ width: `${Math.min(100, s.revenueShare * 100 * 5)}%` }} />
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
</div>
<span className="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
@@ -121,98 +60,55 @@ export function StationSummaryTable({
</div>
</div>
)}
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
export function CustomerSummaryTable({
customers,
scope = 'global',
scopeLabel,
onDrillRequest,
}: {
customers: HydrogenCustomerRow[];
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}) {
const [sortKey, setSortKey] = useState<'name' | 'payer' | 'kg' | 'cost' | 'revenue'>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const sortedCustomers = useMemo(() => sortBy(customers, sortKey, sortDirection, (customer, key) => customer[key]), [customers, sortDirection, sortKey]);
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const openCustomer = (customer: HydrogenCustomerRow, index: number) => {
if (onDrillRequest) onDrillRequest({ kind: 'customer', key: `${customer.name}-${index}`, label: customer.name });
else setDrill(buildCustomerDrillPayload(customer));
};
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
return (
<>
{customers.length > 0 && (
<div className="ehb-sum-table-card">
<div className="ehb-sum-table-card__head">
<div className="ehb-sum-table-card__title">
<Search size={14} className="text-sky-600" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}
<span className="ehb-title-sub ehb-hide-h5">
/
</span>
<span className="ehb-title-sub ehb-show-h5">
</span>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
</div>
<div className="ehb-sum-table-card__meta">
Top {customers.length}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="客户" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'center' }}><SortableColumnHeader label="承担方" sortKey="payer" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="center" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="成本支出" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="应收" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-center py-1.5 w-14 hidden sm:table-cell"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{sortedCustomers.map((c2, i) => {
{customers.map((c2, i) => {
const kgFmt = fmtKg(c2.kg);
const costFmt = fmtYuan(c2.cost);
const revFmt = fmtYuan(c2.revenue);
return (
<tr key={c2.name + i} onClick={() => openCustomer(c2, i)} style={{ cursor: 'pointer' }} title="点击查看客户账单明细">
<td className="col-idx">{i + 1}</td>
<td style={{ fontWeight: 600, color: '#0284c7' }}>
{c2.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}> </span>
</td>
<td style={{ textAlign: 'center' }}>
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<td className="py-1.5 text-center hidden sm:table-cell">
{c2.payer === 'lingniu' ? (
<span className="ehb-payer-tag is-own"></span>
) : c2.payer === 'mixed' ? (
<span className="ehb-payer-tag is-mix"></span>
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold"></span>
) : (
<span className="ehb-payer-tag is-ext"></span>
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold"></span>
)}
</td>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td style={{ textAlign: 'right' }} className="col-amber-fee">
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
</td>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
</tr>
);
@@ -222,7 +118,6 @@ export function CustomerSummaryTable({
</div>
</div>
)}
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}
@@ -2,37 +2,12 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenOverviewResponse } from '../api.js';
import {
buildCustomerDrillPayload,
buildMetricDrillPayload,
buildMonthDrillPayload,
buildRegionDrillPayload,
buildStationDrillPayload,
deriveOverviewMetrics,
formatKg,
formatRelative,
formatYuan,
} from './model.js';
const drillKpi = {
yearKg: 10_000,
yearFee: 80_000,
yearRevenue: 75_000,
yearProfit: 15_000,
ourYearKg: 4_000,
ourYearFee: 20_000,
customerYearKg: 6_000,
monthKg: 1_000,
monthFee: 8_000,
monthRevenue: 7_500,
monthProfit: 1_500,
todayKg: 100,
todayFee: 800,
todayRevenue: 750,
todayProfit: 150,
lingniuBornKg: 0,
lingniuBornFee: 0,
};
test('重量和金额单位保持现有阈值及负数格式', () => {
assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' });
assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' });
@@ -70,7 +45,7 @@ test('总览派生指标保持月份顺序、动能、集中度和收益率口
lingniuBornKg: 0,
lingniuBornFee: 0,
},
top5: [{ id: 1, rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
top5: [{ rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
regions: [],
monthly: [
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
@@ -78,13 +53,11 @@ test('总览派生指标保持月份顺序、动能、集中度和收益率口
],
customers: [],
stations: [
{ id: 1, name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
{ id: 2, name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
],
availableYears: [2026],
year: 2026,
latestLedgerTime: '2026-08-17 21:50:09',
filter: { stationId: null, vehicleScope: 'all', verifyScope: 'all' },
} satisfies HydrogenOverviewResponse;
const metrics = deriveOverviewMetrics(data);
@@ -93,56 +66,7 @@ test('总览派生指标保持月份顺序、动能、集中度和收益率口
assert.equal(metrics.latestMonth?.month, '2026-02');
assert.equal(metrics.monthMomentum, 50);
assert.equal(metrics.top5Share, 60);
assert.equal(metrics.customerGrossMarginPct, -10);
assert.equal(metrics.profitYield, -10);
assert.equal(metrics.stationAvgKg, 500);
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
});
test('KPI 下钻只使用真实汇总口径且保留客户单毛利语义', () => {
const payload = buildMetricDrillPayload(drillKpi, 'yearProfit');
assert.equal(payload.title, '客户单毛利');
assert.equal(payload.metrics[0]?.label, '客户单毛利');
assert.equal(payload.metrics[0]?.value, '¥1.5 万元');
assert.equal(payload.rows.length, 0);
assert.match(payload.emptyMessage ?? '', /未返回站点、客户、车牌及单笔订单层级/);
});
test('月度下钻按接口返回的羚牛与外部车辆加氢量拆分', () => {
const payload = buildMonthDrillPayload({
month: '2026-08',
kg: 1_000,
lingniuKg: 700,
externalKg: 300,
fee: 4_500,
revenue: 5_000,
profit: 500,
});
assert.equal(payload.rows[0]?.category, '羚牛车辆');
assert.equal(payload.rows[0]?.share, '70.0%');
assert.equal(payload.rows[1]?.share, '30.0%');
assert.equal(payload.metrics[3]?.label, '客户单毛利');
});
test('省级区域下钻仅关联同省真实站点,市级缺少映射时明确空态', () => {
const stations = [
{ id: 1, name: '嘉兴站', province: '浙江', kg: 600, revenue: 1_000, share: 0.6, revenueShare: 0.5 },
{ id: 2, name: '广州站', province: '广东', kg: 400, revenue: 1_000, share: 0.4, revenueShare: 0.5 },
];
const provincePayload = buildRegionDrillPayload({ region: '浙江', kg: 600, share: 0.6 }, stations, 'province');
assert.equal(provincePayload.rows.length, 1);
assert.equal(provincePayload.rows[0]?.station, '嘉兴站');
const cityPayload = buildRegionDrillPayload({ region: '嘉兴', kg: 600, share: 0.6 }, stations, 'city');
assert.equal(cityPayload.rows.length, 0);
assert.match(cityPayload.emptyMessage ?? '', /未返回城市字段/);
});
test('站点与客户弹层提供汇总指标及真实事件入口', () => {
const stationPayload = buildStationDrillPayload({ id: 9, name: '测试站', province: '广东', kg: 900, revenue: 3_000, share: 0.3, revenueShare: 0.4 });
assert.equal(stationPayload.primaryActionLabel, '进入单站视图');
assert.equal(stationPayload.metrics[0]?.value, '900.00 Kg');
const customerPayload = buildCustomerDrillPayload({ name: '测试客户', payer: 'customer', kg: 500, cost: 2_000, revenue: 2_500 });
assert.equal(customerPayload.metrics[3]?.label, '价差');
assert.equal(customerPayload.metrics[3]?.value, '¥500 元');
});
+1 -224
View File
@@ -1,48 +1,4 @@
import type { HydrogenOverviewResponse } from '../api';
import type {
HydrogenCustomerRow,
HydrogenKpi,
HydrogenMonthlyPoint,
HydrogenRegionShare,
HydrogenStationFull,
} from '../types';
export type OverviewScope = 'global' | 'station';
export type OverviewMetricKey = 'yearKg' | 'yearFee' | 'yearProfit' | 'monthKg' | 'todayKg';
export type OverviewDrillKind = 'metric' | 'month' | 'station' | 'customer' | 'region';
export interface OverviewDrillRequest {
kind: OverviewDrillKind;
key: string;
label: string;
entityId?: number;
}
export interface OverviewDrillMetric {
label: string;
value: string;
tone?: 'default' | 'blue' | 'green' | 'amber' | 'red';
}
export interface OverviewDrillColumn {
key: string;
label: string;
align?: 'left' | 'right';
}
export interface OverviewDrillPayload {
kind: OverviewDrillKind;
title: string;
subtitle: string;
metrics: OverviewDrillMetric[];
columns: OverviewDrillColumn[];
rows: Record<string, string | number>[];
emptyMessage?: string;
primaryActionLabel?: string;
groupBy?: 'station' | 'customer' | 'vehicle';
groupByOptions?: Array<'station' | 'customer' | 'vehicle'>;
rowActionLabel?: string;
}
export function formatKg(kg: number): { value: string; unit: string } {
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
@@ -66,185 +22,6 @@ export function formatYuan(yuan: number): { value: string; unit: string } {
};
}
function formatKgText(value: number): string {
const formatted = formatKg(value);
return `${formatted.value} ${formatted.unit}`;
}
function formatYuanText(value: number): string {
const formatted = formatYuan(value);
return `¥${formatted.value} ${formatted.unit}`;
}
const METRIC_LABELS: Record<OverviewMetricKey, string> = {
yearKg: '累计加氢量',
yearFee: '累计加氢费',
yearProfit: '客户单毛利',
monthKg: '本月加氢',
todayKg: '本日加氢',
};
/**
*
* onDrillRequest
*/
export function buildMetricDrillPayload(kpi: HydrogenKpi, key: OverviewMetricKey): OverviewDrillPayload {
const customerCost = Math.max(0, kpi.yearFee - kpi.ourYearFee);
const common = {
kind: 'metric' as const,
title: METRIC_LABELS[key],
subtitle: '汇总口径穿透 · 当前筛选范围',
columns: [],
rows: [],
emptyMessage: '当前总览接口未返回站点、客户、车牌及单笔订单层级;汇总值已按真实账本展示。',
};
if (key === 'yearKg') {
return {
...common,
metrics: [
{ label: '累计加氢量', value: formatKgText(kpi.yearKg), tone: 'blue' },
{ label: '我司车辆', value: formatKgText(kpi.ourYearKg) },
{ label: '客户车辆', value: formatKgText(kpi.customerYearKg) },
],
};
}
if (key === 'yearFee') {
return {
...common,
metrics: [
{ label: '累计加氢费', value: formatYuanText(kpi.yearFee), tone: 'blue' },
{ label: '我司承担', value: formatYuanText(kpi.ourYearFee) },
{ label: '客户承担', value: formatYuanText(customerCost) },
],
};
}
if (key === 'yearProfit') {
return {
...common,
metrics: [
{ label: '客户单毛利', value: formatYuanText(kpi.yearProfit), tone: kpi.yearProfit >= 0 ? 'green' : 'red' },
{ label: '客户收入', value: formatYuanText(kpi.yearRevenue) },
{ label: '客户成本', value: formatYuanText(customerCost) },
],
};
}
if (key === 'monthKg') {
return {
...common,
metrics: [
{ label: '本月加氢量', value: formatKgText(kpi.monthKg), tone: 'amber' },
{ label: '本月加氢费', value: formatYuanText(kpi.monthFee) },
{ label: '占年比', value: `${kpi.yearKg > 0 ? (kpi.monthKg / kpi.yearKg * 100).toFixed(1) : '0.0'}%` },
],
};
}
return {
...common,
metrics: [
{ label: '本日加氢量', value: formatKgText(kpi.todayKg), tone: 'blue' },
{ label: '本日加氢费', value: formatYuanText(kpi.todayFee) },
{ label: '占月比', value: `${kpi.monthKg > 0 ? (kpi.todayKg / kpi.monthKg * 100).toFixed(1) : '0.0'}%` },
],
};
}
export function buildMonthDrillPayload(month: HydrogenMonthlyPoint): OverviewDrillPayload {
const lingniuKg = month.lingniuKg ?? 0;
const externalKg = month.externalKg ?? Math.max(0, month.kg - lingniuKg);
return {
kind: 'month',
title: `${month.month} 月度经营明细`,
subtitle: '月度聚合 · 当前车辆范围',
metrics: [
{ label: '加氢总量', value: formatKgText(month.kg), tone: 'blue' },
{ label: '成本支出', value: formatYuanText(month.fee), tone: 'amber' },
{ label: '客户收入', value: formatYuanText(month.revenue), tone: 'green' },
{ label: '客户单毛利', value: formatYuanText(month.profit), tone: month.profit >= 0 ? 'green' : 'red' },
],
columns: [
{ key: 'category', label: '车辆范围' },
{ key: 'kg', label: '加氢量', align: 'right' },
{ key: 'share', label: '占比', align: 'right' },
],
rows: [
{ category: '羚牛车辆', kg: formatKgText(lingniuKg), share: `${month.kg > 0 ? (lingniuKg / month.kg * 100).toFixed(1) : '0.0'}%` },
{ category: '外部车辆', kg: formatKgText(externalKg), share: `${month.kg > 0 ? (externalKg / month.kg * 100).toFixed(1) : '0.0'}%` },
],
};
}
export function buildStationDrillPayload(station: HydrogenStationFull): OverviewDrillPayload {
return {
kind: 'station',
title: `${station.name}」加氢汇总明细`,
subtitle: `${station.province || '未归属省份'} · 当前统计周期`,
metrics: [
{ label: '加氢量', value: formatKgText(station.kg), tone: 'blue' },
{ label: '氢费收入', value: formatYuanText(station.revenue), tone: 'green' },
{ label: '加氢量占比', value: `${(station.share * 100).toFixed(1)}%` },
{ label: '收入占比', value: `${(station.revenueShare * 100).toFixed(1)}%` },
],
columns: [],
rows: [],
emptyMessage: '当前总览接口未返回该站按日、客户及车辆流水;可切换到单站视图继续查看真实数据。',
primaryActionLabel: '进入单站视图',
};
}
export function buildCustomerDrillPayload(customer: HydrogenCustomerRow): OverviewDrillPayload {
const payerLabel = customer.payer === 'lingniu' ? '羚牛承担' : customer.payer === 'mixed' ? '混合承担' : '客户承担';
return {
kind: 'customer',
title: `${customer.name}」客户账单明细`,
subtitle: `${payerLabel} · 当前统计周期`,
metrics: [
{ label: '加氢量', value: formatKgText(customer.kg), tone: 'blue' },
{ label: '成本支出', value: formatYuanText(customer.cost), tone: 'amber' },
{ label: '应收', value: formatYuanText(customer.revenue), tone: 'green' },
{ label: '价差', value: formatYuanText(customer.revenue - customer.cost), tone: customer.revenue - customer.cost >= 0 ? 'green' : 'red' },
],
columns: [],
rows: [],
emptyMessage: '当前总览接口未返回该客户按日、车牌及单笔加氢流水。',
};
}
export function buildRegionDrillPayload(
region: HydrogenRegionShare,
stations: HydrogenStationFull[],
granularity: 'province' | 'city',
): OverviewDrillPayload {
const matchedStations = granularity === 'province'
? stations.filter(station => (station.province?.trim() || '未归属') === region.region)
: [];
return {
kind: 'region',
title: `${region.region}加氢区域明细`,
subtitle: `${granularity === 'province' ? '省级' : '市级'}口径 · 当前统计周期`,
metrics: [
{ label: '区域加氢量', value: formatKgText(region.kg), tone: 'blue' },
{ label: '全局占比', value: `${(region.share * 100).toFixed(1)}%` },
{ label: '已关联站点', value: `${matchedStations.length}` },
],
columns: [
{ key: 'station', label: '加氢站' },
{ key: 'province', label: '所属省份' },
{ key: 'kg', label: '加氢量', align: 'right' },
{ key: 'share', label: '区域内占比', align: 'right' },
],
rows: matchedStations.map(station => ({
station: station.name,
province: station.province || '未归属',
kg: formatKgText(station.kg),
share: `${region.kg > 0 ? (station.kg / region.kg * 100).toFixed(1) : '0.0'}%`,
})),
emptyMessage: granularity === 'city'
? '当前站点汇总接口未返回城市字段,无法将市级汇总继续关联到具体站点。'
: '当前区域暂无可关联站点。',
};
}
export function formatRelative(timestamp: number, now = Date.now()): string {
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
if (seconds < 5) return '刚刚';
@@ -288,7 +65,7 @@ export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
: null,
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
customerGrossMarginPct: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
profitYield: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
monthlyDual: monthly.map(month => ({
...month,
File diff suppressed because it is too large Load Diff
+2 -187
View File
@@ -1,11 +1,10 @@
export type CustomerType = 'all' | 'external' | 'lingniu';
export type CustomerType = 'external' | 'lingniu';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi {
yearKg: number;
yearFee: number;
yearRevenue: number;
// 客户单毛利:对客收入减去客户承担订单的氢气成本,不代表全量经营利润。
yearProfit: number;
ourYearKg: number;
ourYearFee: number;
@@ -23,7 +22,6 @@ export interface HydrogenKpi {
}
export interface HydrogenStationTop {
id: number;
rank: number;
name: string;
kg: number;
@@ -40,8 +38,6 @@ export interface HydrogenRegionShare {
export interface HydrogenMonthlyPoint {
month: string; // YYYY-MM
kg: number;
lingniuKg?: number;
externalKg?: number;
fee: number;
revenue: number;
profit: number;
@@ -49,16 +45,14 @@ export interface HydrogenMonthlyPoint {
export interface HydrogenCustomerRow {
name: string;
payer: 'lingniu' | 'customer' | 'mixed';
payer: 'lingniu' | 'customer';
kg: number;
cost: number;
revenue: number;
}
export interface HydrogenStationFull {
id: number;
name: string;
province?: string;
kg: number;
revenue: number;
share: number; // 加氢量占比
@@ -66,199 +60,20 @@ export interface HydrogenStationFull {
}
export interface HydrogenStationRow {
id: number;
name: string;
pricePerKg: number;
kg: number;
fee: number;
chainPct: number;
}
export interface HydrogenDailyRow {
date: string;
totalKg: number;
totalFee: number;
chainPct: number;
customerType: CustomerType;
stations: HydrogenStationRow[];
}
export interface HydrogenDailyDetailVehicle {
id: string;
time: string;
plateNo: string;
vehicleScope: 'lingniu' | 'external';
source: string;
verifyStatus: string;
unitPrice: number;
kg: number;
fee: number;
}
export interface HydrogenDailyDetailCustomer {
id: number;
name: string;
kg: number;
fee: number;
vehicles: HydrogenDailyDetailVehicle[];
}
export interface HydrogenDailyDetailStation {
id: number;
name: string;
stationType: string;
kg: number;
fee: number;
// Null means the source system has no persisted station-balance record for
// this station on or before the selected date; it must not be treated as 0.
balance: number | null;
balanceEffectiveTime: string | null;
customers: HydrogenDailyDetailCustomer[];
}
export interface HydrogenDailyDetailResponse {
date: string;
recordCount: number;
stations: HydrogenDailyDetailStation[];
}
export interface HydrogenStationBoardStation {
id: number;
name: string;
province: string;
city: string;
kg: number;
fee: number;
recordCount: number;
paymentAmount: number;
paymentCount: number;
share: number;
latestLedgerTime: string | null;
latestPaymentDate: string | null;
// 区间内逐日真实加氢量,仅供单站总览的迷你趋势柱使用。
// 空日期由服务端补零,绝不使用前端模拟数据。
dailyKg: Array<{ date: string; kg: number }>;
}
export interface HydrogenStationBoardDailyRow {
date: string;
kg: number;
fee: number;
avgPrice: number;
recordCount: number;
changeKg: number;
paymentAmount: number;
paymentCount: number;
}
export interface HydrogenStationBoardSummaryDailyRow {
date: string;
kg: number;
fee: number;
recordCount: number;
paymentAmount: number;
paymentCount: number;
}
export interface HydrogenStationBoardCustomerMonth {
month: string;
customerName: string;
kg: number;
fee: number;
recordCount: number;
}
export interface HydrogenStationBoardResponse {
range: { start: string; end: string };
summary: {
stationCount: number;
activeStationCount: number;
totalKg: number;
totalFee: number;
recordCount: number;
paymentAmount: number;
paymentCount: number;
latestLedgerTime: string | null;
daily: HydrogenStationBoardSummaryDailyRow[];
};
stations: HydrogenStationBoardStation[];
selected: {
daily: HydrogenStationBoardDailyRow[];
customerMonths: HydrogenStationBoardCustomerMonth[];
} | null;
}
export interface HydrogenOverviewDetailRecord {
id: number;
orderNo: string;
refuelTime: string;
stationId: number;
stationName: string;
province: string;
city: string;
customerId: number;
customerName: string;
plateNo: string;
vehicleScope: 'lingniu' | 'external';
source: string;
verifyStatus: string;
kg: number;
costPrice: number;
cost: number;
customerPrice: number;
revenue: number;
paymentStatus: number | null;
}
export type HydrogenOverviewDetailGroupBy = 'station' | 'customer' | 'vehicle';
export interface HydrogenOverviewDetailGroup {
id: number | string;
name: string;
province: string | null;
city: string | null;
recordCount: number;
stationCount: number;
customerCount: number;
kg: number;
cost: number;
customerCost: number;
revenue: number;
vehicleScope?: 'lingniu' | 'external';
source?: string;
verifyStatus?: string;
}
export interface HydrogenOverviewDetailResponse {
filter: {
year: number;
vehicleScope: 'all' | 'lingniu' | 'external';
verifyScope: 'all' | 'verified';
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;
};
truncated: boolean;
summary: {
recordCount: number;
stationCount: number;
customerCount: number;
kg: number;
cost: number;
customerCost: number;
revenue: number;
};
groupBy: HydrogenOverviewDetailGroupBy | null;
groups: HydrogenOverviewDetailGroup[];
records: HydrogenOverviewDetailRecord[];
}
export interface ElectricKpi {
totalKwh: number;
totalFee: number;
-2
View File
@@ -1,6 +1,5 @@
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { readOnlyMiddleware } from './read-only-middleware.js';
import { cors } from 'hono/cors';
import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js';
@@ -21,7 +20,6 @@ export function createApp(): Hono {
const app = new Hono();
app.use('/api/*', cors());
app.use('/api/*', readOnlyMiddleware);
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
app.route('/api/auth', authRouter);
-1
View File
@@ -3,7 +3,6 @@ import { startMileageBackgroundJobs } from './routes/mileage/index.js';
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
export function startBackgroundServices(): void {
if (process.env.DB_READ_ONLY === '1') return;
ensureSchedulingTables().catch((error) => {
console.error('scheduling bootstrap error:', error);
});
+6 -18
View File
@@ -1,29 +1,17 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
dotenv.config();
const rawHydrogenPool = mysql.createPool({
// 氢能账本归属线上业务库。保留专用变量,便于未来拆分独立只读库;
// 未配置专用变量时,复用主业务库,避免部署环境误回退到历史数据库地址。
host: process.env.HYDROGEN_DB_HOST || process.env.DB_HOST,
port: Number(process.env.HYDROGEN_DB_PORT || process.env.DB_PORT) || 3306,
user: process.env.HYDROGEN_DB_USER || process.env.DB_USER,
password: process.env.HYDROGEN_DB_PASSWORD || process.env.DB_PASSWORD,
database: process.env.HYDROGEN_DB_NAME || process.env.DB_NAME,
const hydrogenPool = mysql.createPool({
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
user: process.env.HYDROGEN_DB_USER || 'root',
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
const rawQuery = rawHydrogenPool.query.bind(rawHydrogenPool) as typeof rawHydrogenPool.query;
const hydrogenPool = {
query: ((sql: unknown, values?: unknown) => {
const statement = typeof sql === 'string' ? sql : String((sql as { sql?: unknown })?.sql ?? '');
assertHydrogenReadOnlySql(statement, process.env.HYDROGEN_DB_READ_ONLY === '1');
return rawQuery(sql as never, values as never);
}) as typeof rawHydrogenPool.query,
};
export default hydrogenPool;
-18
View File
@@ -1,18 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
test('只读模式允许查询语句', () => {
for (const sql of ['SELECT 1', ' SHOW TABLES', 'WITH rows AS (SELECT 1) SELECT * FROM rows', 'EXPLAIN SELECT 1']) {
assert.doesNotThrow(() => assertHydrogenReadOnlySql(sql, true));
}
});
test('只读模式拒绝数据库写入语句', () => {
for (const sql of ['INSERT INTO ledger VALUES (1)', 'UPDATE ledger SET value = 1', 'DELETE FROM ledger', 'ALTER TABLE ledger ADD value INT']) {
assert.throws(
() => assertHydrogenReadOnlySql(sql, true),
/HYDROGEN_DB_READ_ONLY blocks non-read SQL/,
);
}
});

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