19 Commits
Author SHA1 Message Date
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
230 changed files with 5524 additions and 41778 deletions
Vendored
BIN
View File
Binary file not shown.
+2
View File
@@ -3,3 +3,5 @@ dist
.env .env
.env.local .env.local
.worktrees .worktrees
.DS_Store
scripts-tmp
+3 -1
View File
@@ -17,7 +17,9 @@ COPY src/shared ./src/shared
COPY tsconfig.json ./ COPY tsconfig.json ./
EXPOSE 3001 EXPOSE 3001
ENV NODE_ENV=production
ENV SERVER_PORT=3001 ENV SERVER_PORT=3001
ENV EXTERNAL_API_BASE=https://lnh2e.com ENV EXTERNAL_API_BASE=https://lnh2e.com
ENV JWT_SECRET=ln-bi-jwt-prod-secret # JWT_SECRET 不再写入镜像:镜像层里的默认密钥等同于公开密钥,任何人可离线伪造令牌。
# 必须在运行环境注入(至少 32 字符),缺失时服务会拒绝启动(见 src/server/config.ts)。
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]
+87
View File
@@ -0,0 +1,87 @@
# LN-BI
羚牛业务数据的统一 BI Web 应用。React 19 + Vite 前端,Hono 后端,单进程同时提供 API 与静态页面。
## 入口
| 入口 | 模块 | 路由 |
| --- | --- | --- |
| 资产 BI | 资产管理、里程管理、车辆/加氢热力图、智能调度 | `/asset` |
| 能源 BI | 氢费 BI、电能、ETC | `/energy` |
| 隐藏页 | 充电记录导入(需能源角色) | `/ele/import` |
| 隐藏页 | 用户反馈管理 | `/admin/feedback` |
| 独立入口 | 氢能经营看板(与 `/energy` 内同一组件) | `/energy/hydrogen-board` |
页面内部用 Hash 保存子页面状态,例如 `/energy#hydrogen/overview`。入口兼容逻辑见 `src/app/routing.ts`
## 快速开始
需要 Node.js 22 与 npm。
```bash
npm ci
cp .env.local.example .env.local # 填入数据库等连接配置,切勿提交
npm run dev:local # 只读本地预览:前端 127.0.0.1:8115API 127.0.0.1:3001
```
`dev:local` 会强制 `DB_READ_ONLY=1``HYDROGEN_DB_READ_ONLY=1``MILEAGE_REPORT_AUTO_ARCHIVE=0`
`DEV_BYPASS_AUTH=1`,并且**不启动建表与定时任务**,适合看真实数据而不写库。不要把它暴露到公网。
常规开发(前端 3000,后端 3001,`/api` 由 Vite 代理):
```bash
npm run dev
```
## 命令
| 命令 | 说明 |
| --- | --- |
| `npm run dev` | 前后端开发模式 |
| `npm run dev:local` | 只读本地预览(免登录,禁写库) |
| `npm run lint` | 类型检查(`tsc --noEmit` |
| `npm test` | Node 内置测试运行器 |
| `npm run build` | 生成 `dist` |
| `npm start` | 生产式启动(需先 build,且必须提供环境变量) |
发布门禁是 `lint` + `test` + `build` 三者同时通过(CI 见 `woodpecker.yml`)。
## 目录
```
src/
├── app/ 应用外壳:路由解析、导航注册
├── auth/ 前端认证状态与注入
├── components/ 跨模块 UIShell、反馈、通用控件)
├── modules/ 前端业务域(每个域自带 api / model / components / css
│ ├── assets/ 资产管理
│ ├── mileage/ 里程(monitoring / statistics / daily-report
│ ├── scheduling/智能调度
│ ├── energy/ 能源(hydrogen / electric / etc
│ ├── ele/ 电能数据导入
│ ├── admin/ 反馈后台
│ └── *-heatmap/ 两类热力图
├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO、日期区间、Excel、高德接入)
└── server/
├── config.ts 环境变量集中读取 + 启动期校验
├── app.ts 应用装配(无副作用,便于测试)
├── index.ts 进程启动
├── local.ts 只读预览启动
├── db/ MySQL / PostgreSQL 连接
├── middleware/认证、只读
├── auth/ 登录(SSO 换票 / 固定密码)、权限
└── routes/ 按业务域的 HTTP 路由
```
## 架构约定
分层规则、依赖方向与「新增一个模块该放哪」见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
其中 6 条硬规则由 `src/architecture.test.ts` 断言,违反即测试失败。
## 部署注意事项
- `JWT_SECRET` **必须**在运行环境注入且不少于 32 字符;SSO 模式下缺失时服务会拒绝启动。
镜像内不再内置默认密钥(历史默认值等同于公开密钥,可被离线伪造令牌)。
- 数据库口令、Harbor 凭据、OSS / OneOS / 高德密钥一律通过环境变量或 CI Secret 注入,
仓库内不保留真实值。历史提交中出现过的凭据都需要轮换。
- 生产环境请显式设置 `NODE_ENV=production``DEV_BYPASS_AUTH=0`;后者会绕过全部认证。
+30 -12
View File
@@ -1,20 +1,29 @@
version: '3.8' version: '3.8'
# 凭据一律通过 Portainer / 环境变量注入,不再写入本文件。
# 注意:此前提交到 Git 的数据库口令与 JWT 密钥已视为泄漏,必须轮换后再使用。
# JWT_SECRET 至少要 32 个字符,缺失时服务会拒绝启动(src/server/config.ts)。
services: services:
ln-bi: ln-bi:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.1.15
network_mode: host network_mode: host
environment: environment:
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" NODE_ENV: "production"
DB_PORT: "3306" # 生产必须保持为 0:该开关会绕过全部认证。
DB_USER: "oneos_db_prod" DEV_BYPASS_AUTH: "0"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1" DB_HOST: "${DB_HOST:?请注入主业务库地址}"
DB_NAME: "ln_asset_management" DB_PORT: "${DB_PORT:-3306}"
MILEAGE_DB_HOST: "101.133.130.65" DB_USER: "${DB_USER:?请注入主业务库只读账号}"
MILEAGE_DB_PORT: "3306" DB_PASSWORD: "${DB_PASSWORD:?请注入主业务库口令}"
MILEAGE_DB_USER: "bi_reader_02" DB_NAME: "${DB_NAME:-ln_asset_management}"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass" # 氢能库未配置时复用主库,因此默认不再单独注入;如需独立只读库再配置。
MILEAGE_DB_NAME: "hydrogen_energy" HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:-}"
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:-}"
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:-}"
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:-}"
HYDROGEN_TENANT_ID: "${HYDROGEN_TENANT_ID:-000000}"
# ECS production accesses OneOS over the private network. Configure the key # ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file. # as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}" ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
@@ -22,7 +31,9 @@ services:
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}" ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "8111" SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com" EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7" BI_AUTH_MODE: "${BI_AUTH_MODE:-sso}"
BI_AUTH_PASSWORD: "${BI_AUTH_PASSWORD:-}"
JWT_SECRET: "${JWT_SECRET:?请注入至少 32 字符的随机签名密钥}"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}" AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}" AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}" HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
@@ -31,6 +42,13 @@ services:
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}" HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}" HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}" HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
# 用户反馈截图上传所需;此前缺失会导致反馈上传必然失败。
OSS_REGION: "${OSS_REGION}"
OSS_ENDPOINT: "${OSS_ENDPOINT}"
OSS_BUCKET: "${OSS_BUCKET}"
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID}"
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET}"
OSS_BASE_DIR: "${OSS_BASE_DIR}"
deploy: deploy:
replicas: 1 replicas: 1
restart_policy: restart_policy:
BIN
View File
Binary file not shown.
+165
View File
@@ -0,0 +1,165 @@
# 架构与分层约定
本文是这个仓库的结构契约。**6 条硬规则由 `src/architecture.test.ts` 断言**,破坏即测试失败。
## 1. 分层与依赖方向
```
app/ 应用外壳(路由、导航注册)
modules/ 前端业务域 —— 只依赖 shared/
shared/ 叶子层:角色常量、跨端 DTO、纯工具(不得依赖 modules/ 或 server/
server/ 后端 —— 只依赖 shared/,不得依赖 modules/
```
**依赖只能向下**。前端通过 HTTP 调用后端,不通过 import。
### 6 条硬规则
| # | 规则 | 违反后果 |
| --- | --- | --- |
| 1 | `server/**` 不得 import `modules/**` | 服务端被前端类型/组件绑架,无法独立部署与测试 |
| 2 | `modules/**``components/**``auth/**``app/**` 不得 import `server/**` | 打包会拖入服务端代码与密钥 |
| 3 | `shared/**` 不得 import `modules/**` / `server/**` / `components/**` | 叶子层反向依赖会让依赖图成环 |
| 4 | 不得出现 `vendor/` 引用 | 生产代码必须位于业务域目录,不能藏在"第三方/参考"目录里 |
| 5 | `model.ts` / `model.tsx` 不得 import `react` | 纯模型必须能在 Node 里直接单测,不依赖 DOM |
| 6 | `@ts-nocheck` 只允许出现在已登记的原型快照文件 | 类型门禁对最大文件失效 |
### 关于第 6 条的豁免清单
这三个文件是**逐字节保留**的 8113 验收原型(UI / CSS 冻结,改动需重新做桌面与移动端截图验收):
```
modules/energy/hydrogen/board/EnergyBiBoardApp.tsx
modules/energy/hydrogen/station-daily/StationDailyApp.tsx
modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx
```
豁免清单是**只减不增**的:新增 `@ts-nocheck` 会被测试拦下,请改为修正类型。
## 2. 前端业务域的形状
每个域尽量保持同一形状,便于"进一个目录就知道从哪读起":
```
modules/<domain>/
index.tsx 域入口组件(导出给 app/modules.ts 注册)
api.ts 只做 HTTP,不做业务判断
types.ts DTO
model.ts 纯函数:筛选 / 排序 / 聚合 / 口径(可被 node:test 直接测)
components/ 无状态展示组件
*.css 域内样式
```
复杂域可以再分子域,例如氢能:
```
modules/energy/hydrogen/
index.tsx 导航入口
api.ts types.ts 数据访问与 DTO
model/ 纯逻辑(格式化、承担方标签、按日汇总格式)
board/ 经营看板 UI
station-daily/ 单站日报 UI
drill/ 下钻弹层 UI
common/ 域内共享工具与组件
fonts/ 看板专用字体
```
判定标准很简单:**能在 Node 里直接测的放 `model/`;需要浏览器/DOM 的放 UI 子目录。**
## 3. 后端结构
```
server/
config.ts 环境变量集中读取 + assertRuntimeConfig() 启动期校验
app.ts createApp():装配中间件、挂载路由、静态托管(无副作用,可测试)
index.ts 进程启动:config 校验 → app → bootstrap → listen
local.ts 只读预览启动(不建表、不定时任务)
bootstrap.ts 启动副作用总入口
db/ mysql / hydrogen / heatmap 连接
db/schema/ 运行时建表/改表的唯一位置(只读模式下整体跳过)
middleware/ authJWT → 注入 user)、read-only
auth/ 登录换票、固定密码、权限过滤与脱敏
routes/<domain>/ 按业务域的 HTTP 路由
```
### 后端业务域的形状(`server/routes/<domain>/`
命名约定:`index.ts` 是**聚合器**(把子路由挂到前缀上),`routes.ts` 是**叶子路由**。
| 域 | 文件 | 说明 |
| --- | --- | --- |
| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | ✅ 完整分层;SQL 有契约测试 |
| `ele/` | `routes.ts` `repository.ts` `model.ts`+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 |
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`+ `routes.test.ts` | ✅ 完整分层;同上 |
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `repository.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | ✅ 完整分层;车辆关联信息 SQL 与 18 个查询集中在此 |
| `energy/` | `index.ts`(聚合)+ `electric.ts` `etc.ts` `hydrogen-station-board.ts` `hydrogen-bi-v2.ts` + `repository.ts` + `query-model.ts` `cache.ts` `constants.ts` | ✅ 完整分层;WHERE 片段组装(`buildFilterClauses` / `buildGroupSelect` / `buildScopedWhere` / `resolvedWhere`)与共享 SQL 片段也都在 repository / constants |
| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` `repository.ts` + `algorithm.ts` `notification-model.ts` | ✅ 完整分层;跨域数据源(里程车辆信息 / OneOS)显式注入 |
| `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`+ `routes.test.ts` | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 |
| `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 |
**全部八个域**`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling` / `energy` / `mileage`
由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
### 中间件顺序(在 `app.ts` 中显式体现)
```
cors → read-only → /api/auth(公开) → authMiddleware → 各业务域路由
→ /api/health → /api/* 未匹配返回 JSON 404 → 静态托管 + SPA 回退
```
两个已经踩过的坑,改动时不要退回去:
- **未匹配的 `/api/*` 必须返回 JSON 404**。若落到 SPA 回退,会返回 `200 text/html`
前端 `res.ok` 为真后在 `res.json()` 抛解析错误,把"路由不存在"伪装成数据问题。
- **模块守卫必须 fail-closed**,写法是 `if (!canAccessX(user?.roles))`
不能写成 `if (user && !canAccessX(user.roles))`——后者在 `user` 缺失时静默放行。
### 角色与数据权限
- 角色常量唯一真源:`src/shared/auth/roles.ts`(前后端共用)。
- 数据范围过滤与客户名脱敏:`src/server/auth/permissions.ts`
- `permissionLevel` 只表示**数据范围**,不等于模块权限;能源/调度/反馈各自另有白名单。
## 4. 新增一个业务模块的步骤
1. `src/modules/<domain>/`:建 `api.ts`HTTP)、`model.ts`(纯逻辑)、`index.tsx`(入口)、`components/`
2. `src/app/modules.ts`:注册导航项,必要时用 `roles.ts` 的判断控制可见性。
3. `src/server/routes/<domain>.ts`(或 `routes/<domain>/`):实现接口;需要模块级权限就加 **fail-closed** 守卫。
4. 数据权限:读取后调用 `filterByPermission` + `maskCustomerNames`,或写等价的 SQL 过滤。
5. 测试:纯逻辑进 `model.test.ts`;接口口径用**固定夹具**锁 SQL 与参数(参考 `server/routes/energy/routes.test.ts`)。
6.`npm run lint && npm test && npm run build`
## 5. 已知未尽事项
诚实记录,避免后来者以为已经做完:
- **后端八个域都已拆出 repository**,SQL 只允许出现在各域的 `repository.ts`(以及共享片段模块
`energy/constants.ts`)中,由架构测试守护。新增查询请沿用同一形状:路由只做校验与组装,
SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kBgzip 50 kB),来自原型渲染方式(大量内联样式)。
### 已收敛的重复实现(不要退回多份)
| 能力 | 唯一实现 | 由测试守护 |
| --- | --- | --- |
| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` |
| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` |
| 运行时 DDL(建表/改表) | `src/server/db/schema/*` | 架构测试:其他 server 文件不得出现 `CREATE TABLE` / `ALTER TABLE`;每个 schema 模块必须检查 `ddlAllowed()` |
| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts``modules/energy/daily-range/model.ts` | `date-range` 暂无独立测试;改动请补 |
| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` |
| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` |
| 环境变量读取与校验 | `src/server/config.ts` | `auth/password.test.ts` 覆盖密码模式;JWT 缺失拒绝启动已实测 |
BIN
View File
Binary file not shown.
@@ -43,7 +43,7 @@ const mileagePool = mysql.createPool({
host: '101.133.130.65', host: '101.133.130.65',
port: 3306, port: 3306,
user: 'bi_reader_02', user: 'bi_reader_02',
password: 'bi_reader_02_Pass', password: process.env.MILEAGE_DB_PASSWORD,
database: 'hydrogen_energy', database: 'hydrogen_energy',
waitForConnections: true, waitForConnections: true,
connectionLimit: 5, connectionLimit: 5,
@@ -20,7 +20,7 @@
### 数据库 2hydrogen_energy(新增连接) ### 数据库 2hydrogen_energy(新增连接)
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy` - 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `<见环境变量 MILEAGE_DB_PASSWORD>`,库名 `hydrogen_energy`
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source - `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source
## 架构 ## 架构
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.15", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.15", "version": "1.2.0",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.15", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"", "dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
@@ -13,7 +13,7 @@
"build": "vite build", "build": "vite build",
"start": "node --import tsx src/server/index.ts", "start": "node --import tsx src/server/index.ts",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)", "test": "node --import tsx --test $(find src -type f \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print | sort)",
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts" "test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
}, },
"dependencies": { "dependencies": {
-178
View File
@@ -1,178 +0,0 @@
沪A00113F
沪A00220F
沪A00333F
沪A00607F
沪A01056F
沪A01311F
沪A01775F
沪A01813F
沪A01855F
沪A02303F
沪A02311F
沪A02326F
沪A02361F
沪A02720F
沪A03086F
沪A03397F
沪A03565F
沪A03620F
沪A03659F
沪A03801F
沪A03870F
沪A05035F
沪A05113F
沪A05223F
沪A05501F
沪A05675F
沪A05697F
沪A05830F
沪A06335F
沪A06599F
沪A06695F
沪A07006F
沪A07153F
沪A07806F
沪A08037F
沪A08150F
沪A08315F
沪A08598F
沪A08786F
沪A09100F
沪A09251F
沪A09276F
沪A09303F
沪A09313F
沪A09322F
沪A09689F
沪A30010F
沪A30399F
沪A31031F
沪A31211F
沪A31281F
沪A31308F
沪A31381F
沪A31613F
沪A32269F
沪A33216F
沪A35236F
沪A35798F
沪A35879F
沪A35898F
沪A36133F
沪A36169F
沪A36569F
沪A36980F
沪A37785F
沪A38795F
沪A39287F
沪A39289F
沪A39585F
沪A39608F
沪A39626F
沪A39815F
沪A39835F
沪A39912F
沪A50026F
沪A50069F
沪A50309F
沪A51580F
沪A51612F
沪A51677F
沪A51893F
沪A52331F
沪A52511F
沪A53309F
沪A53322F
沪A53506F
沪A53960F
沪A55179F
沪A55297F
沪A55339F
沪A55666F
沪A55695F
沪A56122F
沪A56701F
沪A56959F
沪A56988F
沪A57139F
沪A57167F
沪A57198F
沪A57838F
沪A57850F
沪A57895F
沪A58087F
沪A58159F
沪A58185F
沪A58307F
沪A58533F
沪A58538F
沪A58593F
沪A58922F
沪A59095F
沪A59510F
沪A59613F
沪A59682F
沪A59799F
沪A59932F
沪A60339F
沪A60691F
沪A60820F
沪A61187F
沪A61193F
沪A61312F
沪A61559F
沪A61600F
沪A61711F
沪A61738F
沪A62322F
沪A62772F
沪A62928F
沪A63013F
沪A63305F
沪A63522F
沪A63660F
沪A63697F
沪A65036F
沪A65181F
沪A65522F
沪A65995F
沪A66216F
沪A66256F
沪A66329F
沪A66593F
沪A66710F
沪A66921F
沪A67018F
沪A67033F
沪A67872F
沪A68115F
沪A68139F
沪A68332F
沪A68613F
沪A68658F
沪A68752F
沪A69311F
沪A69826F
沪A69997F
沪A85021F
沪A89315F
沪A89385F
沪A89662F
浙F00885F
浙F08889F
浙F09898F
粤A00255F
粤A02683F
粤A02956F
粤A03502F
粤A03532F
粤A03569F
粤A05106F
粤A05391F
粤A05428F
粤A05839F
粤A05985F
粤A05995F
粤A06569F
粤A06931F
粤A06932F
-60
View File
@@ -1,60 +0,0 @@
import mysql from 'mysql2/promise';
import fs from 'node:fs';
const pool = mysql.createPool({
host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com',
port: 3306,
user: 'root',
password: 'LN#Passw0rd@2026',
database: 'lingniu_prod',
connectTimeout: 15000, ssl: { rejectUnauthorized: false },
});
async function main() {
const excelPlates = new Set(
fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim())
);
console.log('excel plates:', excelPlates.size);
// 按 dept-stats 逻辑查金可鹏 18T Operating
const [rows] = await pool.query<any[]>(`
SELECT truck.plate_number AS plate,
dic_type.dic_name AS type_label,
dic_status.dic_name AS status_label,
cus.customer_name AS customer,
org_truck.org_name AS subject_org
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code='dic_truck_type' AND dic_type.dic_code=truck.model AND dic_type.is_deleted=0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0
LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0
LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0
LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0
LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0
LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0
WHERE truck.is_deleted=0 AND truck.is_operation=1
AND u.user_name='金可鹏'
AND dic_type.dic_name LIKE '%18吨%'
AND dic_status.dic_name IN ('租赁','自营','挂靠')
ORDER BY truck.plate_number
`);
console.log('DB 金可鹏 18T operating:', rows.length);
const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim()));
const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort();
const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort();
console.log('\n=== DB 有但 Excel 没有(多出来的) ===');
console.log('数量:', extra.length);
for (const p of extra) {
const r = (rows as any[]).find((x) => x.plate === p);
console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org);
}
console.log('\n=== Excel 有但 DB 没有 ===');
console.log('数量:', missing.length);
for (const p of missing) console.log(' ', p);
await pool.end();
}
main().catch((e) => { console.error(e); process.exit(1); });
+1 -1
View File
@@ -16,7 +16,7 @@ const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage")); const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const HydrogenPrototypeBoard = lazy( const HydrogenPrototypeBoard = lazy(
() => () =>
import("./vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp").then( import("./modules/energy/hydrogen/board/EnergyBiBoardApp").then(
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }), ({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
), ),
); );
+1 -1
View File
@@ -15,7 +15,7 @@ import type { PathSet } from './routing';
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule')); const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
const MileageModule = lazy(() => import('../modules/mileage/MileageModule')); const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule')); const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule')); const HydrogenModule = lazy(() => import('../modules/energy/hydrogen/index'));
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule')); const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
const EtcModule = lazy(() => import('../modules/energy/EtcModule')); const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule')); const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
+206
View File
@@ -0,0 +1,206 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
/**
* 架构守护测试。
*
* 这些不是代码风格偏好,而是这个仓库赖以保持"能读懂"的结构约束。
* 一旦被打破(尤其是 client/server 互相引用、shared 反向依赖上层),
* 依赖图会重新变成无法追踪的网,所以用断言固化。
*/
const srcDir = path.dirname(fileURLToPath(import.meta.url));
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(ts|tsx|css)$/.test(entry.name)) out.push(full);
}
return out;
}
const allFiles = walk(srcDir);
const rel = (file: string) => path.relative(srcDir, file);
/** 取出文件里所有 import 说明符(含副作用 import / 动态 import / @import)。 */
function specifiers(file: string): string[] {
const source = readFileSync(file, "utf8");
const found: string[] = [];
const patterns = [
/(?:^|[^\w$])(?:import|export)\s+[\s\S]*?\sfrom\s*['"]([^'"]+)['"]/g,
/(?:^|[^\w$])import\s*['"]([^'"]+)['"]/g,
/(?:^|[^\w$])import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
/@import\s+['"]([^'"]+)['"]/g,
];
for (const re of patterns) {
for (const match of source.matchAll(re)) found.push(match[1]);
}
return found;
}
/** 把相对 import 解析为 src 下的路径(用于判断它指向哪个分区)。 */
function targetPath(file: string, spec: string): string | null {
if (!spec.startsWith(".")) return null;
return path.relative(srcDir, path.resolve(path.dirname(file), spec));
}
/** 去掉注释后再做 SQL 关键字匹配:注释里的 "update status" 之类不应触发守卫。 */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
}
function filesUnder(prefix: string): string[] {
return allFiles.filter((f) => rel(f).startsWith(prefix));
}
function fileContains(file: string, re: RegExp): boolean {
return re.test(readFileSync(file, "utf8"));
}
test("分层铁律:server 不得依赖前端 modules", () => {
const violations: string[] = [];
for (const file of filesUnder("server/")) {
for (const spec of specifiers(file)) {
const target = targetPath(file, spec);
if (target && target.startsWith("modules/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
assert.deepEqual(violations, [], "server 必须独立于前端:请把共享内容下沉到 src/shared");
});
test("分层铁律:前端不得依赖 server", () => {
const violations: string[] = [];
const clientDirs = ["modules/", "components/", "auth/", "app/"];
for (const dir of clientDirs) {
for (const file of filesUnder(dir)) {
for (const spec of specifiers(file)) {
const target = targetPath(file, spec);
if (target && target.startsWith("server/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
}
assert.deepEqual(violations, [], "前端不得直接引用服务端模块");
});
test("分层铁律:shared 是最底层,不得反向依赖上层", () => {
const violations: string[] = [];
for (const file of filesUnder("shared/")) {
for (const spec of specifiers(file)) {
const target = targetPath(file, spec);
if (target && (target.startsWith("modules/") || target.startsWith("server/") || target.startsWith("components/"))) {
violations.push(`${rel(file)} -> ${spec}`);
}
}
}
assert.deepEqual(violations, [], "src/shared 必须保持为可被前后端共同依赖的叶子层");
});
test("原型代码不再散落在 vendor 目录", () => {
const violations: string[] = [];
for (const file of allFiles) {
for (const spec of specifiers(file)) {
if (spec.includes("vendor/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
assert.deepEqual(violations, [], "生产代码应位于 src/modules 下的业务域目录,而不是 src/vendor");
});
test("纯模型文件不得依赖 React(保证可被 node:test 直接测)", () => {
const violations: string[] = [];
for (const file of allFiles.filter((f) => /(^|\/)model\.tsx?$/.test(rel(f)))) {
for (const spec of specifiers(file)) {
if (spec === "react" || spec.startsWith("react/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数");
});
test("高德 SDK 只在 shared/amap.ts 接入", () => {
// 两个热力图曾各自写一份 load + 底图 + 控件 + 色带,导致同类地图长得不一样。
const importers = allFiles
.filter((f) => rel(f) !== "shared/amap.ts")
.filter((f) => specifiers(f).some((s) => s === "@amap/amap-jsapi-loader"))
.map(rel);
assert.deepEqual(importers, [], "请通过 src/shared/amap.ts 使用高德地图,不要各自加载 SDK");
});
test("Excel 导出只在 shared/xlsx.ts 组装工作簿", () => {
// 允许 server 侧直接读 xlsx(导入解析),但不允许前端再次自行拼装 workbook。
const offenders = allFiles
.filter((f) => rel(f).startsWith("modules/"))
.filter((f) => {
const source = readFileSync(f, "utf8");
return /book_new|book_append_sheet|writeFile\(/.test(source);
})
.map(rel);
assert.deepEqual(offenders, [], "请改用 src/shared/xlsx.ts 的 writeWorkbook / export*Sheet");
});
test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => {
// 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。
const allowed = [
"modules/energy/hydrogen/board/EnergyBiBoardApp.tsx",
"modules/energy/hydrogen/station-daily/StationDailyApp.tsx",
"modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx",
];
const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort();
assert.deepEqual(actual, [...allowed].sort(), "新增 @ts-nocheck 会让类型门禁失效,请改为修正类型");
});
test("运行时 DDL 只允许出现在 server/db/schema", () => {
// 建表原先散落在路由与 store 里,且由 GET 触发,使"只读预览"仍可能写库。
const offenders = allFiles
.filter((f) => rel(f).startsWith("server/") && !rel(f).startsWith("server/db/schema/"))
.filter((f) => !rel(f).endsWith(".test.ts"))
.filter((f) => /CREATE TABLE|ALTER TABLE/.test(readFileSync(f, "utf8")))
.map(rel);
assert.deepEqual(offenders, [], "请把建表/改表语句集中到 src/server/db/schema/");
});
test("运行时建表必须尊重只读模式", () => {
const schemaFiles = allFiles.filter((f) => rel(f).startsWith("server/db/schema/") && rel(f) !== "server/db/schema/guard.ts");
const offenders = schemaFiles
.filter((f) => !readFileSync(f, "utf8").includes("ddlAllowed()"))
.map(rel);
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
});
test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => {
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
const layered = [
"server/routes/vehicles",
"server/routes/ele",
"server/routes/feedback",
"server/routes/vehicle-heatmap",
"server/routes/hydrogen-heatmap",
"server/routes/scheduling",
"server/routes/energy",
"server/routes/mileage",
];
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
// 不应误判,而小写 SQL 也依然能被抓到。
const SQL = /\bselect\b[\s\S]{0,400}?\bfrom\b|\binsert\s+(into|ignore)\b|\bupdate\s+[\w.`]+\s+set\b|\bdelete\s+from\b|\bcreate\s+table\b|\balter\s+table\b/i;
const offenders: string[] = [];
for (const dir of layered) {
const abs = path.join(srcDir, dir);
if (!existsSync(path.join(abs, "repository.ts"))) {
offenders.push(`${dir}/repository.ts 缺失`);
continue;
}
for (const file of readdirSync(abs)) {
if (!file.endsWith(".ts")) continue;
// repository.ts 是 SQL 的归属地;constants.ts 允许存放被多条查询共享的
// SQL 片段(如公共 WHERE / CTE),否则这些片段会被迫复制到每个查询里。
if (file === "repository.ts" || file === "constants.ts" || file.endsWith(".test.ts")) continue;
if (SQL.test(stripComments(readFileSync(path.join(abs, file), "utf8")))) {
offenders.push(`${dir}/${file} 仍含 SQL`);
}
}
}
assert.deepEqual(offenders, [], "SQL 必须留在各域的 repository.ts");
});
-17
View File
@@ -1,17 +0,0 @@
import { createContext, useContext, type ReactNode } from 'react';
const DemoModeContext = createContext(false);
export function DemoModeProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
return <DemoModeContext.Provider value={enabled}>{children}</DemoModeContext.Provider>;
}
export function useDemoMode() {
return useContext(DemoModeContext);
}
export default function Blur({ children }: { children: ReactNode }) {
const demo = useContext(DemoModeContext);
if (!demo) return <>{children}</>;
return <span className="blur-[5px] select-none">{children}</span>;
}
-3
View File
@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Sus
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Building2, ChevronRight } from 'lucide-react'; import { Building2, ChevronRight } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab'; import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface'; import { LoadingState } from './ui/surface';
@@ -93,7 +92,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [user]); }, [user]);
return ( return (
<DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen"> <div className="enterprise-grid-bg flex min-h-screen">
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */} {/* 氢费看板按原型交付;其他模块继续保留全局水印。 */}
{!isHydrogenPrototype ? ( {!isHydrogenPrototype ? (
@@ -220,6 +218,5 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
</div> </div>
</nav> </nav>
</div> </div>
</DemoModeProvider>
); );
} }
+14 -17
View File
@@ -9,7 +9,7 @@ import {
MapPin, MapPin,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import * as XLSX from 'xlsx'; import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -40,7 +40,6 @@ import {
} from './model'; } from './model';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
import { AssetsHeader } from './components/AssetsHeader'; import { AssetsHeader } from './components/AssetsHeader';
@@ -393,7 +392,7 @@ export default function AssetsModule() {
业务负责人: item.manager || '', 业务负责人: item.manager || '',
客户: item.customerName || '', 客户: item.customerName || '',
})); }));
const ws = XLSX.utils.json_to_sheet(table); const ws = buildJsonSheet(table);
ws['!cols'] = [ ws['!cols'] = [
{ wch: 14 }, { wch: 14 },
{ wch: 8 }, { wch: 8 },
@@ -404,9 +403,7 @@ export default function AssetsModule() {
{ wch: 14 }, { wch: 14 },
{ wch: 24 }, { wch: 24 },
]; ];
const wb = XLSX.utils.book_new(); writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]); }, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
@@ -705,7 +702,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className="font-bold text-gray-700 text-xs"><Blur>{m.manager}</Blur></span> <span className="font-bold text-gray-700 text-xs">{m.manager}</span>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -787,7 +784,7 @@ export default function AssetsModule() {
> >
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1"> <td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
<Blur>{m.manager}</Blur> {m.manager}
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
<td <td
@@ -907,7 +904,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
<span className="text-[11px] font-bold text-gray-700"><Blur>{m.manager}</Blur></span> <span className="text-[11px] font-bold text-gray-700">{m.manager}</span>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -986,7 +983,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<h3 className="text-sm font-bold text-gray-800 shrink-0"><Blur>{m.manager}</Blur></h3> <h3 className="text-sm font-bold text-gray-800 shrink-0">{m.manager}</h3>
<span className="text-[11px] text-gray-500 shrink-0">{m.department}</span> <span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
<div <div
className="text-[11px] font-bold text-blue-600 whitespace-nowrap" className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
@@ -1510,12 +1507,12 @@ export default function AssetsModule() {
> >
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2"> <td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2">
{isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />} {isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
<Blur>{cust.customer}</Blur> {cust.customer}
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">
<span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span> <span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{cust.manager}</Blur></td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{cust.manager}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td>
@@ -1530,7 +1527,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700"><Blur>{cust.customer}</Blur></div> <div className="text-sm font-bold text-gray-700">{cust.customer}</div>
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
@@ -1540,7 +1537,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700"><Blur>{cust.manager}</Blur></div> <div className="text-sm font-bold text-gray-700">{cust.manager}</div>
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div> <div className="text-[10px] text-gray-400 uppercase mb-1"></div>
@@ -1574,7 +1571,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />} {isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm"><Blur>{cust.customer}</Blur></span> <span className="font-bold text-gray-800 text-sm">{cust.customer}</span>
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span> <span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span>
</div> </div>
</div> </div>
@@ -1592,7 +1589,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-[10px] font-bold text-gray-700"><Blur>{cust.customer}</Blur></div> <div className="text-[10px] font-bold text-gray-700">{cust.customer}</div>
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
@@ -1602,7 +1599,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-xs font-bold text-gray-700"><Blur>{cust.manager}</Blur></div> <div className="text-xs font-bold text-gray-700">{cust.manager}</div>
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div> <div className="text-[8px] text-gray-400 uppercase mb-1"></div>
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import Blur from '../../../components/Blur';
import { SearchSelect } from '../../../components/SearchSelect'; import { SearchSelect } from '../../../components/SearchSelect';
import type { WeeklyDetailItem } from '../api'; import type { WeeklyDetailItem } from '../api';
import type { ModalVehicleFilters, VehicleModalSelection } from '../model'; import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
@@ -167,8 +166,8 @@ export function VehicleDetailModal({
<tbody className="text-[11px]"> <tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => ( {filteredModalWeeklyDetail.map((v, i) => (
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}> <tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td> <td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center">{v.plate_number}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.customer_name || '—'}</td>
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td> <td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr> </tr>
))} ))}
@@ -215,12 +214,12 @@ export function VehicleDetailModal({
{showPlateNumbers.source === 'customer' ? ( {showPlateNumbers.source === 'customer' ? (
<> <>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td> <td className="p-2 border-r border-gray-100 font-medium text-gray-700">{v.customerManager || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td> <td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]">{v.subjectOrg || '—'}</td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td> <td className="p-2 border-r border-gray-100 font-bold text-gray-800">{v.customerName || '—'}</td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td> <td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
<td className="p-2 border-r border-gray-100 text-center"> <td className="p-2 border-r border-gray-100 text-center">
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${ <span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' : v.status === 'Operating' ? 'bg-green-100 text-green-700' :
@@ -233,13 +232,13 @@ export function VehicleDetailModal({
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td> <td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td> <td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td> <td className="p-2 text-gray-500 text-[10px]">{v.orgName || '—'}</td>
</> </>
) : ( ) : (
<> <>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td> <td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && ( {showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td> <td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center">{v.customerName || '—'}</td>
)} )}
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
@@ -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>
);
}
-304
View File
@@ -1,304 +0,0 @@
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 {
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';
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';
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
const [datasets, setDatasets] = useState<DailyDatasets>({ lingniu: null, external: null });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [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]);
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);
});
return () => { cancelled = true; };
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
const allRows = useMemo(
() => mergeHydrogenDailyRows(datasets.lingniu, datasets.external),
[datasets.external, datasets.lingniu],
);
const vehicleRows = useMemo(() => {
if (vehicleScope === 'lingniu') return datasets.lingniu;
if (vehicleScope === 'external') return datasets.external;
return allRows;
}, [allRows, datasets.external, datasets.lingniu, vehicleScope]);
const stationOptions = useMemo(() => getHydrogenDailyStations(vehicleRows), [vehicleRows]);
useEffect(() => {
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);
}
return next;
});
};
const handleSelectDate = (date: string) => {
setExpanded((previous) => new Set(previous).add(date));
void loadDetail(date);
setHighlightedDate(date);
const element = document.getElementById(`hydrogen-daily-row-${date}`);
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
if (loading && !scopedRows) {
return <LoadingState label="正在加载氢气按日看板数据..." />;
}
if (error && !scopedRows) {
return <ErrorState message={error} />;
}
return (
<div className="flex flex-col gap-3">
<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));
}}
onCustomPick={() => setPick('custom')}
onDateRangeChange={(field, value) => {
setPick('custom');
setDateRange((prev) => ({ ...prev, [field]: value }));
}}
onCustomerChange={(cust: CustomerType) => {
setVehicleScope(cust === 'all' ? 'all' : cust === 'lingniu' ? 'lingniu' : 'external');
}}
onStationChange={scope === 'station' ? setSelectedStationId : undefined}
onVehicleScopeChange={setVehicleScope}
onRefresh={() => setRefreshKey((k) => k + 1)}
/>
{error ? (
<div className="flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
<div className="flex items-center gap-2">
<AlertTriangle size={14} className="text-amber-600" />
<span>{error}</span>
</div>
<button
type="button"
onClick={() => setRefreshKey((k) => k + 1)}
className="inline-flex items-center gap-1 font-bold text-amber-900 hover:underline"
>
<RefreshCw size={12} />
</button>
</div>
) : null}
{scope === 'station' && selectedStation ? (
<StationDailyOverview
station={selectedStation}
totalKg={summary.totalKg}
totalFee={summary.totalFee}
averagePrice={summary.avgPrice}
/>
) : null}
<DailyKpiGrid
rangeLabel={getRangeModeLabel(pick)}
rangeText={`${effectiveRange.start}${effectiveRange.end}`}
totalKg={summary.totalKg}
activeDays={summary.activeDays}
dayCount={dayCount}
averageKg={summary.avgKg}
stationCount={summary.stationCount}
vehicleScope={vehicleScope}
lingniuKg={lingniuSum}
externalKg={externalSum}
selectedStationName={scope === 'station' ? selectedStation?.name : undefined}
/>
<DailyTrendChart
rows={trend}
averageKg={summary.avgKg}
peakDay={summary.peakDay}
lowDay={summary.lowDay}
zeroDays={summary.zeroDays}
selectedDate={highlightedDate}
onSelectDate={handleSelectDate}
/>
{scopedRows && scopedRows.length > 0 ? (
<DailyDetailTable
rows={scopedRows}
totalKg={summary.totalKg}
totalFee={summary.totalFee}
expanded={expanded}
highlightedDate={highlightedDate}
details={details}
onToggle={toggleRow}
onRetryDetail={(date) => void loadDetail(date, true)}
/>
) : (
<EmptyState
title="当前筛选条件下没有加氢记录"
description="可尝试切换统计区间、车辆范围或站点。"
/>
)}
</div>
);
}
-282
View File
@@ -1,282 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchHydrogenOverview,
fetchHydrogenOverviewDetail,
type HydrogenOverviewResponse,
type HydrogenVehicleScope,
type HydrogenVerifyScope,
} from './api';
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
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';
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);
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [loading, setLoading] = useState(true);
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);
try {
const result = await fetchHydrogenOverview({
year: activeYear,
vehicleScope,
verifyScope,
stationId: effectiveStationId,
force,
});
setData(result);
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));
} finally {
setLoading(false);
setRefreshing(false);
}
}, [activeYear, currentYear, effectiveStationId, onRangeTextChange, vehicleScope, verifyScope]);
useEffect(() => {
void load(false);
}, [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 (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) {
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: '元' };
return (
<>
<OverviewHeader
activeYear={activeYear}
availableYears={DEFAULT_AVAILABLE_YEARS}
vehicleScope={vehicleScope}
verifyScope={verifyScope}
latestLedgerTime={data?.latestLedgerTime ?? null}
lastRefreshAt={lastRefreshAt}
refreshing={refreshing}
onSelectYear={(y) => setActiveYear(y)}
onVehicleScopeChange={(vs) => setVehicleScope(vs)}
onVerifyScopeChange={setVerifyScope}
onRefresh={() => void load(true)}
/>
{data && derived ? (
<section className="ehb-host" aria-label="经营总览">
<KpiSection
kpi={data.kpi}
scope={scope}
scopeLabel={selectedStationName}
onDrillRequest={handleDrillRequest}
/>
<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}
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)}
/>
)}
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
</>
);
}
-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>;
}
-34
View File
@@ -1,34 +0,0 @@
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
import HydrogenSettlement from './HydrogenSettlement';
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
export type HydrogenSubTab = 'daily' | 'overview' | 'settlement';
interface Props {
sub: HydrogenSubTab;
onSubChange?: (sub: HydrogenSubTab) => void;
onScopeChange?: (scope: HydrogenBoardScope) => void;
onDailyRangeTextChange?: (rangeText: string) => void;
onOverviewRangeTextChange?: (rangeText: string) => void;
}
export default function HydrogenView({
sub,
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} />;
}
-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',
);
});
+21 -153
View File
@@ -1,117 +1,17 @@
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import type { import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick, CustomerType, DateQuickPick,
HydrogenStationBoardResponse, HydrogenStationBoardResponse,
HydrogenDailyDetailResponse,
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailResponse,
} from './types'; } from './types';
const BASE = '/api/energy'; const BASE = '/api/energy';
export interface HydrogenOverviewResponse { /**
kpi: HydrogenKpi; * 氢能单站日报数据源。
top5: HydrogenStationTop[]; * 注意:该接口路径仍带 `/hydrogen` 前缀(历史命名),但为当前单站日报页面在用,
regions: HydrogenRegionShare[]; * 与已下线的 `/hydrogen/overview|daily|settlement` 等旧接口无关。
monthly: HydrogenMonthlyPoint[]; */
customers: HydrogenCustomerRow[];
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> {
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');
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[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
if (query.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 { export interface HydrogenStationBoardQuery {
startDate: string; startDate: string;
endDate: string; endDate: string;
@@ -126,6 +26,13 @@ export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Pro
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`); return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`);
} }
/** 电能与 ETC 共用的自然日区间查询参数。 */
export interface EnergyRangeQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
}
export interface ElectricOverviewResponse { export interface ElectricOverviewResponse {
kpi: ElectricKpi; kpi: ElectricKpi;
trend: ElectricDailyRow[]; trend: ElectricDailyRow[];
@@ -136,12 +43,15 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`); return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
} }
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> { export function fetchElectricMonthly(
const q = new URLSearchParams({ customer }); customer: CustomerType,
if (query.range) q.set('range', query.range); query: EnergyRangeQuery = { range: 'last15' },
if (query.startDate) q.set('startDate', query.startDate); ): Promise<ElectricMonthGroup[]> {
if (query.endDate) q.set('endDate', query.endDate); const params = new URLSearchParams({ customer });
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`); 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 fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${params.toString()}`);
} }
export interface EtcOverviewResponse { export interface EtcOverviewResponse {
@@ -158,45 +68,3 @@ export interface EtcOverviewResponse {
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> { export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`); 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,6 +1,5 @@
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react'; import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react';
import type { CustomerType, DateQuickPick } from '../types'; import type { CustomerType, DateQuickPick } from '../types';
import type { HydrogenDailyVehicleScope } from '../hydrogen-daily/model';
import { QUICK_PICK_OPTIONS, type RangeMode } from './model'; import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
interface DailyRangeControlsProps { interface DailyRangeControlsProps {
@@ -9,7 +8,6 @@ interface DailyRangeControlsProps {
customer: CustomerType; customer: CustomerType;
stations?: { id: number; name: string }[]; stations?: { id: number; name: string }[];
selectedStationId?: number | null; selectedStationId?: number | null;
vehicleScope?: HydrogenDailyVehicleScope;
updatedAt?: string | null; updatedAt?: string | null;
loading?: boolean; loading?: boolean;
onQuickPick: (pick: DateQuickPick) => void; onQuickPick: (pick: DateQuickPick) => void;
@@ -17,7 +15,6 @@ interface DailyRangeControlsProps {
onDateRangeChange: (field: 'start' | 'end', value: string) => void; onDateRangeChange: (field: 'start' | 'end', value: string) => void;
onCustomerChange: (customer: CustomerType) => void; onCustomerChange: (customer: CustomerType) => void;
onStationChange?: (stationId: number | null) => void; onStationChange?: (stationId: number | null) => void;
onVehicleScopeChange?: (scope: HydrogenDailyVehicleScope) => void;
onRefresh?: () => void; onRefresh?: () => void;
} }
@@ -27,7 +24,6 @@ export default function DailyRangeControls({
customer, customer,
stations = [], stations = [],
selectedStationId = null, selectedStationId = null,
vehicleScope,
updatedAt, updatedAt,
loading = false, loading = false,
onQuickPick, onQuickPick,
@@ -35,7 +31,6 @@ export default function DailyRangeControls({
onDateRangeChange, onDateRangeChange,
onCustomerChange, onCustomerChange,
onStationChange, onStationChange,
onVehicleScopeChange,
onRefresh, onRefresh,
}: DailyRangeControlsProps) { }: DailyRangeControlsProps) {
return ( return (
@@ -92,47 +87,23 @@ export default function DailyRangeControls({
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{onVehicleScopeChange && vehicleScope ? ( <div className="flex rounded-md bg-slate-100 p-0.5">
<div className="flex rounded-md bg-slate-100 p-0.5" aria-label="车辆归属"> {(['lingniu', 'external'] as const).map(option => (
{([ <button
['all', '全部车辆'], key={option}
['lingniu', '仅羚牛车辆'], type="button"
['external', '仅外部车辆'], onClick={() => onCustomerChange(option)}
] as const).map(([id, label]) => ( className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${
<button customer === option
key={id} ? 'bg-white font-semibold text-blue-600 shadow-sm'
type="button" : 'text-slate-500 hover:text-slate-800'
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 <Truck size={13} />
? 'bg-white font-semibold text-blue-600 shadow-sm' {option === 'external' ? '外部车辆' : '羚牛车辆'}
: 'text-slate-500 hover:text-slate-800' </button>
}`} ))}
> </div>
<Truck size={13} />
{label}
</button>
))}
</div>
) : (
<div className="flex rounded-md bg-slate-100 p-0.5">
{(['lingniu', 'external'] as const).map(option => (
<button
key={option}
type="button"
onClick={() => onCustomerChange(option)}
className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${
customer === option
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
<Truck size={13} />
{option === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
)}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null} {updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null}
@@ -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
@@ -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);
};
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,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,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>
);
}
@@ -1,160 +0,0 @@
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';
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
const now = new Date(2026, 7, 12, 23, 30);
assert.deepEqual(getQuickRange('thisWeek', now), {
start: '2026-08-10',
end: '2026-08-12',
});
assert.deepEqual(getQuickRange('thisMonth', now), {
start: '2026-08-01',
end: '2026-08-12',
});
assert.deepEqual(getQuickRange('last15', now), {
start: '2026-07-29',
end: '2026-08-12',
});
});
test('全部车辆由两类真实日报按日期和站点合并', () => {
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',
end: '2026-08-12',
});
assert.equal(getRangeModeLabel('custom'), '自定义区间');
assert.equal(getRangeModeLabel('last15'), '近 15 天');
});
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
const rows: HydrogenDailyRow[] = [
{
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 }],
},
{
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 }],
},
{
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 }],
},
];
const summary = summarizeHydrogenRows(rows);
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
assert.equal(summary.totalKg, 400);
assert.equal(summary.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');
assert.equal(summary.zeroDays, 1);
});
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);
});
-208
View File
@@ -1,208 +0,0 @@
import type { HydrogenDailyRow } from '../types';
export {
formatYmd,
getQuickRange,
getRangeModeLabel,
normalizeRange,
QUICK_PICK_OPTIONS,
type RangeMode,
} from '../daily-range/model';
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
const source = rows ?? [];
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
const 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 peakDay = trendData.reduce<HydrogenDailyRow | null>(
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
null,
);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>(
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
null,
);
return {
trendData,
totalKg,
totalFee,
activeDays,
stationCount: stationIds.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));
}
@@ -1,201 +0,0 @@
import {
Bar,
BarChart,
Cell,
LabelList,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
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';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps {
x?: number;
y?: number;
index?: number;
payload?: { value: string };
}
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
return (
<g transform={`translate(${x},${y})`}>
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
{index + 1}
</text>
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
{payload?.value}
</text>
</g>
);
}
interface DistributionChartsProps {
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
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}` : '';
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>
<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);
}}
>
<XAxis type="number" hide />
<YAxis
dataKey="name"
type="category"
axisLine={false}
tickLine={false}
width={180}
tick={<RankYAxisTick />}
/>
<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`, '加氢量']}
/>
<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>
</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="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
<PieChart>
<Pie
data={visibleRegions}
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) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-[10px] text-slate-400 font-bold"></div>
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{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">
<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>
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
@@ -1,80 +0,0 @@
export function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" />
</div>
{/* 5 卡占位 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-xl bg-slate-100" />
<div className="h-3 w-16 bg-slate-100 rounded" />
</div>
<div className="h-7 w-24 bg-slate-200 rounded" />
<div className="space-y-1.5 pt-1 border-t border-slate-50">
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
</div>
</div>
))}
</div>
{/* 月度柱图占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="flex items-end gap-2 h-[120px]">
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="space-y-3">
{[100, 78, 56, 40, 28].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-32 bg-slate-100 rounded" />
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
<div className="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3">
<div className="w-1/2 h-[200px] flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
</div>
<div className="flex-1 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-200" />
<div className="h-3 w-16 bg-slate-100 rounded" />
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
</div>
</div>
);
}
@@ -1,176 +0,0 @@
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';
interface InsightCardsProps {
monthAvgKg: number;
bestMonth: HydrogenMonthlyPoint | null;
latestMonth: HydrogenMonthlyPoint | undefined;
monthMomentum: number | null;
top5Share: number;
customerGrossMarginPct: 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({
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
customerGrossMarginPct,
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>
<div className="ehb-insight__title"></div>
<div className="ehb-insight__value is-neg">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
<div className="ehb-insight__desc">
{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>
<div className="ehb-insight__title"></div>
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
{customerGrossMarginPct.toFixed(1)}%
</div>
<div className="ehb-insight__desc">
{yearProfitValue}{yearProfitUnit} · {yearRevenueValue}{yearRevenueUnit}
{customerGrossMarginPct < 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,104 +0,0 @@
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
import { useState, type ReactNode } from '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';
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;
}
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;
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>
<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>}
</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);
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
const ourYearKgFmt = fmtKg(k.ourYearKg);
const customerYearKgFmt = fmtKg(k.customerYearKg);
const monthKgFmt = fmtKg(k.monthKg);
const monthFeeFmt = fmtYuan(k.monthFee);
const todayKgFmt = fmtKg(k.todayKg);
const todayFeeFmt = fmtYuan(k.todayFee);
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const openDrill = (key: OverviewMetricKey) => {
if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key });
else setDrill(buildMetricDrillPayload(k, key));
};
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>
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}
@@ -1,127 +0,0 @@
import {
Bar,
BarChart,
LabelList,
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';
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
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}` : '';
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>
<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">
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
labelFormatter={(d) => `${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<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>
</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>
<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">
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Legend
verticalAlign="top"
height={20}
iconSize={8}
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
/>
<Tooltip
formatter={(v, name) => {
const f = fmtYuan(Number(v ?? 0));
return [`¥${f.value} ${f.unit}`, name];
}}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
</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 +0,0 @@
import { ChevronDown, RefreshCw, Truck } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { HydrogenVehicleScope } from '../../api';
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;
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,
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">
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
onClick={() => onVerifyScopeChange?.('all')}
>
</button>
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
onClick={() => onVerifyScopeChange?.('verified')}
title="真实账本已返回全量有效订单"
>
</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="数据刷新"
>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} aria-hidden />
</button>
</div>
</div>
</section>
);
}
@@ -1,31 +0,0 @@
import { AnimatePresence, motion } from 'motion/react';
interface RefreshOverlayProps {
refreshing: boolean;
hasData: boolean;
}
export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) {
return (
<AnimatePresence>
{refreshing && hasData && (
<motion.div
key="refresh-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
>
<motion.div
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
style={{ width: '40%' }}
/>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,228 +0,0 @@
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';
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));
};
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>
</div>
<div className="ehb-sum-table-card__meta">
{stations.length}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<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>
</thead>
<tbody>
{sortedStations.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>
</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)}%` }} />
</div>
<span className="ehb-ratio-text">{(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>
<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)}%` }} />
</div>
<span className="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</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));
};
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>
<div className="ehb-sum-table-card__meta">
Top {customers.length}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<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>
</thead>
<tbody>
{sortedCustomers.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' }}>
{c2.payer === 'lingniu' ? (
<span className="ehb-payer-tag is-own"></span>
) : c2.payer === 'mixed' ? (
<span className="ehb-payer-tag is-mix"></span>
) : (
<span className="ehb-payer-tag is-ext"></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 style={{ textAlign: 'right' }} className="col-amber-fee">
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
</td>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}
@@ -1,148 +0,0 @@
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' });
assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' });
assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' });
assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' });
});
test('相对更新时间保持秒、分钟、小时和未来时间边界', () => {
const now = new Date(2026, 7, 13, 12, 0, 0).getTime();
assert.equal(formatRelative(now + 60_000, now), '刚刚');
assert.equal(formatRelative(now - 30_000, now), '30 秒前');
assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前');
assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前');
});
test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => {
const data = {
kpi: {
yearKg: 1000,
yearFee: 0,
yearProfit: -50,
ourYearKg: 0,
customerYearKg: 0,
ourYearFee: 0,
yearRevenue: 500,
monthKg: 0,
monthFee: 0,
monthRevenue: 0,
monthProfit: 0,
todayKg: 0,
todayFee: 0,
todayRevenue: 0,
todayProfit: 0,
lingniuBornKg: 0,
lingniuBornFee: 0,
},
top5: [{ id: 1, rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
regions: [],
monthly: [
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
{ month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 },
],
customers: [],
stations: [
{ 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 },
],
availableYears: [2026],
year: 2026,
latestLedgerTime: '2026-08-17 21:50:09',
filter: { stationId: null, vehicleScope: 'all', verifyScope: 'all' },
} satisfies HydrogenOverviewResponse;
const metrics = deriveOverviewMetrics(data);
assert.equal(metrics.monthAvgKg, 125);
assert.equal(metrics.bestMonth?.month, '2026-02');
assert.equal(metrics.latestMonth?.month, '2026-02');
assert.equal(metrics.monthMomentum, 50);
assert.equal(metrics.top5Share, 60);
assert.equal(metrics.customerGrossMarginPct, -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,298 +0,0 @@
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' };
return { value: kg.toFixed(2), unit: 'Kg' };
}
export function formatYuan(yuan: number): { value: string; unit: string } {
const absolute = Math.abs(yuan);
if (absolute >= 100_000_000) {
return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
}
if (absolute >= 10_000) {
return {
value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }),
unit: '万元',
};
}
return {
value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
unit: '元',
};
}
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 '刚刚';
if (seconds < 60) return `${seconds} 秒前`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
}
export function formatRefreshTime(timestamp: number, now = Date.now()): string {
const exactTime = new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`;
}
export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
const { kpi, monthly, stations, top5 } = data;
const monthAvgKg = monthly.length > 0
? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length
: 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>(
(best, item) => (!best || item.kg > best.kg ? item : best),
null,
);
const latestMonth = monthly[monthly.length - 1];
const previousMonth = monthly[monthly.length - 2];
return {
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
: null,
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
customerGrossMarginPct: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
monthlyDual: monthly.map(month => ({
...month,
monthLabel: `${month.month.slice(5).replace(/^0/, '')}`,
})),
};
}
@@ -3,7 +3,7 @@
* 本原型独立嵌入 bi-next禁止引入 OneOS V2 组件 * 本原型独立嵌入 bi-next禁止引入 OneOS V2 组件
* 字体例外汉字/UI 与数字等宽对齐 V2 TokenDESIGN §2.2 * 字体例外汉字/UI 与数字等宽对齐 V2 TokenDESIGN §2.2
*/ */
@import '../../../resources/design-system/fonts/jetbrains-mono/jetbrains-mono.css'; @import '../../fonts/jetbrains-mono/jetbrains-mono.css';
/* —— 访问口令门(轻门禁 · 对齐宿主蓝系,非 V2 组件) —— */ /* —— 访问口令门(轻门禁 · 对齐宿主蓝系,非 V2 组件) —— */
.ehb-gate { .ehb-gate {
@@ -3,8 +3,8 @@ import test from "node:test";
import { createElement } from "react"; import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls"; import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format"; import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import type { H2BiDailyResponse } from "./types"; import type { H2BiDailyResponse } from "../types";
test("每日环比区分真实零值、缺失值和非数值", () => { test("每日环比区分真实零值、缺失值和非数值", () => {
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —"); for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
@@ -1,9 +1,10 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Download, RefreshCw, Truck } from "lucide-react"; import { Download, RefreshCw, Truck } from "lucide-react";
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls"; import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format"; import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "./api"; import { formatFixed } from "../model/display-format";
import { downloadExcelAoa } from "./prototype-download"; import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
import { exportAoaSheet } from "../../../../shared/xlsx";
import "./real-daily-mobile.css"; import "./real-daily-mobile.css";
import type { import type {
H2BiDailyResponse, H2BiDailyResponse,
@@ -11,13 +12,8 @@ import type {
H2BiDrillResponse, H2BiDrillResponse,
H2BiQuery, H2BiQuery,
H2BiVehicleScope, H2BiVehicleScope,
} from "./types"; } from "../types";
const format = (value: number, digits = 2) =>
value.toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope => const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
scope === "own" ? "lingniu" : scope; scope === "own" ? "lingniu" : scope;
const sourceLabel = (source: unknown) => { const sourceLabel = (source: unknown) => {
@@ -226,7 +222,7 @@ export function PrototypeRealDailyView({
}; };
const exportRows = () => { const exportRows = () => {
if (!daily) return; if (!daily) return;
downloadExcelAoa( exportAoaSheet(
dailySummaryRows(daily), dailySummaryRows(daily),
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`, `每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
"日期汇总", "日期汇总",
@@ -342,7 +338,7 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-kpi-card"> <div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div> <div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val"> <div className="ehb-daily-kpi-val">
{format(daily?.kpis.totalKg ?? 0)} <small>Kg</small> {formatFixed(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
</div> </div>
<div className="ehb-daily-kpi-sub"> <div className="ehb-daily-kpi-sub">
{startDate} {endDate} {startDate} {endDate}
@@ -351,7 +347,7 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-kpi-card"> <div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div> <div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val"> <div className="ehb-daily-kpi-val">
¥{format(daily?.kpis.totalCost ?? 0)} ¥{formatFixed(daily?.kpis.totalCost ?? 0)}
</div> </div>
<div className="ehb-daily-kpi-sub"></div> <div className="ehb-daily-kpi-sub"></div>
</div> </div>
@@ -359,7 +355,7 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-kpi-title"></div> <div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div> <div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
<div className="ehb-daily-kpi-sub"> <div className="ehb-daily-kpi-sub">
{format(daily?.kpis.averageDailyKg ?? 0)} Kg {formatFixed(daily?.kpis.averageDailyKg ?? 0)} Kg
</div> </div>
</div> </div>
<div className="ehb-daily-kpi-card"> <div className="ehb-daily-kpi-card">
@@ -395,12 +391,12 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-summary-pills"> <div className="ehb-daily-summary-pills">
<div className="ehb-daily-pill-item"> <div className="ehb-daily-pill-item">
<span></span> <span></span>
<strong>{peak ? `${peak.date} ${format(peak.kg)} Kg` : "—"}</strong> <strong>{peak ? `${peak.date} ${formatFixed(peak.kg)} Kg` : "—"}</strong>
</div> </div>
<div className="ehb-daily-pill-item"> <div className="ehb-daily-pill-item">
<span></span> <span></span>
<strong> <strong>
{trough ? `${trough.date} ${format(trough.kg)} Kg` : "—"} {trough ? `${trough.date} ${formatFixed(trough.kg)} Kg` : "—"}
</strong> </strong>
</div> </div>
<div className="ehb-daily-pill-item"> <div className="ehb-daily-pill-item">
@@ -416,7 +412,7 @@ export function PrototypeRealDailyView({
}} }}
> >
<span className="ehb-daily-avg-label"> <span className="ehb-daily-avg-label">
{format(averageKg)} Kg {formatFixed(averageKg)} Kg
</span> </span>
</div> </div>
{trend.map((row) => { {trend.map((row) => {
@@ -430,7 +426,7 @@ export function PrototypeRealDailyView({
key={row.date} key={row.date}
className="ehb-daily-bar-col" className="ehb-daily-bar-col"
onClick={() => openDate(row.date, true)} onClick={() => openDate(row.date, true)}
title={`${row.date} 加氢总量 ${format(row.kg)} Kg;点击展开并定位当日明细`} title={`${row.date} 加氢总量 ${formatFixed(row.kg)} Kg;点击展开并定位当日明细`}
aria-label={`展开并定位${row.date}当日加氢明细`} aria-label={`展开并定位${row.date}当日加氢明细`}
> >
<div <div
@@ -539,8 +535,8 @@ export function PrototypeRealDailyView({
<tr style={{ background: "#f8fafc", fontWeight: 700 }}> <tr style={{ background: "#f8fafc", fontWeight: 700 }}>
<td></td> <td></td>
<td /> <td />
<td>{format(daily?.kpis.totalKg ?? 0)}</td> <td>{formatFixed(daily?.kpis.totalKg ?? 0)}</td>
<td>¥{format(daily?.kpis.totalCost ?? 0)}</td> <td>¥{formatFixed(daily?.kpis.totalCost ?? 0)}</td>
<td></td> <td></td>
</tr> </tr>
{(daily?.days ?? []).map((day) => { {(daily?.days ?? []).map((day) => {
@@ -567,9 +563,9 @@ export function PrototypeRealDailyView({
</DailyTreeButton> </DailyTreeButton>
</td> </td>
<td></td> <td></td>
<td>{format(day.kg)}</td> <td>{formatFixed(day.kg)}</td>
<td> <td>
<strong className="ehb-daily-cost">{format(day.cost)}</strong> <strong className="ehb-daily-cost">{formatFixed(day.cost)}</strong>
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small> <small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
</td> </td>
<td></td> <td></td>
@@ -597,8 +593,8 @@ export function PrototypeRealDailyView({
</DailyTreeButton> </DailyTreeButton>
</td> </td>
<td></td> <td></td>
<td>{format(station.kg)}</td> <td>{formatFixed(station.kg)}</td>
<td>¥{format(station.cost)}</td> <td>¥{formatFixed(station.cost)}</td>
<td></td> <td></td>
</tr> </tr>
{stationOpen && station.customers.length === 0 ? <DailyBranchState {stationOpen && station.customers.length === 0 ? <DailyBranchState
@@ -636,8 +632,8 @@ export function PrototypeRealDailyView({
</DailyTreeButton> </DailyTreeButton>
</td> </td>
<td></td> <td></td>
<td>{format(customer.kg)}</td> <td>{formatFixed(customer.kg)}</td>
<td>¥{format(customer.cost)}</td> <td>¥{formatFixed(customer.cost)}</td>
<td></td> <td></td>
</tr> </tr>
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState {customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
@@ -672,15 +668,15 @@ export function PrototypeRealDailyView({
</span> </span>
</td> </td>
<td> <td>
{format( {formatFixed(
Number(record.unitPrice ?? 0), Number(record.unitPrice ?? 0),
)} )}
</td> </td>
<td> <td>
{format(Number(record.kg ?? 0))} {formatFixed(Number(record.kg ?? 0))}
</td> </td>
<td> <td>
¥{format(Number(record.cost ?? 0))} ¥{formatFixed(Number(record.cost ?? 0))}
</td> </td>
<td></td> <td></td>
</tr> </tr>
@@ -2,10 +2,12 @@ import { Component, Fragment, useEffect, useMemo, useRef, useState } from "react
import type { ErrorInfo, ReactNode } from "react"; import type { ErrorInfo, ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react"; import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
import { MobileListFullscreenButton } from "../../../vendor/lnbi-8113-exact/common/MobileListFullscreenButton"; import { MobileListFullscreenButton } from "../common/MobileListFullscreenButton";
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "./api"; import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "../api";
import { downloadExcelAoa } from "./prototype-download"; import { exportAoaSheet } from "../../../../shared/xlsx";
import { bearingLabels } from "./bearing-labels"; import { HYDROGEN_VERIFY_START_DATE } from "../../../../shared/hydrogen-verify";
import { bearingLabels } from "../model/bearing-labels";
import { formatFixed } from "../model/display-format";
import type { import type {
H2BiDrillGroupBy, H2BiDrillGroupBy,
H2BiDrillGroupRow, H2BiDrillGroupRow,
@@ -14,7 +16,7 @@ import type {
H2BiMetaResponse, H2BiMetaResponse,
H2BiQuery, H2BiQuery,
H2BiVehicleScope, H2BiVehicleScope,
} from "./types"; } from "../types";
import "./drill-prototype-parity.css"; import "./drill-prototype-parity.css";
type DrillKind = "kpi" | "customer" | "station" | "records"; type DrillKind = "kpi" | "customer" | "station" | "records";
@@ -79,11 +81,6 @@ type DrillState = Pick<
stationName?: string; stationName?: string;
}; };
const formatNumber = (value: unknown, digits = 2) =>
Number(value ?? 0).toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope => const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope =>
value === "own" ? "lingniu" : value; value === "own" ? "lingniu" : value;
const verifyLabel = (status: unknown) => { const verifyLabel = (status: unknown) => {
@@ -461,7 +458,7 @@ function ExpandedChildren({ data, state, query, kind, label, depth = 1 }: {
function ExpandedMetrics({ row, kind, label, amountScope }: { function ExpandedMetrics({ row, kind, label, amountScope }: {
row: { kg?: unknown; cost?: unknown; revenue?: unknown }; kind: DrillKind; label: string; amountScope: H2BiAmountScope; row: { kg?: unknown; cost?: unknown; revenue?: unknown }; kind: DrillKind; label: string; amountScope: H2BiAmountScope;
}) { }) {
const cell = (value: unknown, money = false) => <td style={{textAlign:"right"}}>{money ? "¥" : ""}{formatNumber(value)}</td>; const cell = (value: unknown, money = false) => <td style={{textAlign:"right"}}>{money ? "¥" : ""}{formatFixed(value)}</td>;
if (label === "加氢利润") return <>{cell(row.revenue, true)}{cell(row.cost, true)}{cell(Number(row.revenue ?? 0) - Number(row.cost ?? 0), true)}</>; if (label === "加氢利润") return <>{cell(row.revenue, true)}{cell(row.cost, true)}{cell(Number(row.revenue ?? 0) - Number(row.cost ?? 0), true)}</>;
if (kind === "customer") return <>{cell(row.cost, true)}{cell(row.revenue, true)}<td></td><td></td></>; if (kind === "customer") return <>{cell(row.cost, true)}{cell(row.revenue, true)}<td></td><td></td></>;
return <>{cell(row.kg)}{cell(amountScope === "customer" ? row.revenue : row.cost, true)}{label === "本日加氢" || label === "本月加氢" ? <td style={{textAlign:"right"}}></td> : null}</>; return <>{cell(row.kg)}{cell(amountScope === "customer" ? row.revenue : row.cost, true)}{label === "本日加氢" || label === "本月加氢" ? <td style={{textAlign:"right"}}></td> : null}</>;
@@ -481,7 +478,7 @@ function ExpandedChild({ row, state, query, kind, label, depth }: {
<button type="button" className="ehb-inline-child-toggle" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${row.name}`} onClick={() => { setExpanded(!expanded); setPage(1); }}>{expanded ? "▾" : "▸"}</button> <button type="button" className="ehb-inline-child-toggle" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${row.name}`} onClick={() => { setExpanded(!expanded); setPage(1); }}>{expanded ? "▾" : "▸"}</button>
<span>{row.name}</span> <span>{row.name}</span>
</div></td> </div></td>
<td>{titleFor(state.level)}</td><td><BearingTags row={row} /></td><td></td><td></td><td style={{textAlign:"right"}}>{formatNumber(row.recordCount, 0)} </td> <td>{titleFor(state.level)}</td><td><BearingTags row={row} /></td><td></td><td></td><td style={{textAlign:"right"}}>{formatFixed(row.recordCount, 0)} </td>
<ExpandedMetrics row={row} kind={kind} label={label} amountScope={state.amountScope} /> <ExpandedMetrics row={row} kind={kind} label={label} amountScope={state.amountScope} />
</tr> </tr>
{expanded ? live.loading || live.error || !live.data ? <tr className="ehb-inline-child-status"><td colSpan={columns}>{live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"}</td></tr> : <> {expanded ? live.loading || live.error || !live.data ? <tr className="ehb-inline-child-status"><td colSpan={columns}>{live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"}</td></tr> : <>
@@ -543,8 +540,8 @@ function GroupTable({
{region ? <td>{index + 1}</td> : null} {region ? <td>{index + 1}</td> : null}
<td style={{ fontWeight: 650 }}>{row.name}</td> <td style={{ fontWeight: 650 }}>{row.name}</td>
<td>{stationCustomer ? <span className={`ehb-tag ${row.lingniuKg > 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}>{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"}</span> : row.province || "未归属"}</td> <td>{stationCustomer ? <span className={`ehb-tag ${row.lingniuKg > 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}>{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"}</span> : row.province || "未归属"}</td>
{monthMetric === "加氢量" ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.lingniuKg)}</td><td className="ehb-key-external" style={{ textAlign: "right" }}>{formatNumber(row.externalKg)}</td></> : null} {monthMetric === "加氢量" ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.lingniuKg)}</td><td className="ehb-key-external" style={{ textAlign: "right" }}>{formatFixed(row.externalKg)}</td></> : null}
<td className={monthMetric === "客户收入" ? "ehb-key-income" : monthMetric === "成本支出" ? "ehb-key-cost" : "ehb-key-volume"} style={{ textAlign: "right" }}>{monthMetric === "客户收入" ? `¥${formatNumber(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatNumber(row.cost)}` : formatNumber(row.kg)}</td> <td className={monthMetric === "客户收入" ? "ehb-key-income" : monthMetric === "成本支出" ? "ehb-key-cost" : "ehb-key-volume"} style={{ textAlign: "right" }}>{monthMetric === "客户收入" ? `¥${formatFixed(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatFixed(row.cost)}` : formatFixed(row.kg)}</td>
{stationCustomer || region ? <td style={{ textAlign: "right" }}>{totalKg > 0 ? `${((Number(row.kg) / totalKg) * 100).toFixed(1)}%` : "0.0%"}</td> : null} {stationCustomer || region ? <td style={{ textAlign: "right" }}>{totalKg > 0 ? `${((Number(row.kg) / totalKg) * 100).toFixed(1)}%` : "0.0%"}</td> : null}
</tr> </tr>
))}</tbody> ))}</tbody>
@@ -552,10 +549,10 @@ function GroupTable({
); );
} }
if (kind === "station" && state.level === "date") { if (kind === "station" && state.level === "date") {
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th></th><th style={{ textAlign: "right" }}></th><th style={{ textAlign: "right" }}> (Kg)</th><th></th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> (/Kg)</th></tr></thead><tbody>{data.groups.map((row, index) => { const previous = Number(data.groups[index + 1]?.kg || 0); const change = previous > 0 ? ((Number(row.kg) - previous) / previous) * 100 : null; return <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} </td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className={change !== null && change >= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td style={{ textAlign: "right" }}>¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}</td></tr>; })}</tbody></table>; return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th></th><th style={{ textAlign: "right" }}></th><th style={{ textAlign: "right" }}> (Kg)</th><th></th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> (/Kg)</th></tr></thead><tbody>{data.groups.map((row, index) => { const previous = Number(data.groups[index + 1]?.kg || 0); const change = previous > 0 ? ((Number(row.kg) - previous) / previous) * 100 : null; return <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td style={{ textAlign: "right" }}>{formatFixed(row.recordCount, 0)} </td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className={change !== null && change >= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td style={{ textAlign: "right" }}>¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}</td></tr>; })}</tbody></table>;
} }
if (kind === "customer" && state.level === "date") { if (kind === "customer" && state.level === "date") {
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th> / </th><th></th><th></th><th style={{ textAlign: "right" }}> (Kg)</th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> ()</th><th></th><th></th></tr></thead><tbody>{data.groups.map((row) => <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td></td><td><span className="ehb-bearer-tag is-cust"></span></td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td></td><td></td></tr>)}</tbody></table>; return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th> / </th><th></th><th></th><th style={{ textAlign: "right" }}> (Kg)</th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> ()</th><th></th><th></th></tr></thead><tbody>{data.groups.map((row) => <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td></td><td><span className="ehb-bearer-tag is-cust"></span></td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td></td><td></td></tr>)}</tbody></table>;
} }
if (state.level === "record") { if (state.level === "record") {
return ( return (
@@ -628,9 +625,9 @@ function GroupTable({
<td style={{ textAlign: "right" }}> <td style={{ textAlign: "right" }}>
1 1
</td> </td>
<td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(record.kg)}</td> <td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(record.kg)}</td>
<td className="ehb-key-income" style={{ textAlign: "right" }}> <td className="ehb-key-income" style={{ textAlign: "right" }}>
¥{formatNumber(amountFor(record))} ¥{formatFixed(amountFor(record))}
</td> </td>
</tr> </tr>
))} ))}
@@ -748,7 +745,7 @@ function GroupTable({
</span> </span>
) : state.level === "station" ? ( ) : state.level === "station" ? (
<span className="ehb-tree-node-sub"> <span className="ehb-tree-node-sub">
{formatNumber(row.customerCount, 0)} {formatFixed(row.customerCount, 0)}
</span> </span>
) : ( ) : (
<span className="ehb-tree-node-sub"></span> <span className="ehb-tree-node-sub"></span>
@@ -767,8 +764,8 @@ function GroupTable({
{state.level === "station" ? "汇总" : "点击展开"} {state.level === "station" ? "汇总" : "点击展开"}
</span> </span>
</td> </td>
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} </td> <td style={{ textAlign: "right" }}>{formatFixed(row.recordCount, 0)} </td>
{isProfit ? <><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>¥{formatNumber(Number(row.revenue) - Number(row.cost))}</td></> : isMonth || isDay ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}></td></> : isCustomerBill ? <><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td></td><td></td></> : <><td className="ehb-key-volume" style={{ textAlign: "right", fontWeight: 700 }}>{formatNumber(row.kg)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td></>} {isProfit ? <><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>¥{formatFixed(Number(row.revenue) - Number(row.cost))}</td></> : isMonth || isDay ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(amountFor(row))}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}></td></> : isCustomerBill ? <><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td></td><td></td></> : <><td className="ehb-key-volume" style={{ textAlign: "right", fontWeight: 700 }}>{formatFixed(row.kg)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(amountFor(row))}</td></>}
</tr> </tr>
{expanded && !expandedLoading && !expandedError && expandedData ? <ExpandedChildren data={expandedData} state={nextState(state, row)} query={query} kind={kind} label={label} /> : null} {expanded && !expandedLoading && !expandedError && expandedData ? <ExpandedChildren data={expandedData} state={nextState(state, row)} query={query} kind={kind} label={label} /> : null}
{expanded ? ( {expanded ? (
@@ -810,17 +807,17 @@ function DrillSummaryCards({ kind, label: labelInput, data }: { kind: DrillKind;
const externalKg = groups.reduce((sum, row) => sum + Number(row.externalKg || 0), 0); const externalKg = groups.reduce((sum, row) => sum + Number(row.externalKg || 0), 0);
const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1]; const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1];
let cards: Array<[string, string, string?]>; let cards: Array<[string, string, string?]>;
if (label === "加氢利润") cards = [["收入合计", `¥${formatNumber(revenue)}`, "income"], ["成本合计", `¥${formatNumber(cost)}`, "cost"], ["加氢利润", `¥${formatNumber(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount}`]]; if (label === "加氢利润") cards = [["收入合计", `¥${formatFixed(revenue)}`, "income"], ["成本合计", `¥${formatFixed(cost)}`, "cost"], ["加氢利润", `¥${formatFixed(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本月加氢") cards = [["本月加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount}`]]; else if (label === "本月加氢") cards = [["本月加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本日加氢") cards = [["本日加氢量", `${formatNumber(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatNumber(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount}`]]; else if (label === "本日加氢") cards = [["本日加氢量", `${formatFixed(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatFixed(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`], ["覆盖加氢站数", `${stationCount}`]]; else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatNumber(revenue)}`, "income"], ["站均收入", `¥${formatNumber(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]]; else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatFixed(revenue)}`, "income"], ["站均收入", `¥${formatFixed(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatNumber(cost)}`, "cost"], ["站均成本", `¥${formatNumber(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]]; else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatFixed(cost)}`, "cost"], ["站均成本", `¥${formatFixed(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]];
else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`]]; else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`]];
else if (/^区域(?:市|省)/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省)/, "")], ["加氢总量", `${formatNumber(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount}`]]; else if (/^区域(?:市|省)/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省)/, "")], ["加氢总量", `${formatFixed(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount}`]];
else if (kind === "station") cards = [["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatNumber(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatNumber(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatNumber(summary?.recordCount ?? 0, 0)}`]]; else if (kind === "station") cards = [["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatFixed(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatFixed(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatFixed(summary?.recordCount ?? 0, 0)}`]];
else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatNumber(revenue)}`, "income"], ["已收", "未接入"], ["未收", "未接入"]]; else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatFixed(revenue)}`, "income"], ["已收", "未接入"], ["未收", "未接入"]];
else cards = [["数据归集总量", `${formatNumber(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatNumber(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount}`], ["来源记录完整度", `${summary?.recordCount ? formatNumber((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]]; else cards = [["数据归集总量", `${formatFixed(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatFixed(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount}`], ["来源记录完整度", `${summary?.recordCount ? formatFixed((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]];
return <div className="ehb-modal-meta-bar">{cards.map(([title, value, tone]) => <div className="ehb-modal-meta-item" key={title}><span className="ehb-modal-meta-label">{title}</span><span className={`ehb-modal-meta-val ${tone ? `ehb-summary-${tone}` : ""}`}>{value}</span></div>)}</div>; return <div className="ehb-modal-meta-bar">{cards.map(([title, value, tone]) => <div className="ehb-modal-meta-item" key={title}><span className="ehb-modal-meta-label">{title}</span><span className={`ehb-modal-meta-val ${tone ? `ehb-summary-${tone}` : ""}`}>{value}</span></div>)}</div>;
} }
@@ -1082,7 +1079,7 @@ export function PrototypeDrillModal({
rows.push([row.name, row.kg, row.cost, row.revenue]), rows.push([row.name, row.kg, row.cost, row.revenue]),
); );
} }
downloadExcelAoa(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透"); exportAoaSheet(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
}; };
const exportCurrent = () => { const exportCurrent = () => {
if (data) exportData(data, `${page}`); if (data) exportData(data, `${page}`);
@@ -1240,12 +1237,12 @@ export function PrototypeDrillModal({
</div> </div>
) : null} ) : null}
<DrillSummaryCards kind={kind} label={cleanLabel} data={live.data} /> <DrillSummaryCards kind={kind} label={cleanLabel} data={live.data} />
{cleanLabel === "待核对订单" ? <div className="ehb-modal-hint-text">线2026-05-01线</div> : null} {cleanLabel === "待核对订单" ? <div className="ehb-modal-hint-text">线{HYDROGEN_VERIFY_START_DATE}线线</div> : null}
{cleanLabel === "加氢利润" && customerBearingScope ? ( {cleanLabel === "加氢利润" && customerBearingScope ? (
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}> <div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
¥ ¥
{formatNumber(live.data?.summary.revenue)} ¥ {formatFixed(live.data?.summary.revenue)} ¥
{formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)} {formatFixed(live.data?.summary.cost)} = ¥{formatFixed(profit)}
</div> </div>
) : null} ) : null}
<div className="ehb-real-drill-primary-actions"> <div className="ehb-real-drill-primary-actions">

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