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 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:14:37 +08:00
parent 3daa9808ce
commit 7556dbcebc
64 changed files with 71 additions and 100 deletions
@@ -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/);
});
@@ -0,0 +1,41 @@
# 移动端经营总览布局决策
## 问题
- 顶部筛选区域占用首屏过多。
- 累计加氢量与累计加氢费使用两个大卡片并排,数字和承担结构拥挤。
- 利润卡片与本月、本日卡片高度不一致,形成大面积无效留白。
- 移动端需要先回答经营结果,再提供费用结构与近期指标。
## 用户选择
- 仅优化移动端布局,保留现有数据、筛选和下钻交互。
- 使用克制、专业、低饱和的视觉方向。
- 以管理层快速查看经营结果、费用结构和近期表现为核心任务。
## 最终设计决策
1. 新增单个移动端「经营总览」容器,集中展示累计加氢量、累计加氢费及我司承担、客户承担、待核准三行对照数据。
2. 桌面端继续使用原有 KPI 栅格,移动端隐藏原累计量费双卡,避免重复信息。
3. 加氢利润改为全宽紧凑卡,本月加氢量与今日加氢量并排呈现。
4. 压缩移动端页头、筛选容器和范围提示的垂直空间,不改变筛选状态与即时生效逻辑。
5. 保持 44px 最小触控热区、等宽数字及 375px/390px 视口无横向溢出。
## 参考稿细化确认
用户追加确认以参考截图优化移动端首屏:
1. 顶部只保留年份、视图、车辆范围和筛选四项快捷入口,订单范围收进展开筛选。
2. 累计经营概览增加我司、客户、待核准三段加氢量构成条,并展示吨数与占比。
3. 增加「查看构成」入口,继续复用累计加氢量明细。
4. 利润卡改为左侧利润、右侧收入与成本的横向结构。
5. 本月与今日指标使用等宽双卡,经营诊断延后至趋势内容之后。
## 单站页与累计明细补充确认
1. 单站页把站点数、统计加氢总量、车次、统计金额和现结金额收进一张经营概览,不再把桌面端四卡压成手机两列。
2. 日期范围与更新时间保留在同一块紧凑查询区;各站概况改为纵向卡片,竖屏不展示无必要的横屏入口。
3. 累计明细顶部改为双主指标:数据归集总量、数据总金额;覆盖站点数和来源完整度降为一行辅助信息。
4. 累计明细筛选默认收起为范围摘要,点击后展开完整筛选;竖屏只保留左上返回,不再重复提供关闭和横屏入口。
5. 层级数据优先保证站点、客户、车辆与订单摘要在首列可读;详细字段继续在表格内部横向查看,不允许撑宽整页。
6. 移动端竖屏明细页头保留左侧返回;站点与客户宽表明细同时保留右侧横屏图标,但不显示重复关闭按钮。业务标题按内容增高并换行,任何入口不得覆盖站点名、客户名或统计时间。
@@ -0,0 +1,151 @@
# 能源氢费经营看板 · 产品需求说明(PRD)
> 原型路径:`src/prototypes/energy-h2-bi-board`
> 宿主嵌入:`https://bi-next.lnh2e.com/energy#hydrogen/overview`
> 宿主视觉单源:`src/resources/prd/energy-bi-host-ref/`
> 方案底稿:`src/resources/prd/energy-board-plan-20260806.md`
> 口令:`lingniu`(轻门禁 · 本会话记住)
> 拍板:嵌入总览 · 我司成本三维度 · 站月/客户汇总表 · **禁 OneOS V2** · D1 无自动解读
> **2026-08-12**:顶栏白卡右上角 **全局 / 单站**;单站嵌加氢站日报(起止查询日期自管);单站模式**不显示**看板标题旁时间芯片;现结登记仍独立 OneOS 模块
> **2026-08-13**:全局视图**去掉**「加氢站日报 / 站日现结登记」关联入口卡;站日报仅经顶栏「单站」;现结登记走独立模块,本页不再挂跳转摘要
> 二期:双视角全路径 · 充耗存侧卡 · **预充值能源账户扣款(体系 B)**
> 作者:OneOS
---
## 0. 嵌入与设计约定
| 项 | 口径 |
|---|---|
| 口令 | `lingniu`(轻门禁;本会话 `sessionStorage` 记住) |
| 宿主页 | `#hydrogen/overview` |
| 功能 | 独立嵌入块「我司成本」:三维度 + 两张汇总表 + 订单明细;**单站**嵌加氢站日报 |
| 设计 | 只跟 bi-next 能源 BI;禁止 `V2*`;字体对齐 V2 §2.2 |
### 视图决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 顶栏范围 | **全局 / 单站**(白卡右上角) | 单站回嵌经营看板 |
| 全局内视图 | `按日` / `总览` | 原看板能力保留 |
| 总览筛选 | 默认**全部车辆**;可切**仅羚牛车辆** / **仅外部车辆**;与年份、全量订单/仅已核对联动 | 外层筛选与 KPI / 图表 / 钻取同一口径 |
| 承担方式 | **我司承担 / 客户承担 / 待核准**三类;「自行」并入「客户承担」 | 首页拆分、穿透筛选、明细列和导出口径一致 |
| 核心 KPI | PC 展示全部 5 项;移动端前 2 项突出,其余 3 项紧凑保留且均可点击穿透 | 不隐藏旧版指标能力 |
| 单站形态 | 加氢站日报:起止查询日期 + KPI 钻取 + 各站单卡(行内加氢量占比 · 按量降序) | 一眼可查 |
| 单站时间 | **不展示**看板标题旁「统计时间范围」芯片 | 与全局统计维度不同 |
| 单站明细 | 日汇总、区间趋势、客户月量/费、收支、现结;导出取证 `.xlsx` | 查看与取证 |
| 现结登记 | **独立** `energy-spot-cash-intake`;本页不办理 | 1C 手维入口唯一 |
| 预充值能源账户 | **二期 · 体系 B** | 与现结完全独立 |
### 关联独立模块
| 模块 | 路径 | 本看板角色 |
|---|---|---|
| 加氢站日报 | `energy-h2-station-daily` | 顶栏「单站」嵌入;可独立外链;**不**再挂全局关联卡 |
| 站日现结登记 | `energy-spot-cash-intake` | 独立模块办理;本页**不**挂跳转入口与近窗摘要 |
| 加氢客户(外部) | `energy-h2-external-customer` | 外部客户主数据;与租赁客户隔离 |
| 外部车辆明细 | `energy-h2-external-vehicle` | 外部车牌主数据 + 导入;API 分流「仅外部」数据源 |
> **2026-08-13 分流口径:** 南海站 API 流水命中外部车辆台账 → `fleetCategory=external`,**只进本看板/站日报**,不进车辆氢费明细;命中自有/租赁车队 → 进氢费明细并可核对。主数据种子车牌与 `mockDaily` 外部牌对齐(见 `src/common/energy-h2-external-fleet`)。
---
## 0.1 现结 ≠ 预充值(双体系硬隔离)
| | **体系 A · 现结** | **体系 B · 预充值(以后)** |
|---|---|---|
| 一句话 | 到站加完氢 → 当场在线支付 | 预充进账户 → 加氢扣款 |
| 本期 | 独立模块手维;看板/站日报只读 | **不做** |
| 禁止 | 「账户充值」指现结;订单金额冒充现结 | 用现结台账冒充账户流水 |
---
## 1. 模块定位
| 项 | 说明 |
|---|---|
| 名称 | 能源氢费经营看板(嵌入:我司成本) |
| 核心任务 | 全局:三维度结构 → 站月/客户汇总 → 订单解释;单站:站日经营读数与取证 |
| 不做 | 现结登记表单;体系 B |
---
## 2. 我司成本三维度(仅全局)
| 维度 | 二级 |
|---|---|
| 租赁成本 | 我司承担 · 包氢项目 |
| 物流成本 | — |
| 运维成本 | 异动 · 调拨 |
客户承担不进三维度。核对 ≠ 对账。
---
## 3. 用户故事
### 经营看板 · 全局
- **起点:** 打开看板(口令后)→ 顶栏「全局」→ 按日 / 总览。
- **怎么运作:** 看我司成本与汇总;站日报经顶栏「单站」进入。
- **闭环:** 本页解释成本结构。
### 经营看板 · 单站
- **起点:** 顶栏切「单站」→ 驾驶舱各站卡片。
- **怎么运作:** 点站点进入汇报口径明细(日汇总/趋势/客户月量费/收支/车辆/现结);可导出取证 Excel。
- **闭环:** 数字可查、明细可追到车与现结笔;登记仍去现结模块。
---
## 4. KPI 穿透表 · 类型标签口径
穿透钻取表(加氢站 → 客户 → 车辆):
表头支持「**按加氢站**」与「**按客户**」两种组织方式;切换只改变层级顺序,均可下钻到车辆和单笔订单。
| 层级 | 类型 / 归属列 |
|---|---|
| 加氢站 | **不展示**自用消费 / 对外销售(站侧不再分自用与对外) |
| 客户 | **不展示**内部客户 / 外部客户标签 |
| 车辆 | 无法识别车牌 → 名称统一「无车牌」(与有牌车辆同级),标签「外部车辆」;能识别且为羚牛车队 → 标签「羚牛车辆」;能识别的外部车 → 标签「外部车辆」 |
| 车辆下订单行 | 展示标签「订单编号」+ 单号;字段口径为**加氢订单编号**(导出列亦用「订单编号」) |
| 加氢站核对状态 | 按下属羚牛车辆汇总:**已核对**(全部已核)/ **部分核对**(有已核也有未核,或任一带部分)/ **未核对**(一条未核);无参与核对车辆时显示 `-` |
| 穿透筛 | **加氢站 / 客户 / 车辆**可搜索选择器(级联)+ **车辆归属**单选按钮组(全部 / 仅羚牛 / 仅外部)+ **承担方式**(全部 / 我司承担 / 客户承担 / 待核准);禁 V2 |
| 加氢利润穿透 | 关键列展示**收入 / 成本 / 利润**;顶栏汇总亦为收入·成本·利润 |
| 本月加氢穿透 | 关键列展示**本月加氢量 / 本月加氢费 / 加氢费占年比** |
| 本日加氢穿透 | 关键列展示**本日加氢量 / 本日加氢费 / 加氢费占月比** |
| 月度柱钻取 | 点月度加氢量柱 → 该月**各站**内部客户加氢总量 / 外部客户加氢总量 / 合计 |
| 月度收支柱钻取 | 点**客户收入**柱 → 该月各站客户收入;点**成本支出**柱 → 该月各站成本支出 |
| Top5 站横条 | 点条 → 该站**内部/外部客户加氢总量**(表可竖滚) |
| 区域环图/图例 | 点市或省 → 该区域**各站加氢总量与占比**(表可竖滚) |
| 站汇总表钻取 | 默认按日展示:**日期 / 车辆数与加氢笔数 / 加氢量 / 较前日增减 / 氢费收入 / 平均单价**;展开后展示客户、车牌、车辆归属、加氢时间和单笔明细 |
| 客户汇总表钻取 | 关键字段:**承担方 / 加氢量 / 成本支出 / 应收 / 已收 / 未收**(按日展开) |
| 头部加氢站占比 | 点击展开**加氢量排名**下拉(高→低、可滚动);点站可进穿透 |
| 按日经营指标 | 默认展示**区间加氢量 / 日均加氢量 / 较上一周期 / 活跃加氢站**;活跃站点按“当前有记录站点 / 全网络站点”展示并给出覆盖率;车辆构成降级为筛选区间说明,不占主指标卡 |
| 全局时间范围 | 每个页面、列表、全屏明细与钻取弹窗必须展示明确的开始与结束时间;**经营汇总到天、当日实时到分钟、订单与加氢流水到秒**;“本日 / 本月 / 年度”等业务名称不可替代具体时间范围 |
| 穿透标签提示 | 车辆归属 / 数据来源 / 核对状态标签均有悬浮说明 |
---
## 5. 验收
1. 顶栏白卡右上角有 **全局 / 单站**;全局下仍有按日/总览。
2. 单站:站点卡片 → 明细含汇报 Excel 对应区块;导出 `.xlsx`。
3. 空态不填 0;现结 ≠ 预充值。
4. 口令 `lingniu`;标注壳在门外。
5. 禁 OneOS V2 控件。
6. KPI 穿透:站/客户无自用·内外标签;无车牌归「无车牌」+「外部车辆」。
7. 总览顶栏:默认全部车辆;切仅羚牛/仅外部/年份/核对后,KPI、图表、钻取数同步变化。
8. 承担方式仅有我司承担、客户承担、待核准三类;不再单列「自行」,且首页、筛选、明细、导出一致。
9. KPI 穿透可按加氢站或客户组织,两种方式都可追到车辆和单笔订单。
10. PC 可见 5 项核心 KPI;移动端 5 项均可见、可点击,前 2 项保持主视觉层级。
---
## 6. 明确不做(本期)
- 体系 B 预充值账户
- 现结在本页办理
- 云效建单(默认不上)
@@ -0,0 +1,30 @@
# 氢能经营看板开发交付说明
## 页面入口
- 本地地址:`http://127.0.0.1:51720/prototypes/energy-h2-bi-board`
- 访问口令:`lingniu`
- 启动方式:在项目目录执行 `npm ci`,再执行 `npm run dev`
## 本次交付范围
- PC 与移动端全局经营总览、日期视图、单站视角。
- 年份、日期区间、车辆归属、订单范围、站点、客户等筛选交互。
- KPI 下钻、表格逐级展开、Tab 切换、更多数据、横屏查看与退出。
- 单站经营明细(日加氢、客户月度、收支、进账)与日期查询。
- Excel 导出入口及前端下载逻辑。
## 数据与接口边界
- 当前为可运行原型,经营数据来自 `data/mockBoard.ts`、`data/mockDaily.ts` 和单站模块的 `data/mockStationDaily.ts`。
- 正式开发需将模拟数据替换为接口返回值,但应保留现有筛选、空状态、下钻层级、双端响应式布局与横屏交互。
- 查询条件为即时生效;日期弹层点击“确定”后更新页面统计范围。
## 验证命令
```bash
npx vitest run tests/energy-h2-bi-board.ui.test.ts tests/energy-h2-bearer-model.test.ts tests/energy-h2-dual-end-interaction.test.ts tests/energy-h2-mobile-kpi-tone.test.ts tests/energy-h2-mobile-layout-polish.test.ts tests/energy-bi-board.visual.test.ts tests/common/EnergyBiBoardMobileLayout.test.ts tests/common/MobileListFullscreenButton.test.tsx
ENTRY_KEY=prototypes/energy-h2-bi-board npx vite build
```
交付前结果:8 个测试文件、97 项检查全部通过,目标页面生产构建通过。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
import type {
CostDim,
FleetScope,
H2OrderRow,
LeaseKind,
OpsKind,
} from '../types';
import { BORNE_BY_LABEL, BORNE_BY_ORDER, 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 activeBorneTypes = BORNE_BY_ORDER.filter((borneBy) => list.some((x) => x.borneBy === borneBy));
const borneLabel = activeBorneTypes.length === 1 ? BORNE_BY_LABEL[activeBorneTypes[0]] : '混合';
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;
pendingKgT: number;
totalFeeWan: number;
companyFeeWan: number;
customerFeeWan: number;
pendingFeeWan: 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 pendingKg = sumKg(filtered.filter((r) => r.borneBy === 'pending'));
const split = companyKg + customerKg + pendingKg || 1;
const companyShare = companyKg / split;
const customerShare = customerKg / split;
const pendingShare = pendingKg / 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),
pendingKgT: round2(totalKgT * pendingShare),
totalFeeWan,
companyFeeWan: round2(totalFeeWan * companyShare),
customerFeeWan: round2(totalFeeWan * customerShare),
pendingFeeWan: round2(totalFeeWan * pendingShare),
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,
};
}
@@ -0,0 +1,191 @@
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' | 'pending' = 'company';
let costDim: 'lease' | 'logistics' | 'ops' | 'pending' = 'lease';
let leaseKind: 'company_borne' | 'package_h2' | undefined = undefined;
let opsKind: 'abnormal' | 'transfer' | undefined = undefined;
const borneSlot = i % 10;
if (customer.id === 'c-ext-a' || customer.id === 'c-ext-d' || borneSlot === 7 || borneSlot === 8) {
borneBy = 'customer';
} else if (borneSlot === 9) {
borneBy = 'pending';
}
const dimType = i % 5;
if (borneBy !== 'company') {
costDim = 'pending';
leaseKind = undefined;
opsKind = undefined;
} else 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(三类承担口径,自行结算已并入客户承担) */
export const HOST_KPI = {
totalKgT: 697.16,
companyKgT: 598.01,
customerKgT: 80,
pendingKgT: 19.15,
totalFeeWan: 2093.71,
companyFeeWan: 1795.95,
customerFeeWan: 240,
pendingFeeWan: 57.76,
profitWan: 13.01,
incomeWan: 2106.72,
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
/** 能源 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' | 'pending';
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: '调拨',
};
export const BORNE_BY_LABEL: Record<BorneBy, string> = {
company: '我司承担',
customer: '客户承担',
pending: '待核准',
};
export const BORNE_BY_ORDER: BorneBy[] = ['company', 'customer', 'pending'];
@@ -0,0 +1,109 @@
import React, { useEffect, useRef, useState } from 'react';
import { isPhoneUserAgent, phoneRotation } from './phone-viewport';
import { Maximize2 } from 'lucide-react';
import './mobile-list-fullscreen.css';
export function MobileListFullscreenButton({
label = '横屏查看',
placement = 'overlay',
}: {
label?: string;
placement?: 'overlay' | 'inline';
}) {
const [isActive, setIsActive] = useState(false);
const targetRef = useRef<HTMLElement | null>(null);
const savedStyles = useRef(new Map<string, [string, string]>());
const restoreGeometry = () => {
const target = targetRef.current;
if (!target) return;
savedStyles.current.forEach(([value, priority], key) => {
if (value) target.style.setProperty(key, value, priority); else target.style.removeProperty(key);
});
savedStyles.current.clear();
};
const updateGeometry = () => {
const target = targetRef.current;
if (!target) return;
restoreGeometry();
const width = window.visualViewport?.width ?? window.innerWidth;
const height = window.visualViewport?.height ?? window.innerHeight;
const rotate = phoneRotation(navigator.userAgent, width, height);
target.dataset.mobileFullscreenMode = rotate ? 'phone-rotated' : 'native-scroll';
if (!rotate) return;
const styles: Record<string, string> = {
position: 'fixed', top: '0px', left: '0px', right: 'auto', bottom: 'auto',
width: `${height}px`, height: `${width}px`, 'max-width': 'none', 'max-height': 'none',
margin: '0px', transform: `translateX(${width}px) rotate(90deg)`, 'transform-origin': '0 0',
overflow: 'hidden', padding: '0px',
};
Object.entries(styles).forEach(([key, value]) => {
savedStyles.current.set(key, [target.style.getPropertyValue(key), target.style.getPropertyPriority(key)]);
target.style.setProperty(key, value, 'important');
});
};
useEffect(() => {
const resize = () => updateGeometry();
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && targetRef.current) { event.stopPropagation(); leaveLandscape(targetRef.current); }
};
window.addEventListener('resize', resize);
window.visualViewport?.addEventListener('resize', resize);
document.addEventListener('keydown', escape, true);
return () => {
window.removeEventListener('resize', resize);
window.visualViewport?.removeEventListener('resize', resize);
document.removeEventListener('keydown', escape, true);
if (targetRef.current) {
restoreGeometry();
targetRef.current.removeAttribute('data-mobile-fullscreen-active');
targetRef.current.removeAttribute('data-mobile-fullscreen-mode');
targetRef.current.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
targetRef.current = null;
document.documentElement.classList.remove('ehb-landscape-session');
}
};
}, []);
const leaveLandscape = (target: HTMLElement) => {
restoreGeometry();
targetRef.current = null;
target.removeAttribute('data-mobile-fullscreen-active');
target.removeAttribute('data-mobile-fullscreen-mode');
target.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
document.documentElement.classList.remove('ehb-landscape-session');
setIsActive(false);
};
const openFullscreen = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
const target = event.currentTarget.closest<HTMLElement>('[data-mobile-fullscreen-list]');
if (!target) return;
if (target.dataset.mobileFullscreenActive === 'true') {
leaveLandscape(target);
return;
}
target.dataset.mobileFullscreenActive = 'true';
targetRef.current = target;
target.classList.add('is-mobile-list-fullscreen');
document.documentElement.classList.add('ehb-landscape-session');
setIsActive(true);
updateGeometry();
};
return (
<button
type="button"
className={`mobile-list-fullscreen-trigger${placement === 'inline' ? ' is-inline' : ''}`}
aria-label={isActive ? '退出完整宽表' : label}
aria-pressed={isActive}
title={isActive ? '退出宽表模式' : label}
onClick={openFullscreen}
>
<span className="mobile-list-fullscreen-trigger__label">{isActive ? '退出宽表' : isPhoneUserAgent(navigator.userAgent) ? '横屏查看' : '查看完整表格'}</span>
<Maximize2 size={15} aria-hidden />
</button>
);
}
@@ -0,0 +1,41 @@
/**
* OneOS 表格下载统一出口:产物一律 .xlsx(禁止 CSV 作为默认/模板路径)。
* 上传可另兼容 .xls / 过渡期 .csv;本模块只负责写出 Excel。
*/
import * as XLSX from 'xlsx';
/** @param {string} [name] */
export function ensureXlsxFilename(name) {
const raw = String(name || 'export').trim() || 'export';
const base = raw.replace(/\.(csv|xls|xlsx)$/i, '');
return `${base}.xlsx`;
}
/**
* @param {unknown[][]} aoa
* @param {string} filename
* @param {string} [sheetName]
*/
export function downloadExcelAoa(aoa, filename, sheetName = 'Sheet1') {
const ws = XLSX.utils.aoa_to_sheet(aoa || []);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31) || 'Sheet1');
XLSX.writeFile(wb, ensureXlsxFilename(filename));
}
/**
* @param {Record<string, unknown>[]} rows
* @param {string} filename
* @param {string} [sheetName]
*/
export function downloadExcel(rows, filename, sheetName = 'Sheet1') {
const ws = XLSX.utils.json_to_sheet(rows || []);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31) || 'Sheet1');
XLSX.writeFile(wb, ensureXlsxFilename(filename));
}
/** @deprecated 别名,写出已是 .xlsx */
export const downloadXlsAoa = downloadExcelAoa;
/** @deprecated 别名,写出已是 .xlsx */
export const downloadXls = downloadExcel;
@@ -0,0 +1,2 @@
export * from './types';
export * from './store';
@@ -0,0 +1,569 @@
/**
* 体系 A · 站日现结进账 Mock + localStorage
* 仅本尊提供 Excel 实站;禁造站。
* - 南海:截止8.11 收支汇总 → 现结
* - 东鹏大道:加氢记录-20260802 汇总进账明细 → 现结
*/
import type {
CashIntakeChangeLog,
StationCashIntakeDay,
StationCashIntakeLine,
} from './types';
export const CASH_INTAKE_STORAGE_KEY = 'oneos-energy-spot-cash-intake-v4';
const LEGACY_STORAGE_KEYS = [
'oneos-energy-spot-cash-intake-v3',
'oneos-energy-spot-cash-intake-v2',
];
function seedDays(): StationCashIntakeDay[] {
return [
{
id: 'day-st-fs-nanhai-2026-08-02',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-02',
totalAmount: 2530.42,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-02-0', customerName: "东展供应链(广州)有限公司", amount: 655.12, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-1', customerName: "广东开鸿氢能科技有限公司", amount: 503.88, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-2', customerName: "现代氢能科技有限公司", amount: 760.38, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-3', customerName: "广东氢动力科技服务有限公司", amount: 296.78, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-4', customerName: "羚牛氢能科技(广东)有限公司", amount: 314.26, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-02 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-03',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-03',
totalAmount: 2193.36,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-03-0', customerName: "现代氢能科技有限公司", amount: 1259.7, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-03-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 634.22, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-03-2', customerName: "东展供应链(广州)有限公司", amount: 299.44, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-03 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-04',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-04',
totalAmount: 5216.16,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-04-0', customerName: "广东沣开科技有限公司", amount: 3000.0, payMethod: 'bank_transfer' },
{ id: 'line-st-fs-nanhai-2026-08-04-1', customerName: "现代氢能科技有限公司", amount: 1024.1, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 473.86, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-3', customerName: "广东氢动力科技服务有限公司", amount: 368.6, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-4', customerName: "东展供应链(广州)有限公司", amount: 163.78, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-5', customerName: "外省过路车", amount: 185.82, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-04 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-05',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-05',
totalAmount: 3469.78,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-05-0', customerName: "现代氢能科技有限公司", amount: 1155.58, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 608.0, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-2', customerName: "广东氢动力科技服务有限公司", amount: 530.86, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-3', customerName: "广东开鸿氢能科技有限公司", amount: 315.78, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-4', customerName: "东展供应链(广州)有限公司", amount: 859.56, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-05 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-06',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-06',
totalAmount: 1006.24,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-06-0', customerName: "现代氢能科技有限公司", amount: 478.8, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-06-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 527.44, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-06 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-07',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-07',
totalAmount: 22094.18,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-07-0', customerName: "东展供应链(广州)有限公司", amount: 201.02, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-07-1', customerName: "现代氢能科技有限公司", amount: 1410.18, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-07-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 482.98, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-07-3', customerName: "羚牛氢能科技(广东)有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-07 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-08',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-08',
totalAmount: 2845.06,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-08-0', customerName: "东展供应链(广州)有限公司", amount: 745.94, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-08-1', customerName: "现代氢能科技有限公司", amount: 1371.8, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-08-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 727.32, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-08 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-09',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-09',
totalAmount: 2485.58,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-09-0', customerName: "现代氢能科技有限公司", amount: 775.2, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-09-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 987.62, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-09-2', customerName: "广东氢动力科技服务有限公司", amount: 250.42, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-09-3', customerName: "东展供应链(广州)有限公司", amount: 472.34, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-09 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-10',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-10',
totalAmount: 22823.78,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-10-0', customerName: "现代氢能科技有限公司", amount: 1582.7, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 757.34, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-2', customerName: "广东氢动力科技服务有限公司", amount: 186.2, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-3', customerName: "东展供应链(广州)有限公司", amount: 297.54, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-4', customerName: "广东瀚清能源有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-10 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-11',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-11',
totalAmount: 2301.04,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-11-0', customerName: "现代氢能科技有限公司", amount: 755.06, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-11-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 596.36, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-11-2', customerName: "广东开鸿氢能科技有限公司", amount: 599.26, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-11-3', customerName: "东展供应链(广州)有限公司", amount: 350.36, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-11 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-06-26',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-06-26',
totalAmount: 10000.0,
lines: [
{ id: 'line-st-dp-dongpeng-2026-06-26-0', customerName: "广州市梅洛特物流有限公司", amount: 10000.0, payMethod: 'bank_transfer' },
],
updatedBy: "金可鹏",
updatedAt: '2026-06-26 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-01',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-01',
totalAmount: 1506.75,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-01-0', customerName: "广州新运多租赁有限公司", amount: 1506.75, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-01 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-02',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-02',
totalAmount: 596.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-02-0', customerName: "广州中味餐饮服务有限公司", amount: 96.25, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-02-1', customerName: "广州新运多租赁有限公司", amount: 500.15, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-02 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-03',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-03',
totalAmount: 918.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-03-0', customerName: "广州新运多租赁有限公司", amount: 918.4, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-03 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-04',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-04',
totalAmount: 349.3,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-04-0', customerName: "广州新运多租赁有限公司", amount: 349.3, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-04 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-07',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-07',
totalAmount: 20640.15,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-07-0', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
{ id: 'line-st-dp-dongpeng-2026-07-07-1', customerName: "广州中味餐饮服务有限公司", amount: 362.25, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-07-2', customerName: "广州新运多租赁有限公司", amount: 277.9, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-07 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-08',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-08',
totalAmount: 896.35,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-08-0', customerName: "广州新运多租赁有限公司", amount: 896.35, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-08 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-09',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-09',
totalAmount: 902.65,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-09-0', customerName: "广州中味餐饮服务有限公司", amount: 211.4, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-09-1', customerName: "广州新运多租赁有限公司", amount: 691.25, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-09 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-10',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-10',
totalAmount: 1198.05,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-10-0', customerName: "广州中味餐饮服务有限公司", amount: 314.65, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-10-1', customerName: "广州新运多租赁有限公司", amount: 883.4, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-10 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-14',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-14',
totalAmount: 266.7,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-14-0', customerName: "广州中味餐饮服务有限公司", amount: 266.7, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-14 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-15',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-15',
totalAmount: 562.8,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-15-0', customerName: "广州中味餐饮服务有限公司", amount: 262.5, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-15-1', customerName: "广州新运多租赁有限公司", amount: 300.3, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-15 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-16',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-16',
totalAmount: 497.7,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-16-0', customerName: "广州中味餐饮服务有限公司", amount: 200.9, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-16-1', customerName: "广州新运多租赁有限公司", amount: 296.8, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-16 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-17',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-17',
totalAmount: 911.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-17-0', customerName: "广州新运多租赁有限公司", amount: 911.4, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-17 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-18',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-18',
totalAmount: 870.45,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-18-0', customerName: "广州新运多租赁有限公司", amount: 870.45, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-18 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-19',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-19',
totalAmount: 468.65,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-19-0', customerName: "广州中味餐饮服务有限公司", amount: 181.3, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-19-1', customerName: "广州新运多租赁有限公司", amount: 287.35, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-19 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-20',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-20',
totalAmount: 708.75,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-20-0', customerName: "广州新运多租赁有限公司", amount: 480.55, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-20-1', customerName: "广州中味餐饮服务有限公司", amount: 228.2, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-20 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-21',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-21',
totalAmount: 234.85,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-21-0', customerName: "广州新运多租赁有限公司", amount: 234.85, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-21 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-25',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-25',
totalAmount: 20686.0,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-25-0', customerName: "广州新运多租赁有限公司", amount: 686.0, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-25-1', customerName: "广州福满华冷链物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-25 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-27',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-27',
totalAmount: 213.85,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-27-0', customerName: "广州新运多租赁有限公司", amount: 213.85, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-27 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-28',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-28',
totalAmount: 20872.2,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-28-0', customerName: "广州中味餐饮服务有限公司", amount: 480.9, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-28-1', customerName: "广州新运多租赁有限公司", amount: 391.3, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-28-2', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-28 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-29',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-29',
totalAmount: 814.8,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-29-0', customerName: "广州中味餐饮服务有限公司", amount: 84.0, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-29-1', customerName: "广州新运多租赁有限公司", amount: 730.8, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-29 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-30',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-30',
totalAmount: 1450.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-30-0', customerName: "广州中味餐饮服务有限公司", amount: 393.05, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-30-1', customerName: "广州新运多租赁有限公司", amount: 1057.35, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-30 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-31',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-31',
totalAmount: 587.65,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-31-0', customerName: "广州新运多租赁有限公司", amount: 587.65, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-31 18:00',
},
];
}
function normalizeDay(d: StationCashIntakeDay): StationCashIntakeDay {
return {
...d,
changeLogs: Array.isArray(d.changeLogs) ? d.changeLogs : [],
};
}
export function loadCashIntakeDays(): StationCashIntakeDay[] {
try {
const raw = localStorage.getItem(CASH_INTAKE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StationCashIntakeDay[];
if (Array.isArray(parsed) && parsed.length) return parsed.map(normalizeDay);
}
for (const key of LEGACY_STORAGE_KEYS) {
const legacy = localStorage.getItem(key);
if (!legacy) continue;
const parsed = JSON.parse(legacy) as StationCashIntakeDay[];
if (Array.isArray(parsed) && parsed.length) {
const next = parsed.map(normalizeDay);
saveCashIntakeDays(next);
return next;
}
}
} catch {
/* ignore */
}
return seedDays().map(normalizeDay);
}
export function saveCashIntakeDays(days: StationCashIntakeDay[]): void {
try {
localStorage.setItem(CASH_INTAKE_STORAGE_KEY, JSON.stringify(days));
} catch {
/* ignore */
}
}
export function getCashIntakeForStationRange(
days: StationCashIntakeDay[],
stationId: string,
startDate: string,
endDate: string,
): StationCashIntakeDay[] {
return days
.filter((d) => d.stationId === stationId && d.bizDate >= startDate && d.bizDate <= endDate)
.slice()
.sort((a, b) => a.bizDate.localeCompare(b.bizDate));
}
export function appendChangeLog(
day: StationCashIntakeDay,
entry: Omit<CashIntakeChangeLog, 'id'> & { id?: string },
): StationCashIntakeDay {
const log = {
id: entry.id || `log-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
at: entry.at,
by: entry.by,
action: entry.action,
summary: entry.summary,
};
const prev = Array.isArray(day.changeLogs) ? day.changeLogs : [];
return { ...day, changeLogs: [log, ...prev].slice(0, 80) };
}
export function upsertCashIntakeDay(
days: StationCashIntakeDay[],
next: StationCashIntakeDay,
): StationCashIntakeDay[] {
const idx = days.findIndex((d) => d.stationId === next.stationId && d.bizDate === next.bizDate);
const copy = days.slice();
if (idx >= 0) copy[idx] = { ...next, changeLogs: next.changeLogs || copy[idx].changeLogs || [] };
else copy.push({ ...next, changeLogs: next.changeLogs || [] });
return copy.sort((a, b) => b.bizDate.localeCompare(a.bizDate));
}
export function deleteCashIntakeDay(
days: StationCashIntakeDay[],
stationId: string,
bizDate: string,
): StationCashIntakeDay[] {
return days.filter((d) => !(d.stationId === stationId && d.bizDate === bizDate));
}
export function sumLines(lines: StationCashIntakeLine[]): number {
return lines.reduce((s, l) => s + (Number(l.amount) || 0), 0);
}
export function summarizeCashIntake(
days: StationCashIntakeDay[],
startDate?: string,
endDate?: string,
): { dayCount: number; totalAmount: number; stationCount: number } {
const filtered = days.filter((d) => {
if (startDate && d.bizDate < startDate) return false;
if (endDate && d.bizDate > endDate) return false;
return true;
});
const stations = new Set(filtered.map((d) => d.stationId));
return {
dayCount: filtered.length,
totalAmount: filtered.reduce((s, d) => s + d.totalAmount, 0),
stationCount: stations.size,
};
}
@@ -0,0 +1,65 @@
/**
* 站日现结进账类型(共享)
* 禁止与预充值能源账户混用(口径见 PRD,不进 UI)。
*/
export type SpotPayMethod = 'wechat_scan' | 'bank_transfer' | 'other';
export const SPOT_PAY_METHOD_LABEL: Record<SpotPayMethod, string> = {
wechat_scan: '微信扫码',
bank_transfer: '对公转账',
other: '其他',
};
export function parsePayMethodLabel(raw: string): SpotPayMethod | null {
const t = String(raw || '').trim();
if (!t) return null;
const entry = (Object.keys(SPOT_PAY_METHOD_LABEL) as SpotPayMethod[]).find(
(k) => SPOT_PAY_METHOD_LABEL[k] === t || k === t,
);
return entry || null;
}
export interface StationCashIntakeLine {
id: string;
customerName: string;
amount: number;
payMethod: SpotPayMethod;
remark?: string;
}
export type CashIntakeChangeAction = 'create' | 'update' | 'delete' | 'import';
export interface CashIntakeChangeLog {
id: string;
at: string;
by: string;
action: CashIntakeChangeAction;
summary: string;
}
/** 唯一键 stationId + bizDate(业务字段仍用 bizDate;UI 称「登记日期」) */
export interface StationCashIntakeDay {
id: string;
stationId: string;
stationName: string;
bizDate: string; // YYYY-MM-DD · 登记日期
totalAmount: number;
remark?: string;
lines: StationCashIntakeLine[];
updatedBy: string;
updatedAt: string;
changeLogs?: CashIntakeChangeLog[];
}
export const CASH_INTAKE_STATIONS = [
{ id: 'st-fs-nanhai', name: '佛山南海羚牛加氢站' },
{ id: 'st-dp-dongpeng', name: '东鹏大道甲醇制氢一体站' },
] as const;
export const DEFAULT_CASH_STATION_ID = 'st-fs-nanhai';
/** Make / 本地预览跳转 */
export const PROTO_PATH_SPOT_CASH = '/prototypes/energy-spot-cash-intake';
export const PROTO_PATH_STATION_DAILY = '/prototypes/energy-h2-station-daily';
export const PROTO_PATH_ENERGY_BI = '/prototypes/energy-h2-bi-board';
@@ -0,0 +1,385 @@
.mobile-list-fullscreen-trigger {
display: none;
}
@media (max-width: 767px) {
[data-mobile-fullscreen-list] {
position: relative;
}
.mobile-list-fullscreen-trigger {
position: absolute;
z-index: 4;
top: 10px;
right: 10px;
display: inline-flex;
width: auto;
min-width: 124px;
height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbe4f1;
border-radius: 9px;
background: #fff;
color: #fff;
background: #2563eb;
border-color: #2563eb;
box-shadow: 0 5px 14px rgb(37 99 235 / 20%);
cursor: pointer;
}
.mobile-list-fullscreen-trigger.is-inline {
position: relative;
inset: auto;
z-index: auto;
flex: 0 0 auto;
}
.mobile-list-fullscreen-trigger__label {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.mobile-list-fullscreen-trigger::before {
position: absolute;
inset: -6px;
content: '';
}
[data-mobile-fullscreen-list] > h2,
[data-mobile-fullscreen-list] > .sd-section-head,
[data-mobile-fullscreen-list] > .sd-panel__head-row,
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head,
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head,
[data-mobile-fullscreen-list] > .ehb-daily-table-head {
box-sizing: border-box;
padding-inline-end: 112px !important;
}
/* 复合标题区的 Tab 在第二行,只让第一行标题避让横屏按钮。 */
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head {
padding-inline-end: 14px !important;
}
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head .ehb-sum-table-card__meta,
[data-mobile-fullscreen-list] > .sd-section-head .sd-panel__meta,
[data-mobile-fullscreen-list] > .sd-panel__head-row .sd-panel__meta {
max-width: 100%;
overflow-wrap: anywhere;
}
[data-mobile-fullscreen-list]:fullscreen,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
box-sizing: border-box;
z-index: 200;
overflow: auto;
padding: 14px;
border-radius: 0;
background: #f4f7fb;
}
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
position: fixed;
inset: 0;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > .mobile-list-fullscreen-trigger,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > * > .mobile-list-fullscreen-trigger {
position: fixed !important;
top: 10px !important;
right: 10px !important;
z-index: 260 !important;
}
[data-mobile-fullscreen-list]:fullscreen .ehb-sum-table-wrap,
[data-mobile-fullscreen-list]:fullscreen .ehb-table-wrap,
[data-mobile-fullscreen-list]:fullscreen .sd-table-scroll,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-sum-table-wrap,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-table-wrap,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .sd-table-scroll,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
max-height: none;
overflow: auto;
background: #fff;
}
}
/* 宽表只切换阅读布局:竖屏保持原方向并由表格横向滚动。 */
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
box-sizing: border-box;
position: fixed !important;
z-index: 200 !important;
inset: 0 !important;
overflow: auto !important;
padding: 10px !important;
border-radius: 0 !important;
background: #f4f7fb !important;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
position: fixed !important;
z-index: 260 !important;
top: 10px !important;
right: 10px !important;
display: inline-flex !important;
width: auto;
min-width: 92px;
height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbe4f1;
border-radius: 9px;
background: #fff;
color: #4f6f9f;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
max-height: none !important;
overflow: auto !important;
background: #fff;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table {
width: max-content !important;
min-width: 100% !important;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th {
position: sticky;
top: 0;
z-index: 4;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table th:first-child,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table td:first-child {
position: sticky;
left: 0;
z-index: 5;
background: #fff;
box-shadow: 5px 0 9px -9px #0f172a;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th:first-child {
z-index: 7;
background: #f7f9fc;
}
/* 汇总表的第一列只是序号;继续冻结第二列站点名称,横向滚动时保留行身份。 */
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:first-child,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:first-child {
width: 42px;
min-width: 42px;
max-width: 42px;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:nth-child(2),
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:nth-child(2) {
position: sticky;
left: 42px;
z-index: 5;
min-width: 190px;
background: #fff;
box-shadow: 7px 0 10px -10px #0f172a;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table thead th:nth-child(2) {
z-index: 7;
background: #f7f9fc;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"]::after {
position: fixed;
right: 10px;
bottom: 10px;
z-index: 250;
padding: 5px 9px;
border: 1px solid #dbe4f1;
border-radius: 999px;
background: rgb(255 255 255 / 92%);
color: #64748b;
content: '左右滑动查看更多列';
font-size: 11px;
pointer-events: none;
box-shadow: 0 4px 12px rgb(15 23 42 / 8%);
}
@media (max-width: 767px) and (orientation: portrait) {
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"],
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"].is-mobile-list-fallback {
transform: none !important;
transform-origin: initial !important;
}
}
/* 下钻弹层最终移动端约束:本文件最后加载,避免旧版宽表规则反向撑开页面。 */
@media (max-width: 767px) {
.ehb-drill-modal--unified .ehb-modal-body {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
min-width: 0 !important;
padding: 12px !important;
gap: 10px !important;
overflow: hidden !important;
}
.ehb-drill-modal--unified .ehb-modal-meta-bar {
flex: 0 0 auto !important;
width: 100% !important;
min-width: 0 !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 6px !important;
overflow: visible !important;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
min-width: 0 !important;
padding: 10px !important;
}
.ehb-drill-modal--unified .ehb-real-drill-filter-summary,
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
display: flex !important;
flex: 0 0 auto !important;
width: 100% !important;
min-width: 0 !important;
}
.ehb-real-drill-primary-actions {
display: grid !important;
flex: 0 0 auto;
grid-template-columns: minmax(0, 1fr) 132px;
width: 100%;
min-width: 0;
gap: 8px;
}
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary,
.ehb-real-drill-primary-actions .mobile-list-fullscreen-trigger {
position: static !important;
width: 100% !important;
min-width: 0 !important;
height: 48px !important;
border-radius: 12px !important;
}
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary {
display: grid !important;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 7px;
}
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
min-height: 48px !important;
padding: 0 14px !important;
border: 1px solid #d9e3f0 !important;
border-radius: 12px !important;
background: #fff !important;
}
.ehb-drill-modal--unified .ehb-modal-table-wrap {
flex: 1 1 auto !important;
width: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
overflow: auto !important;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto !important;
align-items: stretch !important;
gap: 8px !important;
}
.ehb-drill-modal--unified .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger {
position: static !important;
min-width: 124px !important;
height: 48px !important;
border-radius: 12px !important;
}
.ehb-drill-usage-guide {
display: grid !important;
flex: 0 0 auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
gap: 6px;
}
.ehb-drill-usage-guide > span {
min-width: 0;
padding: 7px 8px;
border-radius: 8px;
background: #eaf2ff;
color: #52637b;
font-size: 10px;
line-height: 1.35;
text-align: center;
}
.ehb-drill-usage-guide strong { color: #1f5fe0; }
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child {
position: sticky !important;
}
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child::after {
display: inline-flex;
margin-left: 8px;
padding: 2px 6px;
border-radius: 999px;
background: #e8f1ff;
color: #2563eb;
content: '展开';
font-size: 9px;
font-weight: 700;
white-space: nowrap;
vertical-align: middle;
}
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded="true"] > td:first-child::after {
background: #e7f8f1;
color: #07875f;
content: '收起';
}
}
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body {
width: 100% !important;
min-width: 0 !important;
overflow: hidden !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar {
width: 100% !important;
min-width: 0 !important;
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap {
flex: 1 1 auto !important;
width: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
overflow: auto !important;
}
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isPhoneUserAgent, phoneRotation } from './phone-viewport.js';
test('仅手机 UA 在竖屏宽表模式旋转,不影响平板和桌面', () => {
for (const ua of ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Mobile Safari/537.36']) {
assert.equal(isPhoneUserAgent(ua), true);
assert.equal(phoneRotation(ua, 390, 844), true);
assert.equal(phoneRotation(ua, 844, 390), false);
}
for (const ua of ['Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)']) {
assert.equal(phoneRotation(ua, 390, 844), false);
}
});
@@ -0,0 +1,8 @@
/** UA-based phone detection intentionally excludes iPad and Android tablets. */
export function isPhoneUserAgent(ua: string): boolean {
return /iPhone|iPod|Windows Phone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua));
}
export function phoneRotation(ua: string, width: number, height: number): boolean {
return isPhoneUserAgent(ua) && height > width;
}
@@ -3,8 +3,8 @@ import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
import type { H2BiDailyResponse } from "./types";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import type { H2BiDailyResponse } from "../types";
test("每日环比区分真实零值、缺失值和非数值", () => {
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
@@ -1,9 +1,9 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Download, RefreshCw, Truck } from "lucide-react";
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "./api";
import { downloadExcelAoa } from "./prototype-download";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
import { downloadExcelAoa } from "../common/prototype-download";
import "./real-daily-mobile.css";
import type {
H2BiDailyResponse,
@@ -11,7 +11,7 @@ import type {
H2BiDrillResponse,
H2BiQuery,
H2BiVehicleScope,
} from "./types";
} from "../types";
const format = (value: number, digits = 2) =>
value.toLocaleString("zh-CN", {
@@ -2,10 +2,10 @@ import { Component, Fragment, useEffect, useMemo, useRef, useState } from "react
import type { ErrorInfo, ReactNode } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
import { MobileListFullscreenButton } from "../../../vendor/lnbi-8113-exact/common/MobileListFullscreenButton";
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "./api";
import { downloadExcelAoa } from "./prototype-download";
import { bearingLabels } from "./bearing-labels";
import { MobileListFullscreenButton } from "../common/MobileListFullscreenButton";
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "../api";
import { downloadExcelAoa } from "../common/prototype-download";
import { bearingLabels } from "../model/bearing-labels";
import type {
H2BiDrillGroupBy,
H2BiDrillGroupRow,
@@ -14,7 +14,7 @@ import type {
H2BiMetaResponse,
H2BiQuery,
H2BiVehicleScope,
} from "./types";
} from "../types";
import "./drill-prototype-parity.css";
type DrillKind = "kpi" | "customer" | "station" | "records";
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,10 @@
# JetBrains Mono(OneOS V2 自托管)
| 项 | 值 |
|---|---|
| 版本 | 2.304 |
| 来源 | https://github.com/JetBrains/JetBrainsMono |
| 许可 | SIL Open Font License 1.1(`OFL.txt`) |
| 用途 | 金额 / 日期 / 单号 / 车牌等数据等宽数字(DESIGN §2.2) |
引入:由 `oneos-ds-tokens.css` `@import` 本目录 `jetbrains-mono.css`,**禁止**再绑 Google Fonts CDN。
@@ -0,0 +1,45 @@
/**
* OneOS V2 · JetBrains Mono(自托管)
* 版本:2.304 · 许可:SIL OFL 1.1(见同目录 OFL.txt)
* 禁止改回 Google Fonts CDN:内网 / Windows 无外网时必须本地 woff2。
*/
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-ExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
const here = path.dirname(fileURLToPath(import.meta.url));
/** 抽出文件里指向某个模块的 import 说明符(支持静态 import 与动态 import())。 */
function importedSpecifier(source: string, moduleName: string): string | null {
const patterns = [
new RegExp(`from\\s*["']([^"']*${moduleName})["']`),
new RegExp(`import\\(\\s*["']([^"']*${moduleName})["']\\s*\\)`),
];
for (const re of patterns) {
const match = source.match(re);
if (match) return match[1];
}
return null;
}
test("能源氢费 BI 入口与独立验收地址复用同一看板", () => {
const entryPath = path.join(here, "index.tsx");
const appPath = path.join(here, "..", "..", "..", "App.tsx");
const entry = readFileSync(entryPath, "utf8");
const app = readFileSync(appPath, "utf8");
const entrySpec = importedSpecifier(entry, "EnergyBiBoardApp");
const appSpec = importedSpecifier(app, "EnergyBiBoardApp");
assert.ok(entrySpec, "氢费 BI 入口必须引用 EnergyBiBoardApp");
assert.ok(appSpec, "App.tsx 的独立验收路由必须引用 EnergyBiBoardApp");
// 不锁定具体路径,只锁定"两个入口解析到同一个文件",避免目录调整造成无意义漂移。
const resolve = (spec: string, from: string) => path.resolve(path.dirname(from), spec);
assert.equal(resolve(entrySpec!, entryPath), resolve(appSpec!, appPath));
assert.match(entry, /return <EnergyBiBoardApp \/>/);
});
@@ -1,4 +1,4 @@
import { EnergyBiBoardApp } from '../../vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp';
import { EnergyBiBoardApp } from './board/EnergyBiBoardApp';
/**
* The accepted hydrogen fee BI surface. It is shared with the independent
@@ -1,4 +1,4 @@
import type { H2BiDailyResponse } from "./types";
import type { H2BiDailyResponse } from "../types";
export function formatDailyChange(value: unknown): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
@@ -0,0 +1,63 @@
# 加氢站日报 · 产品需求说明(PRD)
> 原型路径:`src/prototypes/energy-h2-station-daily`
> 口令:`lingniu`(轻门禁)
> 设计:能源 BI / 经营看板皮(非 OneOS V2 台账)
> 对外:只叙事、禁听众标签、禁说明书墙
> 作者:OneOS
> 日期:2026-08-12
> **2026-08-13**:去掉总览堆积「加氢量占比」条;占比改到各站概况行内「加氢量占比」(含进度条);各站按区间加氢量从高到低排序
---
## 0. 定位与边界
| 项 | 口径 |
|---|---|
| 名称 | 加氢站日报 |
| 职责 | 查询区间总览 → 钻取单站明细;现结区展示进账事实 |
| 不做 | 现结登记办理;经营看板三维度;台账列表总览;「单站驾驶舱」表述;总览页导出 |
| 与经营看板 | 可嵌入经营看板「单站」;也可独立打开。**嵌入时看板标题旁不展示查询区间**(维度不同) |
### 视图决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 总览形态 | Hero KPI + 各站单卡片(行内加氢量占比) | 经营读数与下钻;占比不下单独堆积条 |
| 各站概况 | **单卡片**;默认「全部」,右上可切「单站」;**按区间加氢量降序** | 本尊 2026-08-12 / 2026-08-13 |
| 台账列表总览 | **不做** | 缺经营感 |
| 明细表集 | 日汇总 → 区间趋势 → 客户月量/费 →(收支∥现结明细) | 页长可控 |
| 车辆明细 | **不外挂**;仅导出取证派生 | 外层过长 |
| 时间维度 | **起止日期**(精确到日)+ 本日/本周/本月快捷;下方 KPI/站卡按区间重算 | 本尊 2026-08-12 |
| 看板嵌入 | 单站模式**不显示**经营看板顶栏时间芯片 | 查询维度不同 |
| 空数值 | 写 `0` | 本尊 2026-08-12 |
| 听众标签 | **禁止**进页面 | 只说事不对人 |
---
## 1. 用户故事
- **起点:** 打开日报 / 看板「单站」→ 选查询起止日期。
- **怎么运作:** KPI「统计加氢总量 / 统计加氢量 / 统计现结金额」可点开日明细;各站行内看「加氢量占比」与进度条;点站卡进站明细。
- **闭环:** 钻取页可导出 `.xlsx`;总览无导出。
---
## 2. 验收
1. 口令 `lingniu`。
2. 看板单站模式:标题旁**无**查询区间芯片;页面无「单站驾驶舱」文案。
3. 查询日期为起止选择器,含本日/本周/本月;改区间后 KPI/站卡同步变化。
4. 加氢站副文案为「统计站点数」;三个 KPI 可钻取日表(条数=区间天数)。
5. **无**总览堆积占比条;各站行有「加氢量占比」数字 + 进度条。
6. 各站概况为单卡片,右上「全部 / 单站」切换;全部列表按区间加氢量从高到低。
---
## 3. 关联
- 现结登记:`energy-spot-cash-intake`
- 经营看板:`energy-h2-bi-board`
- 共享现结:`oneos-energy-spot-cash-intake-v3`
- 加氢客户(外部)/ 外部车辆:`energy-h2-external-customer` · `energy-h2-external-vehicle`(外部量仅统计,不进车辆氢费明细)
- 量侧实站(禁造站):南海 + 东鹏大道(未提供 Excel 不上 mock)
@@ -0,0 +1,128 @@
/**
* 客户多选(能源 BI 皮 · 禁 V2)
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
export const SdCustomerMultiSelect: React.FC<{
options: string[];
value: string[];
onChange: (next: string[]) => void;
label?: string;
}> = ({ options, value, onChange, label = '客户' }) => {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const filtered = useMemo(() => {
const key = q.trim();
if (!key) return options;
return options.filter((n) => n.includes(key));
}, [options, q]);
const allSelected = value.length === 0 || value.length === options.length;
const triggerText = allSelected
? '全部客户'
: value.length <= 2
? value.join('、')
: `已选 ${value.length} 家`;
const toggle = (name: string) => {
if (value.length === 0) {
// 从「全部」切入:只留当前点中
onChange([name]);
return;
}
if (value.includes(name)) {
const next = value.filter((n) => n !== name);
onChange(next.length === 0 ? [] : next);
return;
}
const next = [...value, name];
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((v) => !v)}
>
<span className="sd-msel__label">{label}</span>
<span className="sd-msel__value" title={triggerText}>
{triggerText}
</span>
<ChevronDown size={14} aria-hidden className="sd-msel__chev" />
</button>
{open ? (
<div className="sd-msel__panel" role="listbox" aria-multiselectable>
<div className="sd-msel__search">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索客户"
aria-label="搜索客户"
/>
{q ? (
<button type="button" className="sd-msel__clear" onClick={() => setQ('')} aria-label="清空搜索">
<X size={12} />
</button>
) : null}
</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">
{filtered.length === 0 ? (
<li className="sd-msel__empty">无匹配客户</li>
) : (
filtered.map((name) => {
const checked = allSelected || value.includes(name);
return (
<li key={name}>
<button
type="button"
className={`sd-msel__opt ${checked ? 'is-on' : ''}`}
role="option"
aria-selected={checked}
onClick={() => toggle(name)}
>
<span className="sd-msel__check" aria-hidden>
{checked ? <Check size={12} /> : null}
</span>
<span className="sd-msel__name" title={name}>{name}</span>
</button>
</li>
);
})
)}
</ul>
</div>
) : null}
</div>
);
};
@@ -0,0 +1,263 @@
/**
* 站日报 · 起止日期区间(精确到日)+ 本日/本周/本月快捷
* 能源 BI 皮 · 禁原生 type=date · 禁 V2
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
function pad2(n: number) {
return n < 10 ? `0${n}` : `${n}`;
}
export function parseYmd(value: string) {
const parts = value.split('-');
const year = parseInt(parts[0], 10) || 2026;
const month = parseInt(parts[1], 10) || 1;
const day = parseInt(parts[2], 10) || 1;
return { year, month, day };
}
export function toYmd(year: number, month: number, day: number) {
return `${year}-${pad2(month)}-${pad2(day)}`;
}
export function displayYmd(value: string) {
const { year, month, day } = parseYmd(value);
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function ymdFromDate(d: Date) {
return toYmd(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
function startOfWeek(d: Date) {
const x = new Date(d);
const day = x.getDay();
const diff = day === 0 ? -6 : 1 - day; // 周一起
x.setDate(x.getDate() + diff);
return x;
}
export function rangeShortcuts(anchor = new Date()): Record<string, { start: string; end: string }> {
const today = ymdFromDate(anchor);
const sow = startOfWeek(anchor);
const eow = new Date(sow);
eow.setDate(sow.getDate() + 6);
const som = toYmd(anchor.getFullYear(), anchor.getMonth() + 1, 1);
const eomDate = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0);
return {
today: { start: today, end: today },
week: { start: ymdFromDate(sow), end: ymdFromDate(eow) },
month: { start: som, end: ymdFromDate(eomDate) },
};
}
type PickTarget = 'start' | 'end';
export const SdDateRangePicker: React.FC<{
label?: string;
start: string;
end: string;
onChange: (next: { start: string; end: string }) => void;
align?: 'left' | 'right';
/** 日历锚点日期(原型固定业务日,避免本机今天跑偏) */
anchorYmd?: string;
}> = ({ label = '查询日期', start, end, onChange, align = 'right', anchorYmd }) => {
const [open, setOpen] = useState(false);
const [picking, setPicking] = useState<PickTarget>('start');
const [draftStart, setDraftStart] = useState(start);
const [draftEnd, setDraftEnd] = useState(end);
const rootRef = useRef<HTMLDivElement>(null);
const anchor = useMemo(() => {
if (anchorYmd) {
const p = parseYmd(anchorYmd);
return new Date(p.year, p.month - 1, p.day);
}
return new Date();
}, [anchorYmd]);
const startP = useMemo(() => parseYmd(draftStart), [draftStart]);
const endP = useMemo(() => parseYmd(draftEnd), [draftEnd]);
const focus = picking === 'start' ? startP : endP;
const [viewYear, setViewYear] = useState(focus.year);
const [viewMonth, setViewMonth] = useState(focus.month);
useEffect(() => {
if (!open) return;
setDraftStart(start);
setDraftEnd(end);
setPicking('start');
const p = parseYmd(start);
setViewYear(p.year);
setViewMonth(p.month);
}, [open, start, end]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const daysInMonth = new Date(viewYear, viewMonth, 0).getDate();
const firstWeekday = new Date(viewYear, viewMonth - 1, 1).getDay();
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const blanks = Array.from({ length: firstWeekday }, (_, i) => i);
const goPrev = (e: React.MouseEvent) => {
e.stopPropagation();
if (viewMonth === 1) {
setViewYear((y) => y - 1);
setViewMonth(12);
} else setViewMonth((m) => m - 1);
};
const goNext = (e: React.MouseEvent) => {
e.stopPropagation();
if (viewMonth === 12) {
setViewYear((y) => y + 1);
setViewMonth(1);
} else setViewMonth((m) => m + 1);
};
const inRange = (ymd: string) => ymd >= draftStart && ymd <= draftEnd;
const isEdge = (ymd: string) => ymd === draftStart || ymd === draftEnd;
const pickDay = (day: number, e: React.MouseEvent) => {
e.stopPropagation();
const ymd = toYmd(viewYear, viewMonth, day);
if (picking === 'start') {
const nextStart = ymd;
const nextEnd = ymd > draftEnd ? ymd : draftEnd;
setDraftStart(nextStart);
setDraftEnd(nextEnd);
setPicking('end');
return;
}
if (ymd < draftStart) {
setDraftStart(ymd);
setDraftEnd(draftStart);
} else {
setDraftEnd(ymd);
}
setPicking('start');
};
const applyDraft = () => {
const a = draftStart <= draftEnd ? draftStart : draftEnd;
const b = draftStart <= draftEnd ? draftEnd : draftStart;
onChange({ start: a, end: b });
setOpen(false);
};
const applyShortcut = (key: 'today' | 'week' | 'month') => {
const map = rangeShortcuts(anchor);
const r = map[key];
setDraftStart(r.start);
setDraftEnd(r.end);
onChange(r);
setOpen(false);
};
return (
<div className={`sd-date sd-date--range ${align === 'right' ? 'sd-date--right' : ''}`} ref={rootRef}>
<button
type="button"
className={`sd-date__trigger ${open ? 'is-open' : ''}`}
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className="sd-date__label">{label}</span>
<span className="sd-date__value">
{displayYmd(start)} 至 {displayYmd(end)}
</span>
<Calendar size={15} aria-hidden className="sd-date__icon" />
</button>
{open ? (
<div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label={label}>
<div className="sd-date__shortcuts">
<button type="button" onClick={() => applyShortcut('today')}>
本日
</button>
<button type="button" onClick={() => applyShortcut('week')}>
本周
</button>
<button type="button" onClick={() => applyShortcut('month')}>
本月
</button>
</div>
<div className="sd-date__range-tabs">
<button
type="button"
className={picking === 'start' ? 'is-on' : ''}
onClick={() => setPicking('start')}
>
开始 {displayYmd(draftStart)}
</button>
<button
type="button"
className={picking === 'end' ? 'is-on' : ''}
onClick={() => setPicking('end')}
>
结束 {displayYmd(draftEnd)}
</button>
</div>
<div className="sd-date__header">
<button type="button" className="sd-date__nav" onClick={goPrev} aria-label="上一月">
<ChevronLeft size={16} />
</button>
<div className="sd-date__title">
{viewYear}年{pad2(viewMonth)}月
</div>
<button type="button" className="sd-date__nav" onClick={goNext} aria-label="下一月">
<ChevronRight size={16} />
</button>
</div>
<div className="sd-date__week">
{['日', '一', '二', '三', '四', '五', '六'].map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="sd-date__grid">
{blanks.map((i) => (
<span key={`b-${i}`} className="sd-date__day is-empty" />
))}
{days.map((d) => {
const ymd = toYmd(viewYear, viewMonth, d);
const cls = [
'sd-date__day',
isEdge(ymd) ? 'is-selected' : '',
inRange(ymd) ? 'is-in-range' : '',
]
.filter(Boolean)
.join(' ');
return (
<button key={d} type="button" className={cls} onClick={(e) => pickDay(d, e)}>
{d}
</button>
);
})}
</div>
<div className="sd-date__footer sd-date__footer--range">
<button type="button" className="sd-date__today" onClick={() => applyShortcut('today')}>
本日
</button>
<button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={applyDraft}>
确定
</button>
</div>
</div>
) : null}
</div>
);
};
@@ -0,0 +1,530 @@
// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved.
/**
* 加氢站日报 · 查询区间总览 → 钻取单站
* 起止日期驱动计算;对外只叙事
*/
import React, { useEffect, useMemo, useState } from 'react';
import { ArrowUpRight, Fuel, MapPin, RefreshCw, X } from 'lucide-react';
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
import { fetchHydrogenStationBoard } from '../../api';
import type { HydrogenStationBoardResponse } from '../../types';
import type { StationDailyVolumeRow } from './data/mockStationDaily';
import { SdDateRangePicker } from './SdDateRangePicker';
import { StationDailyDetailView } from './StationDailyDetailView';
import './styles.css';
function money(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function kg(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** 闭区间日历天数 */
function calendarDays(start: string, end: string): string[] {
const days: string[] = [];
const cursor = new Date(`${start}T00:00:00`);
const last = new Date(`${end}T00:00:00`);
while (cursor <= last) {
const y = cursor.getFullYear();
const m = String(cursor.getMonth() + 1).padStart(2, '0');
const d = String(cursor.getDate()).padStart(2, '0');
days.push(`${y}-${m}-${d}`);
cursor.setDate(cursor.getDate() + 1);
}
return days;
}
type StationOverview = {
id: string;
name: string;
region: string;
endKg: number;
endAmt: number;
endVehicles: number;
rangeKg: number;
rangeAmt: number;
cashDays: number;
cashTotal: number;
vols: StationDailyVolumeRow[];
share: number;
};
type KpiDrill = 'volumeTotal' | 'volumeDays' | 'cashDays' | null;
function StationTrend({ vols }: { vols: StationDailyVolumeRow[] }) {
const recent = vols.slice(-7);
const peak = Math.max(...recent.map((v) => v.quantityKg), 1);
return (
<div className="sd-station-row__trend" aria-label="近7日加氢趋势">
<span className="sd-station-row__trend-title">近7日趋势</span>
<div className="sd-station-row__trend-bars">
{recent.map((v) => (
<div
key={v.date}
className={`sd-station-row__trend-col ${v.quantityKg === 0 ? 'is-zero' : ''}`}
aria-label={`${v.date},加氢量 ${kg(v.quantityKg)} Kg`}
>
<span className="sd-station-row__trend-tooltip" role="tooltip">
<strong>{v.date}</strong>
<em>{kg(v.quantityKg)} Kg</em>
</span>
<span
className="sd-station-row__trend-bar-slot"
style={{ '--trend-bar-height': v.quantityKg === 0 ? '0%' : `${(v.quantityKg / peak) * 100}%` } as React.CSSProperties}
>
<span className="sd-station-row__trend-value">
{v.quantityKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<i style={{ height: v.quantityKg === 0 ? '0' : `${(v.quantityKg / peak) * 100}%` }} />
</span>
<small>{v.date.slice(5)}</small>
</div>
))}
{recent.length === 0 ? <span className="sd-station-row__trend-empty">暂无趋势数据</span> : null}
</div>
</div>
);
}
function localIsoDate(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
const defaultRange = (() => {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 9);
return { startDate: localIsoDate(start), end: localIsoDate(end) };
})();
function isAllowedSingleStation(name: string) {
return name.includes('东鹏大道') || (name.includes('佛山南海') && name.includes('羚牛'));
}
type StationDailyAppProps = {
embedded?: boolean;
startDate?: string;
endDate?: string;
onStartDateChange?: (value: string) => void;
onEndDateChange?: (value: string) => void;
refreshToken?: number;
onLoadingChange?: (loading: boolean) => void;
};
export const StationDailyApp: React.FC<StationDailyAppProps> = ({
embedded = false,
startDate: controlledStartDate,
endDate: controlledEndDate,
onStartDateChange,
onEndDateChange,
refreshToken = 0,
onLoadingChange,
}) => {
const [localStartDate, setLocalStartDate] = useState(defaultRange.startDate);
const [localEndDate, setLocalEndDate] = useState(defaultRange.end);
const startDate = controlledStartDate ?? localStartDate;
const endDate = controlledEndDate ?? localEndDate;
const setStartDate = onStartDateChange ?? setLocalStartDate;
const setEndDate = onEndDateChange ?? setLocalEndDate;
const [drillStationId, setDrillStationId] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState('2026-08-12 10:00');
const [tick, setTick] = useState(0);
const [kpiDrill, setKpiDrill] = useState<KpiDrill>(null);
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
const [liveError, setLiveError] = useState<string | null>(null);
const [liveLoading, setLiveLoading] = useState(true);
const [stationCashBoard, setStationCashBoard] = useState<HydrogenStationBoardResponse[] | null>(null);
const [stationCashError, setStationCashError] = useState<string | null>(null);
useEffect(() => {
let active = true;
setLiveLoading(true);
onLoadingChange?.(true);
setLiveError(null);
fetchHydrogenStationBoard({ startDate, endDate, force: tick > 0 })
.then((result) => {
if (!active) return;
setLiveBoard(result);
setUpdatedAt(result.summary.latestLedgerTime ?? '暂无账本更新时间');
setLiveLoading(false);
onLoadingChange?.(false);
})
.catch((reason: unknown) => {
if (!active) return;
setLiveError(reason instanceof Error ? reason.message : '单站统计加载失败');
setLiveLoading(false);
onLoadingChange?.(false);
});
return () => { active = false; };
}, [startDate, endDate, tick, refreshToken]);
useEffect(() => {
if (!drillStationId) return;
requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: 'auto' });
document.querySelector<HTMLElement>('.ehb-body')?.scrollTo({ top: 0, behavior: 'auto' });
});
}, [drillStationId]);
const overviewRows: StationOverview[] = useMemo(() => {
const base = (liveBoard?.stations ?? [])
.map((st) => {
const vols = st.dailyKg.map((row) => ({
date: row.date,
stationId: String(st.id),
quantityKg: row.kg,
unitPrice: st.kg > 0 ? st.fee / st.kg : 0,
amountYuan: st.kg > 0 ? row.kg * st.fee / st.kg : 0,
vehicleCount: 0,
}));
const endVol = vols.find((r) => r.date === endDate) || vols[vols.length - 1];
return {
id: String(st.id),
name: st.name,
region: [st.province, st.city].filter(Boolean).join(' · '),
endKg: endVol?.quantityKg ?? 0,
endAmt: endVol?.amountYuan ?? 0,
endVehicles: st.recordCount,
rangeKg: st.kg,
rangeAmt: st.fee,
cashDays: st.paymentCount,
cashTotal: st.paymentAmount,
vols,
share: st.share,
};
});
return base
.filter((station) => isAllowedSingleStation(station.name))
.sort((a, b) => b.rangeKg - a.rangeKg || a.name.localeCompare(b.name, 'zh-CN'));
}, [liveBoard, endDate]);
// 自营站列表完整展示,点击其中一站再进入单站详情。
const boardRows = overviewRows;
const stationIdsKey = overviewRows.map((row) => row.id).sort().join(',');
// 总览请求的 summary.daily 是全站口径;单站现结日表必须另以真实 stationId 查询,不能按比例拆分。
useEffect(() => {
if (!stationIdsKey) {
setStationCashBoard(null);
return;
}
let active = true;
setStationCashBoard(null);
setStationCashError(null);
Promise.all(stationIdsKey.split(',').map((id) =>
fetchHydrogenStationBoard({ startDate, endDate, stationId: Number(id), force: tick > 0 })))
.then((result) => {
if (active) setStationCashBoard(result);
})
.catch((reason: unknown) => {
if (active) setStationCashError(reason instanceof Error ? reason.message : '当前站点现结流水加载失败');
});
return () => { active = false; };
}, [stationIdsKey, startDate, endDate, tick]);
const totals = useMemo(() => {
return {
rangeKg: boardRows.reduce((sum, row) => sum + row.rangeKg, 0),
rangeAmt: boardRows.reduce((sum, row) => sum + row.rangeAmt, 0),
cashTotal: boardRows.reduce((sum, row) => sum + row.cashTotal, 0),
endKg: boardRows.reduce((sum, row) => sum + row.endKg, 0),
endVehicles: boardRows.reduce((sum, row) => sum + row.endVehicles, 0),
};
}, [boardRows]);
/** 区间内按日汇总(全站 · 无量日补 0) */
const dailyAgg = useMemo(() => {
const map = new Map<string, { date: string; kg: number; amt: number; vehicles: number }>();
boardRows.forEach((station) => station.vols.forEach((row) => {
const current = map.get(row.date) ?? { date: row.date, kg: 0, amt: 0, vehicles: 0 };
current.kg += row.quantityKg;
current.amt += row.amountYuan;
map.set(row.date, current);
}));
return calendarDays(startDate, endDate).map((date) => map.get(date) || { date, kg: 0, amt: 0, vehicles: 0 });
}, [boardRows, startDate, endDate]);
/** 当前选中站点的区间日现结;接口成功后才把无进账日期显式为 0。 */
const cashDaily = useMemo(() => {
if (!stationCashBoard || stationCashBoard.some((board) => !board.selected)) return null;
const map = new Map<string, number>();
for (const board of stationCashBoard) {
for (const row of board.selected!.daily) map.set(row.date, (map.get(row.date) ?? 0) + row.paymentAmount);
}
return calendarDays(startDate, endDate).map((date) => ({ date, amount: map.get(date) || 0 }));
}, [stationCashBoard, startDate, endDate]);
const handleRefresh = () => {
setTick((n) => n + 1);
};
const openStation = (id: string) => setDrillStationId(id);
if (drillStationId) {
const detail = (
<StationDailyDetailView
stationId={drillStationId}
asOf={endDate}
rangeStart={startDate}
rangeEnd={endDate}
updatedAt={updatedAt}
onBack={() => setDrillStationId(null)}
onRefresh={handleRefresh}
onRangeChange={({ start, end }) => {
setStartDate(start);
setEndDate(end);
}}
/>
);
if (embedded) {
return <div className="sd-embedded" data-annotation-id="energy-h2-station-daily-embedded">{detail}</div>;
}
return (
<div className="ehb-shell ehb-shell--station-daily" data-annotation-id="energy-h2-station-daily">
<div className="ehb-body sd-body">{detail}</div>
</div>
);
}
const hasCash = boardRows.some((r) => r.cashDays > 0);
const dayCount = calendarDays(startDate, endDate).length;
const cockpit = (
<>
<header className={`sd-topbar ${embedded ? 'sd-topbar--embedded' : ''}`}>
{!embedded ? (
<div className="sd-topbar__lead">
<p className="sd-topbar__kicker">羚牛氢能 · 加氢站经营</p>
<h1 className="sd-topbar__title">加氢站日报</h1>
<p className="sd-topbar__updated">最后更新时间 {updatedAt}</p>
</div>
) : null}
{!embedded ? <div className="sd-topbar__tools">
<SdDateRangePicker
label="查询日期"
start={startDate}
end={endDate}
anchorYmd={endDate}
onChange={({ start, end }) => {
setStartDate(start);
setEndDate(end);
}}
/>
<button type="button" className="sd-btn sd-btn--ghost" onClick={handleRefresh}>
<RefreshCw size={15} aria-hidden />
刷新
</button>
</div> : null}
</header>
<section className="sd-mobile-operating-overview" aria-label="经营概览">
<div className="sd-mobile-operating-overview__head">
<div>
<span>经营概览</span>
<small>{startDate} 至 {endDate}</small>
</div>
<strong>{boardRows.length} 个站点</strong>
</div>
<div className="sd-mobile-operating-overview__source">
数据来源:加氢业务账本;现结金额来自加氢站收款流水
</div>
<div className="sd-mobile-operating-overview__summary">
<button type="button" className="sd-mobile-operating-overview__primary" onClick={() => setKpiDrill('volumeTotal')}>
<span>统计加氢总量</span>
<strong>{kg(totals.rangeKg)}<small> Kg</small></strong>
<em><Fuel size={13} aria-hidden />{totals.endVehicles} 车次</em>
</button>
<div className="sd-mobile-operating-overview__financials">
<div><span>统计金额</span><strong>¥{money(totals.rangeAmt)}</strong></div>
<button type="button" onClick={() => setKpiDrill('cashDays')}>
<span>现结金额</span><strong>¥{money(totals.cashTotal)}</strong>
</button>
</div>
</div>
</section>
<div className="sd-hero-kpis" aria-label="全站核心指标">
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">加氢站</div>
<div className="sd-hero-kpi__value">{boardRows.length}</div>
<div className="sd-hero-kpi__sub">统计站点数</div>
<div className="sd-hero-kpi__source">来源:加氢业务账本(保留范围内无加氢记录的站点)<br />区间:{startDate} 至 {endDate}</div>
</div>
<button type="button" className="sd-hero-kpi sd-hero-kpi--accent sd-hero-kpi--click" onClick={() => setKpiDrill('volumeTotal')}>
<div className="sd-hero-kpi__label">统计加氢总量</div>
<div className="sd-hero-kpi__value">
{kg(totals.rangeKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">
{totals.endVehicles} 车次 · 点按查看日明细
</div>
<div className="sd-hero-kpi__source">来源:加氢业务账本(有效加氢记录按加氢量求和)<br />区间:{startDate} 至 {endDate}</div>
</button>
<button type="button" className="sd-hero-kpi sd-hero-kpi--click" onClick={() => setKpiDrill('volumeDays')}>
<div className="sd-hero-kpi__label">加氢车次</div>
<div className="sd-hero-kpi__value">
{totals.endVehicles}
<span className="sd-unit">车次</span>
</div>
<div className="sd-hero-kpi__sub">{dayCount} 天 · 日均 {(totals.endVehicles / Math.max(dayCount, 1)).toFixed(1)} 车次</div>
<div className="sd-hero-kpi__source">来源:加氢业务账本(有效记录数)<br />区间:{startDate} 至 {endDate}</div>
</button>
<button type="button" className="sd-hero-kpi sd-hero-kpi--click" onClick={() => setKpiDrill('cashDays')}>
<div className="sd-hero-kpi__label">统计现结金额</div>
<div className="sd-hero-kpi__value">
{money(totals.cashTotal)}
<span className="sd-unit">元</span>
</div>
<div className="sd-hero-kpi__sub">{cashDaily ? (hasCash ? `${cashDaily.filter((d) => d.amount > 0).length} 天有进账` : '0 天有进账') : '正在读取当前站点流水'}</div>
<div className="sd-hero-kpi__source">来源:加氢站收款流水(已入账金额)<br />区间:{startDate} 至 {endDate}</div>
</button>
</div>
{liveLoading && !liveBoard ? (
<div className="sd-live-loading-overlay" role="status" aria-live="polite">
<span className="sd-live-loading-spinner" aria-hidden />
<strong>正在读取单站真实统计数据</strong>
<span>加载完成前不展示业务零值</span>
</div>
) : liveLoading ? <div className="ehb-empty">正在更新,暂时保留上一份有效数据…</div> : null}
{liveError ? <div className="ehb-empty">单站统计加载失败:{liveError}</div> : null}
<section className="sd-station-board" aria-label="站点经营概况" data-mobile-fullscreen-list>
<div className="sd-section-head">
<h2 className="sd-section-title">站点经营概况</h2>
<span className="sd-station-board__fullscreen">
<MobileListFullscreenButton label="横屏全屏查看站点经营概况" />
</span>
<span className="sd-panel__meta">全部自营站 · {boardRows.length} 站</span>
</div>
<div className="sd-station-single-card">
{boardRows.map((r) => (
<button
key={r.id}
type="button"
className="sd-station-row is-solo"
onClick={() => openStation(r.id)}
>
<div className="sd-station-row__main">
<span className="sd-station-card__icon" aria-hidden>
<Fuel size={18} />
</span>
<div className="sd-station-row__id">
<div className="sd-station-card__name">{r.name}</div>
{r.endVehicles === 0 ? <div className="sd-station-card__metric-source">当前期间无加氢记录</div> : null}
<div className="sd-station-card__region">
<MapPin size={12} aria-hidden />
{r.region}
</div>
</div>
<div className="sd-station-row__metrics">
<div>
<span className="sd-station-card__m-label">加氢量</span>
<strong>
{kg(r.rangeKg)}
<span>Kg</span>
</strong>
<small className="sd-station-card__metric-source">加氢业务账本 · {startDate} 至 {endDate}</small>
</div>
<div>
<span className="sd-station-card__m-label">车次</span>
<strong>{r.endVehicles}</strong>
</div>
<div>
<span className="sd-station-card__m-label">金额</span>
<strong>¥{money(r.rangeAmt)}</strong>
</div>
</div>
<StationTrend vols={r.vols} />
<span className="sd-station-card__go" aria-hidden>
<ArrowUpRight size={16} />
</span>
</div>
</button>
))}
</div>
</section>
{kpiDrill ? (
<div className="sd-kpi-modal" role="dialog" aria-modal="true">
<button type="button" className="sd-kpi-modal__mask" aria-label="关闭" onClick={() => setKpiDrill(null)} />
<div className="sd-kpi-modal__panel">
<div className="sd-kpi-modal__head">
<h3>
{kpiDrill === 'volumeTotal' && '统计加氢总量 · 日明细'}
{kpiDrill === 'volumeDays' && '统计加氢量 · 单日数据'}
{kpiDrill === 'cashDays' && '统计现结金额 · 单日数据'}
</h3>
<span className="sd-kpi-modal__meta">
{startDate} 至 {endDate}
</span>
<button type="button" className="sd-btn sd-btn--ghost" onClick={() => setKpiDrill(null)}>
<X size={14} aria-hidden />
关闭
</button>
</div>
<div className="sd-table-scroll">
{kpiDrill === 'cashDays' && cashDaily ? (
<table className="sd-bi-table sd-bi-table--fill">
<thead>
<tr>
<th>日期</th>
<th className="is-num">现结金额(元)</th>
</tr>
</thead>
<tbody>
{cashDaily.map((d) => (
<tr key={d.date}>
<td className="is-mono">{d.date}</td>
<td className="is-num">{money(d.amount)}</td>
</tr>
))}
</tbody>
</table>
) : kpiDrill === 'cashDays' ? (
<div className="ehb-empty" role="status">
{stationCashError ? `当前站点现结流水加载失败:${stationCashError}` : '正在读取当前站点现结流水…'}
</div>
) : (
<table className="sd-bi-table sd-bi-table--fill">
<thead>
<tr>
<th>日期</th>
<th className="is-num">加氢车次</th>
<th className="is-num">加氢量(Kg)</th>
<th className="is-num">加氢金额(元)</th>
</tr>
</thead>
<tbody>
{dailyAgg.map((d) => (
<tr key={d.date}>
<td className="is-mono">{d.date}</td>
<td className="is-num">{d.vehicles}</td>
<td className="is-num">{kg(d.kg)}</td>
<td className="is-num">{money(d.amt)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
) : null}
</>
);
if (embedded) {
return <div className="sd-embedded" data-annotation-id="energy-h2-station-daily-embedded">{cockpit}</div>;
}
return (
<div className="ehb-shell ehb-shell--station-daily" data-annotation-id="energy-h2-station-daily">
<div className="ehb-body sd-body">{cockpit}</div>
</div>
);
};
@@ -0,0 +1,884 @@
// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved.
/**
* 单站日报明细 · 对齐汇报 Excel 精简表集
* 近7日红涨绿跌 · 月环比同色 · 列表最多10条可展开
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react';
import { downloadExcelAoa } from '../common/download-xls';
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
import {
SPOT_PAY_METHOD_LABEL,
type StationCashIntakeDay,
} from '../common/energy-spot-cash-intake';
import { fetchHydrogenStationBoard } from '../../api';
import { fetchAllH2BiDrillRecords } from '../api';
import { PrototypeDrillModal } from '../drill/prototype-real-drills';
import { MobileDailyList, MobileCustomerMonthList } from './StationMobileLists';
import type { HydrogenStationBoardResponse } from '../../types';
import {
monthTotals,
stationTrendDateLabel,
} from './data/mockStationDaily';
import { SdCustomerMultiSelect } from './SdCustomerMultiSelect';
import { SdDateRangePicker } from './SdDateRangePicker';
import { customerMonthLabel, customerMonthRange, dateRangeLabel } from './station-month-range';
const ROW_LIMIT = 10;
function money(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function kg(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function padYmd(raw: string) {
const parts = String(raw || '').split(/[-/]/);
if (parts.length < 3) return raw;
const y = parts[0];
const m = String(Number(parts[1]) || 0).padStart(2, '0');
const d = String(Number(parts[2]) || 0).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/** 股市色:升红 · 降绿 · 平灰 */
function stockDeltaClass(curr: number, prev: number | null | undefined): string {
if (prev == null || !Number.isFinite(prev)) return '';
if (curr > prev) return 'is-stock-up';
if (curr < prev) return 'is-stock-down';
return 'is-stock-flat';
}
/** 普通业务数值不使用涨跌色;仅零值弱化、负余额标记风险。 */
function businessValueClass(n: number, negativeIsRisk = false): string {
if (n === 0) return 'is-zero';
if (negativeIsRisk && n < 0) return 'is-risk-negative';
return '';
}
function DeltaMark({ curr, prev }: { curr: number; prev: number | null | undefined }) {
if (prev == null || !Number.isFinite(prev) || curr === prev) return null;
return (
<span className="sd-delta" aria-hidden>
{curr > prev ? '▲' : '▼'}
</span>
);
}
function MoreToggle({
expanded,
total,
onToggle,
}: {
expanded: boolean;
total: number;
onToggle: () => void;
}) {
if (total <= ROW_LIMIT) return null;
return (
<button type="button" className="sd-more-btn" onClick={onToggle}>
{expanded ? (
<>
收起
<ChevronUp size={14} aria-hidden />
</>
) : (
<>
更多(还有 {total - ROW_LIMIT} 条)
<ChevronDown size={14} aria-hidden />
</>
)}
</button>
);
}
export const StationDailyDetailView: React.FC<{
stationId: string;
asOf: string;
rangeStart: string;
rangeEnd: string;
updatedAt: string;
onBack: () => void;
onRefresh: () => void;
onRangeChange: (next: { start: string; end: string }) => void;
}> = ({ stationId, asOf, rangeStart, rangeEnd, updatedAt, onBack, onRefresh, onRangeChange }) => {
const [cashTick, setCashTick] = useState(0);
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([]);
const [hoverDate, setHoverDate] = useState<string | null>(null);
const [expandCust, setExpandCust] = useState(false);
const [customerMonthlyMetric, setCustomerMonthlyMetric] = useState<'volume' | 'fee'>('volume');
const [expandBal, setExpandBal] = useState(false);
const [expandCash, setExpandCash] = useState(false);
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
const [orderDate, setOrderDate] = useState<string | null>(null);
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const exportController = useRef<AbortController | null>(null);
const [liveError, setLiveError] = useState<string | null>(null);
const [liveLoading, setLiveLoading] = useState(true);
useEffect(() => {
let active = true;
exportController.current?.abort();
exportController.current = null;
setExporting(false);
setExportError(null);
setOrderDate(null);
setLiveError(null);
setLiveLoading(true);
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
setLiveBoard(null);
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId), force: cashTick > 0 }).then((board) => {
if (!active) return;
setLiveBoard(board);
setLiveLoading(false);
}).catch((reason: unknown) => {
if (!active) return;
setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败');
setLiveLoading(false);
});
return () => { active = false; exportController.current?.abort(); };
}, [stationId, rangeStart, rangeEnd, updatedAt, cashTick]);
const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId));
const stationName = liveStation?.name || stationId;
const region = liveStation ? [liveStation.province, liveStation.city].filter(Boolean).join(' · ') : '';
const startDate = rangeStart;
const end = rangeEnd;
// 接口口径:截至查询结束日的近 12 个月;缺失月只补展示槽位,不新增业务数据。
const customerMonthKeys = useMemo(() => customerMonthRange(end), [end]);
useEffect(() => {
const latestMonth = customerMonthKeys[customerMonthKeys.length - 1] ?? '';
if (!customerMonthKeys.includes(mobileMonthKey)) setMobileMonthKey(latestMonth);
}, [customerMonthKeys, mobileMonthKey]);
const volumeRows = useMemo(
() => (liveBoard?.selected?.daily ?? []).map((row) => ({
date: row.date,
stationId,
quantityKg: row.kg,
unitPrice: row.avgPrice,
amountYuan: row.fee,
vehicleCount: row.recordCount,
})),
[liveBoard, stationId],
);
/** 近 7 日(按日期升序取末 7 条) */
const volume7 = useMemo(() => {
const sorted = [...volumeRows].sort((a, b) => a.date.localeCompare(b.date));
return sorted.slice(-7);
}, [volumeRows]);
const volume7Kg = volume7.reduce((s, r) => s + r.quantityKg, 0);
const volume7Amt = volume7.reduce((s, r) => s + r.amountYuan, 0);
const volume7Vehicles = volume7.reduce((s, r) => s + r.vehicleCount, 0);
const asOfVolume = volumeRows.find((r) => r.date === asOf) || volumeRows[volumeRows.length - 1];
const rangeKg = volumeRows.reduce((s, r) => s + r.quantityKg, 0);
const rangeAmt = volumeRows.reduce((s, r) => s + r.amountYuan, 0);
const monthKg = volumeRows
.filter((r) => r.date.startsWith(asOf.slice(0, 7)))
.reduce((s, r) => s + r.quantityKg, 0);
const prevDayKg = useMemo(() => {
if (!asOfVolume) return null;
const sorted = [...volumeRows].sort((a, b) => a.date.localeCompare(b.date));
const idx = sorted.findIndex((r) => r.date === asOfVolume.date);
return idx > 0 ? sorted[idx - 1].quantityKg : null;
}, [asOfVolume, volumeRows]);
const customerMonthRows = liveBoard?.selected?.customerMonths;
const allCustCells = useMemo(
() => {
const byCustomer = new Map<string, Record<string, number>>();
for (const row of customerMonthRows ?? []) {
const months = byCustomer.get(row.customerName) ?? {};
const monthKey = String(row.month).slice(0, 7);
months[monthKey] = (months[monthKey] ?? 0) + row.kg;
byCustomer.set(row.customerName, months);
}
return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months }));
},
[customerMonthRows, stationId],
);
const customerOptions = useMemo(
() => allCustCells.map((c) => c.customerName),
[allCustCells],
);
const custCells = useMemo(() => {
if (selectedCustomers.length === 0) return allCustCells;
const set = new Set(selectedCustomers);
return allCustCells.filter((c) => set.has(c.customerName));
}, [allCustCells, selectedCustomers]);
const allFeeCells = useMemo(() => {
const byCustomer = new Map<string, Record<string, number>>();
for (const row of customerMonthRows ?? []) {
const months = byCustomer.get(row.customerName) ?? {};
const monthKey = String(row.month).slice(0, 7);
months[monthKey] = (months[monthKey] ?? 0) + row.fee;
byCustomer.set(row.customerName, months);
}
return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months }));
}, [customerMonthRows, stationId]);
const feeCells = useMemo(() => {
const allowed = new Set(custCells.map((row) => row.customerName));
return allFeeCells.filter((row) => allowed.has(row.customerName));
}, [custCells, allFeeCells]);
const kgMonthTot = useMemo(() => monthTotals(custCells, customerMonthKeys), [custCells, customerMonthKeys]);
const feeMonthTot = useMemo(() => monthTotals(feeCells, customerMonthKeys), [feeCells, customerMonthKeys]);
const custVisible = expandCust ? custCells : custCells.slice(0, ROW_LIMIT);
const feeVisible = expandCust ? feeCells : feeCells.slice(0, ROW_LIMIT);
const cashDays: StationCashIntakeDay[] = useMemo(() => {
void cashTick;
return (liveBoard?.selected?.daily ?? []).filter((row) => row.paymentAmount > 0).map((row) => ({
id: `payment-${stationId}-${row.date}`,
stationId,
stationName,
bizDate: row.date,
totalAmount: row.paymentAmount,
lines: [{ id: `payment-line-${stationId}-${row.date}`, customerName: '站点现结汇总', amount: row.paymentAmount, payMethod: 'other' }],
updatedBy: '只读账本',
updatedAt,
}));
}, [liveBoard, stationId, stationName, updatedAt, cashTick]);
const cashLines = useMemo(
() =>
(liveBoard?.selected?.externalReceipts?.rows ?? []).map((row) => ({
...row, bizDate: row.date,
sourceLabel: row.source === 'spot_daily_auto' ? '现结自动汇总'
: row.source === 'external_recharge_manual' ? '手工充值' : `未知来源(${row.source})`,
payLabel: row.payMethod === 'corporate' ? '对公转账' : row.payMethod === 'wechat' ? '微信' : row.payMethod || '未注明',
})),
[liveBoard],
);
const receiptTotal = cashLines.reduce((sum, row) => sum + row.amount, 0);
const cashVisible = expandCash ? cashLines : cashLines.slice(0, ROW_LIMIT);
const cashTotal = cashDays.reduce((s, d) => s + d.totalAmount, 0);
const balanceRows = useMemo(
() => [],
[],
);
const balVisible = expandBal ? balanceRows : balanceRows.slice(0, ROW_LIMIT);
const balanceSubtotal = useMemo(
() => ({
recharge: balanceRows.reduce((s, b) => s + b.rechargeOrSpotYuan, 0),
prepaid: balanceRows.reduce((s, b) => s + b.consumePrepaidYuan, 0),
spot: balanceRows.reduce((s, b) => s + b.consumeSpotYuan, 0),
balance: balanceRows.reduce((s, b) => s + b.balanceYuan, 0),
}),
[balanceRows],
);
const maxKg = Math.max(...volumeRows.map((r) => r.quantityKg), 1);
const hoverRow = hoverDate ? volumeRows.find((r) => r.date === hoverDate) : null;
const handleExport = async () => {
if (liveLoading || liveError || !liveBoard || exportController.current) return;
const controller = new AbortController();
exportController.current = controller;
setExporting(true);
setExportError(null);
try {
if (!liveBoard?.selected?.customerMonths || !liveBoard?.selected?.externalReceipts) {
throw new Error('客户数据未完整返回,暂不导出,请刷新后重试');
}
const result = await fetchAllH2BiDrillRecords({
year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd,
vehicleScope: 'all', verifyScope: 'all', stationId,
}, { signal: controller.signal });
if (controller.signal.aborted) return;
const vehicleRows = result.records.map((row) => ({
date: String(row.time ?? '').slice(0, 10),
plateNo: String(row.plateNo ?? '未关联车牌'),
customerName: String(row.customerName ?? '未关联客户'),
fleet: row.vehicleScope === 'lingniu' ? 'own' : 'external',
quantityKg: Number(row.kg) || 0,
unitPrice: Number(row.unitPrice) || 0,
amountYuan: Number(row.cost) || 0,
}));
const monthHeads = customerMonthKeys.map(customerMonthLabel);
const aoa: (string | number)[][] = [
[`${stationName} · 站日报(查询日期 ${asOf})`],
['统计区间', `${startDate} 至 ${end}`],
['最后更新时间', updatedAt],
[],
['近7日加氢量'],
['日期', '加氢量(Kg)', '单价', '金额(元)', '车次'],
...volume7.map((r) => [r.date, r.quantityKg, r.unitPrice, r.amountYuan, r.vehicleCount]),
[],
['客户月加氢量(Kg)'],
['客户', ...monthHeads],
...custCells.map((c) => [
c.customerName,
...customerMonthKeys.map((m) => c.months[m] ?? 0),
]),
[],
['客户月加氢费(元)'],
['客户', ...monthHeads],
...feeCells.map((c) => [
c.customerName,
...customerMonthKeys.map((m) => c.months[m] ?? 0),
]),
[],
['客户氢费收支汇总'],
['客户', '充值/现金结算', '扣预付消费', '现结消费', '余额', '备注'],
...balanceRows.map((b) => [
b.customerName,
b.rechargeOrSpotYuan,
b.consumePrepaidYuan,
b.consumeSpotYuan,
b.balanceYuan,
b.remark || '',
]),
['未接入完整账户余额,不以零代替缺失数据'],
[],
['外部客户进账(当前租户全部外部客户,不按站点归属;不计入单站收益)'],
['日期', '客户', '付款方式', '金额(元)', '来源', '源记录数', '刷新时间', '记录ID'],
...cashLines.map((l) => [l.bizDate, l.customerName, l.payLabel, l.amount, l.sourceLabel, l.sourceRecordCount, l.updatedAt ?? '', l.id]),
[],
['自营站全部车辆客户加氢月度(不含手工充值)'],
['月份', '客户', '加氢量(Kg)', '加氢金额(元)', '记录数'],
...(liveBoard?.selected?.customerMonths ?? []).map((r) => [r.month, r.customerName, r.kg, r.fee, r.recordCount]),
[],
['车辆加氢明细(查询区间全部真实账本记录)', result.records.length],
['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'],
...vehicleRows.map((r) => [
r.date,
r.plateNo,
r.customerName,
r.fleet === 'own' ? '羚牛车辆' : '外部车辆',
r.quantityKg,
r.unitPrice,
r.amountYuan,
]),
];
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
} catch (reason) {
if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试');
} finally {
if (exportController.current === controller) {
exportController.current = null;
setExporting(false);
}
}
};
const renderMonthCells = (
months: Record<string, number>,
fmt: (n: number) => string,
) =>
customerMonthKeys.map((m, i) => {
const curr = months[m] ?? 0;
const prevKey = i > 0 ? customerMonthKeys[i - 1] : null;
const prev = prevKey != null ? (months[prevKey] ?? 0) : null;
const cls = stockDeltaClass(curr, prev);
const zeroClass = curr === 0 ? 'is-zero' : '';
const currentMonthClass =
i === customerMonthKeys.length - 1 ? 'is-current-month' : '';
return (
<td key={m} className={`is-num ${cls} ${zeroClass} ${currentMonthClass}`}>
{fmt(curr)}
<DeltaMark curr={curr} prev={prev} />
</td>
);
});
if (liveLoading || liveError) {
return (
<div className="sd-detail" data-annotation-id="station-daily-detail">
<header className="sd-detail-top">
<div className="sd-detail-top__lead">
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack} aria-label="返回上一级">
<ArrowLeft size={15} aria-hidden />
返回
</button>
<div>
<h1 className="sd-detail-top__title">{stationName}</h1>
<p className="sd-detail-top__meta">{startDate} 至 {end}</p>
</div>
</div>
</header>
<div className="ehb-empty" role={liveError ? 'alert' : 'status'}>
{liveError ? `站点详情加载失败:${liveError}` : '正在读取当前站点真实统计数据,完成前不展示业务零值'}
</div>
</div>
);
}
return (
<div className="sd-detail sd-detail--mobile-pilot" data-annotation-id="station-daily-detail">
<header className="sd-detail-top">
<div className="sd-detail-top__lead">
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}>
<ArrowLeft size={15} aria-hidden />
返回
</button>
<div>
<h1 className="sd-detail-top__title">{stationName}</h1>
<p className="sd-detail-top__meta">
{region ? `${region} · ` : ''}
{startDate} 至 {end}
</p>
<p className="sd-detail-top__updated">最后更新时间 {updatedAt}</p>
</div>
</div>
<div className="sd-detail-top__tools">
<SdDateRangePicker
label="查询日期"
start={rangeStart}
end={rangeEnd}
anchorYmd={asOf}
onChange={onRangeChange}
/>
<button
type="button"
className="sd-btn sd-btn--ghost"
onClick={() => {
onRefresh();
setCashTick((n) => n + 1);
}}
>
<RefreshCw size={16} aria-hidden />
刷新
</button>
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" disabled={liveLoading || Boolean(liveError) || exporting} onClick={handleExport}>
<Download size={15} aria-hidden />
{exporting ? '正在读取全部记录…' : '导出取证'}
</button>
{exporting ? <button type="button" className="sd-btn sd-btn--ghost" onClick={() => exportController.current?.abort()}>取消导出</button> : null}
</div>
</header>
{exportError ? <div role="alert" className="ehb-empty">导出失败:{exportError}。未生成文件,请缩小日期范围或重试。</div> : null}
{liveError ? (
<div className="ehb-empty" role="alert">站点详情加载失败:{liveError}</div>
) : null}
<div className="sd-hero-kpis sd-hero-kpis--detail">
<div className="sd-hero-kpi sd-hero-kpi--accent">
<div className="sd-hero-kpi__label">当日加氢</div>
<div className={`sd-hero-kpi__value ${stockDeltaClass(asOfVolume?.quantityKg ?? 0, prevDayKg)}`}>
{asOfVolume ? kg(asOfVolume.quantityKg) : '0.00'}
<span className="sd-unit">Kg</span>
<DeltaMark curr={asOfVolume?.quantityKg ?? 0} prev={prevDayKg} />
</div>
<div className="sd-hero-kpi__sub">
{asOfVolume
? `¥${money(asOfVolume.amountYuan)} · ${asOfVolume.vehicleCount} 车次`
: '¥0.00 · 0 车次'}
</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">查询区间加氢</div>
<div className="sd-hero-kpi__value">
{kg(rangeKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">区间内本月加氢</div>
<div className="sd-hero-kpi__value">
{kg(monthKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)} · 仅计入查询区间</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">单站现结流水</div>
<div className="sd-hero-kpi__value">
{money(cashTotal)}
<span className="sd-unit">元</span>
</div>
<div className="sd-hero-kpi__sub">
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
<br />不含客户级充值/进账
</div>
</div>
</div>
<div className="sd-mobile-detail-hub" data-mobile-fullscreen-list>
<header className="sd-mobile-detail-tabs">
<div className="sd-mobile-detail-tabs__title">
<span>经营明细</span>
<MobileListFullscreenButton label="横屏全屏查看经营明细" placement="inline" />
</div>
<div className="sd-mobile-detail-tabs__rail" role="tablist" aria-label="经营明细类型">
{([
['daily', '日加氢'],
['customer', '客户月度'],
['balance', '收支'],
['cash', '进账'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={mobileDetailTab === key}
className={mobileDetailTab === key ? 'is-active' : ''}
onClick={() => setMobileDetailTab(key)}
>
{label}
</button>
))}
</div>
</header>
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'daily' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<h2 className="sd-panel__title">加氢站每日加氢量汇总(近 7 日)</h2>
<MobileDailyList key={`${stationId}|${rangeStart}|${rangeEnd}`}
rows={volume7} allRows={volumeRows} loading={liveLoading} error={liveError}
onOpenOrders={setOrderDate} />
<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>近 7 日合计</td>
<td className="is-num">{kg(volume7Kg)}</td>
<td className="is-num">0</td>
<td className="is-num">0</td>
<td className="is-num">{money(volume7Amt)}</td>
<td className="is-num">{volume7Vehicles}</td>
</tr>
{volume7.length === 0 ? (
<tr>
<td colSpan={6} className="ehb-empty-cell">
本窗暂无加氢量
</td>
</tr>
) : (
volume7.map((r, i) => {
const prev = i > 0 ? volume7[i - 1].quantityKg : null;
// 较昨日:用完整序列前一日更准
const fullIdx = volumeRows.findIndex((x) => x.date === r.date);
const prevFull =
fullIdx > 0 ? volumeRows[fullIdx - 1].quantityKg : prev;
const diff = prevFull == null ? null : r.quantityKg - prevFull;
const cls = stockDeltaClass(r.quantityKg, prevFull);
return (
<tr key={r.date}>
<td>{padYmd(r.date)}</td>
<td className={`is-num ${cls}`}>
{kg(r.quantityKg)}
<DeltaMark curr={r.quantityKg} prev={prevFull} />
</td>
<td className={`is-num ${cls}`}>
{diff == null ? '0.00' : `${diff > 0 ? '+' : ''}${kg(diff)}`}
</td>
<td className="is-num">{r.unitPrice}</td>
<td className="is-num">{money(r.amountYuan)}</td>
<td className="is-num">{r.vehicleCount}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
<section className="sd-panel sd-panel--block sd-mobile-trend-panel">
<div className="sd-panel__head-row">
<h2 className="sd-panel__title">区间加氢量趋势</h2>
<span className="sd-trend-legend-chip" aria-hidden>
<i className="sd-trend-legend-dot" />
{stationName}
</span>
</div>
<div
className="sd-trend sd-trend--fill"
role="img"
aria-label="区间加氢量趋势"
onMouseLeave={() => setHoverDate(null)}
>
{volumeRows.length === 0 ? (
<div className="ehb-empty-cell" style={{ padding: 24 }}>
本窗暂无趋势
</div>
) : (
volumeRows.map((r) => (
<div
key={r.date}
className={`sd-trend__col ${hoverDate === r.date ? 'is-hover' : ''}`}
onMouseEnter={() => setHoverDate(r.date)}
>
<div className="sd-trend__val">{r.quantityKg.toFixed(0)}</div>
<div className="sd-trend__bar-wrap">
<div
className="sd-trend__bar"
style={{ height: `${Math.max(8, (r.quantityKg / maxKg) * 100)}%` }}
/>
</div>
<div className="sd-trend__date">
<span className="sd-trend__date--desktop">{stationTrendDateLabel(r.date)}</span>
<span className="sd-trend__date--mobile">{stationTrendDateLabel(r.date).slice(5)}</span>
</div>
</div>
))
)}
{hoverRow ? (
<div className="sd-trend-tip" role="tooltip">
<div className="sd-trend-tip__date">{padYmd(hoverRow.date)}</div>
<div className="sd-trend-tip__row">
<i className="sd-trend-legend-dot" />
<span className="sd-trend-tip__name">{stationName}</span>
<strong>
{kg(hoverRow.quantityKg)} Kg · ¥{money(hoverRow.amountYuan)}
</strong>
</div>
<div className="sd-trend-tip__sub">{hoverRow.vehicleCount} 车次</div>
</div>
) : null}
</div>
</section>
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'customer' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<div className="sd-panel__head-row">
<div className="sd-customer-month-head">
<h2 className="sd-panel__title">客户月度汇总(近 12 个月)</h2>
<div className="sd-customer-month-tabs" role="tablist" aria-label="客户月度汇总指标">
<button
type="button"
role="tab"
aria-selected={customerMonthlyMetric === 'volume'}
className={customerMonthlyMetric === 'volume' ? 'is-active' : ''}
onClick={() => setCustomerMonthlyMetric('volume')}
>
加氢量(Kg)
</button>
<button
type="button"
role="tab"
aria-selected={customerMonthlyMetric === 'fee'}
className={customerMonthlyMetric === 'fee' ? 'is-active' : ''}
onClick={() => setCustomerMonthlyMetric('fee')}
>
加氢费(元)
</button>
</div>
</div>
<SdCustomerMultiSelect
options={customerOptions}
value={selectedCustomers}
onChange={setSelectedCustomers}
/>
</div>
<p className="sd-panel__meta">自营站全部车辆与客户,包含羚牛及外部车辆;未关联客户保留统计,不含手工充值。</p>
<MobileCustomerMonthList key={stationId} months={customerMonthKeys}
volumeCustomers={allCustCells} feeCustomers={allFeeCells}
month={mobileMonthKey} onMonthChange={setMobileMonthKey}
metric={customerMonthlyMetric} onMetricChange={setCustomerMonthlyMetric}
loading={liveLoading} error={liveError || (!customerMonthRows ? '客户数据暂不可用' : null)} />
<div className="sd-table-scroll sd-table-scroll--matrix sd-desktop-matrix-table">
<table className={`sd-bi-table sd-bi-table--matrix ${customerMonthlyMetric === 'fee' ? 'is-amount' : ''}`}>
<thead>
<tr>
<th>客户</th>
{customerMonthKeys.map((m, i) => (
<th
key={m}
className={`is-num ${i === customerMonthKeys.length - 1 ? 'is-current-month' : ''}`}
>
{customerMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="is-total">
<td>合计</td>
{customerMonthlyMetric === 'volume'
? renderMonthCells(kgMonthTot, kg)
: renderMonthCells(feeMonthTot, money)}
</tr>
{(customerMonthlyMetric === 'volume' ? custCells : feeCells).length === 0 ? (
<tr>
<td colSpan={1 + customerMonthKeys.length} className="ehb-empty-cell">
{!customerMonthRows ? '客户数据暂不可用' : '无匹配客户'}
</td>
</tr>
) : (
(customerMonthlyMetric === 'volume' ? custVisible : feeVisible).map((c) => (
<tr key={c.customerName}>
<td>{c.customerName}</td>
{renderMonthCells(c.months, customerMonthlyMetric === 'volume' ? kg : money)}
</tr>
))
)}
</tbody>
</table>
</div>
<div className="sd-table-scroll sd-mobile-combined-fullscreen-table">
<table className="sd-bi-table sd-bi-table--ledger">
<thead><tr><th>客户</th><th className="is-num">加氢量(Kg)</th><th className="is-num">加氢费(元)</th></tr></thead>
<tbody>
{custVisible.map((customer) => {
const feeCustomer = feeCells.find((item) => item.customerName === customer.customerName);
const volumeValue = customer.months[mobileMonthKey] ?? 0;
const feeValue = feeCustomer?.months[mobileMonthKey] ?? 0;
return <tr key={customer.customerName}><td>{customer.customerName}</td><td className={`is-num ${businessValueClass(volumeValue)}`}>{kg(volumeValue)}</td><td className={`is-num ${businessValueClass(feeValue)}`}>{money(feeValue)}</td></tr>;
})}
</tbody>
</table>
</div>
<MoreToggle
expanded={expandCust}
total={(customerMonthlyMetric === 'volume' ? custCells : feeCells).length}
onToggle={() => setExpandCust((v) => !v)}
/>
</section>
<div className="sd-dual sd-dual--cash sd-dual--ledger">
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'balance' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<h2 className="sd-panel__title">客户氢费收支汇总</h2>
<p className="sd-panel__meta">完整账户余额未接入:新进账表不能单独推算余额或经营利润。</p>
<div className="sd-mobile-record-list sd-mobile-balance-list">
{balVisible.map((customer) => (
<article key={customer.customerName} className="sd-mobile-record">
<div className="sd-mobile-record__lead"><strong>{customer.customerName}</strong><span>{customer.remark || '账户正常'}</span></div>
<div className="sd-mobile-record__value"><strong className={businessValueClass(customer.balanceYuan, true)}>¥{money(customer.balanceYuan)}</strong><span>余额</span></div>
<div className="sd-mobile-record__meta">充值 ¥{money(customer.rechargeOrSpotYuan)} · 扣预付 ¥{money(customer.consumePrepaidYuan)} · 现结 ¥{money(customer.consumeSpotYuan)}</div>
</article>
))}
</div>
<div className="sd-table-scroll">
<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>
{balanceRows.length === 0 ? (
<tr>
<td colSpan={6} className="ehb-empty-cell">
暂无收支汇总
</td>
</tr>
) : (
<>
{balVisible.map((b) => (
<tr key={b.customerName}>
<td title={b.customerName}>{b.customerName}</td>
<td className={`is-num ${businessValueClass(b.rechargeOrSpotYuan)}`}>
{money(b.rechargeOrSpotYuan)}
</td>
<td className={`is-num ${businessValueClass(b.consumePrepaidYuan)}`}>
{money(b.consumePrepaidYuan)}
</td>
<td className={`is-num ${businessValueClass(b.consumeSpotYuan)}`}>
{money(b.consumeSpotYuan)}
</td>
<td className={`is-num ${businessValueClass(b.balanceYuan, true)}`}>
{money(b.balanceYuan)}
</td>
<td>{b.remark || ''}</td>
</tr>
))}
<tr className="is-total">
<td>小计</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.recharge)}`}>
{money(balanceSubtotal.recharge)}
</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.prepaid)}`}>{money(balanceSubtotal.prepaid)}</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.spot)}`}>{money(balanceSubtotal.spot)}</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.balance, true)}`}>
{money(balanceSubtotal.balance)}
</td>
<td />
</tr>
</>
)}
</tbody>
</table>
</div>
<MoreToggle expanded={expandBal} total={balanceRows.length} onToggle={() => setExpandBal((v) => !v)} />
</section>
<section className={`sd-panel sd-mobile-detail-panel sd-external-receipts ${mobileDetailTab === 'cash' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<div className="sd-panel__head-row sd-external-receipt-head">
<h2 className="sd-panel__title">外部客户充值/现结进账</h2>
<span className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? `合计 ¥${money(receiptTotal)} · ${cashLines.length} 条` : '金额暂不可用'}</span>
</div>
<p className="sd-panel__meta">当前租户全部外部客户,不按站点归属。现结自动汇总已包含在加氢业务中,不重复计入单站现结或收益;充值也不等于利润。</p>
{!cashLines.length ? <p className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? '查询区间暂无客户进账记录' : '客户进账数据暂不可用:接口未返回新数据,请刷新或检查服务版本。'}</p> : null}
<div className="sd-mobile-record-list sd-mobile-cash-list">
{cashVisible.map((line) => (
<article key={line.id} className="sd-mobile-record">
<div className="sd-mobile-record__lead"><strong>{line.customerName}</strong><span>{line.bizDate}</span></div>
<div className="sd-mobile-record__value"><strong className={businessValueClass(line.amount)}>¥{money(line.amount)}</strong><span className="sd-mobile-pay-tag">{line.payLabel}</span></div>
<div className="sd-mobile-record__meta">{line.sourceLabel} · {line.sourceRecordCount} 条源记录{line.updatedAt ? ` · 更新 ${line.updatedAt}` : ''}</div>
</article>
))}
</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>来源 / 源记录数</th>
<th className="is-num">金额(元)</th>
</tr>
</thead>
<tbody>
{cashLines.length === 0 ? (
<tr>
<td colSpan={5} className="ehb-empty-cell">
{liveBoard?.selected?.externalReceipts ? '本窗暂无进账明细' : '客户进账数据暂不可用'}
</td>
</tr>
) : (
cashVisible.map((l) => (
<tr key={l.id}>
<td className="is-mono">{l.bizDate}</td>
<td title={l.customerName}>{l.customerName}</td>
<td>{l.payLabel}</td>
<td>{l.sourceLabel} / {l.sourceRecordCount}</td>
<td className={`is-num ${businessValueClass(l.amount)}`}>{money(l.amount)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<MoreToggle expanded={expandCash} total={cashLines.length} onToggle={() => setExpandCash((v) => !v)} />
</section>
</div>
</div>
{orderDate ? <PrototypeDrillModal kind="records"
label={`${stationName} · ${orderDate} 加氢订单`}
query={{ year: Number(orderDate.slice(0, 4)), startDate: orderDate, endDate: orderDate,
date: orderDate, stationId, vehicleScope: 'all', verifyScope: 'all' }}
onClose={() => setOrderDate(null)} /> : null}
</div>
);
};
@@ -0,0 +1,104 @@
import React, { useMemo, useState } from 'react';
import './station-mobile-lists.css';
import {
customerMonthTotal,
customerMetricValue,
formatKg,
formatMoney,
mobileCustomerRows,
mobileDailyRows,
monthLabel,
type CustomerMetric,
type StationDailyVolumeRow,
type StationMonthlyCustomer,
} from './station-mobile-list-model';
export type { CustomerMetric, StationDailyVolumeRow, StationMonthlyCustomer } from './station-mobile-list-model';
export interface MobileDailyListProps {
/** The selected near-seven-day rows; `allRows` remains the source for actual prior-day comparison. */
rows: StationDailyVolumeRow[];
allRows: StationDailyVolumeRow[];
loading?: boolean;
error?: string | null;
onOpenOrders: (date: string) => void;
}
export function MobileDailyList({ rows, allRows, loading = false, error = null, onOpenOrders }: MobileDailyListProps) {
const dailyRows = useMemo(() => mobileDailyRows(rows, allRows), [rows, allRows]);
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">日报正在加载…</section>;
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">日报加载失败:{error}</section>;
if (!dailyRows.length) return <section className="sd-mobile-list-pilot sd-mobile-list__state">暂无日报数据</section>;
return <section className="sd-mobile-list-pilot" aria-label="近七日日报">
<p className="sd-mobile-list__hint">最新日期在前 · 点日期展开车次、单价和订单</p>
<div className="sd-mobile-list__head sd-mobile-daily__head"><span>日期</span><span>加氢量</span><span>金额</span></div>
<div className="sd-mobile-list__rows">
{dailyRows.map((row) => {
const hasRecord = row.vehicleCount > 0;
const delta = row.previousKg == null ? '无前日数据' : `${row.quantityKg - row.previousKg >= 0 ? '+' : ''}${formatKg(row.quantityKg - row.previousKg)} kg`;
return <details className="sd-mobile-list__detail" key={row.date}>
<summary className="sd-mobile-daily__row">
<span><strong>{row.date.slice(5)}</strong><small>{row.date.slice(0, 4)}</small></span>
<span className={hasRecord ? '' : 'is-zero'}><strong>{formatKg(row.quantityKg)}</strong><small>kg</small></span>
<span className={hasRecord ? '' : 'is-zero'}><strong>¥{formatMoney(row.amountYuan)}</strong>{!hasRecord && <small>无记录</small>}</span>
</summary>
<div className="sd-mobile-list__expanded">
<span>车次:{row.vehicleCount} 次</span><span>单价:¥{formatMoney(row.unitPrice)}/kg</span><span>较昨日:{delta}</span>
<button type="button" onClick={() => onOpenOrders(row.date)}>查看当天订单</button>
</div>
</details>;
})}
</div>
</section>;
}
export interface MobileCustomerMonthListProps {
/** Continuous YYYY-MM values, including months with no business records. */
months: string[];
volumeCustomers: StationMonthlyCustomer[];
feeCustomers: StationMonthlyCustomer[];
month: string;
onMonthChange: (month: string) => void;
metric: CustomerMetric;
onMetricChange: (metric: CustomerMetric) => void;
loading?: boolean;
error?: string | null;
}
export function MobileCustomerMonthList({ months, volumeCustomers, feeCustomers, month, onMonthChange, metric, onMetricChange, loading = false, error = null }: MobileCustomerMonthListProps) {
const [search, setSearch] = useState('');
const customers = metric === 'volume' ? volumeCustomers : feeCustomers;
const sortedCustomers = useMemo(() => mobileCustomerRows(customers, month, search), [customers, month, search]);
const total = useMemo(() => customerMonthTotal(customers, month), [customers, month]);
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">客户月度数据正在加载…</section>;
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">客户月度加载失败:{error}</section>;
return <section className="sd-mobile-list-pilot" aria-label="客户月度排行">
<div className="sd-mobile-customer__controls">
<select aria-label="选择月份" value={month} onChange={(event) => onMonthChange(event.target.value)}>
{months.map((item) => <option value={item} key={item}>{monthLabel(item)}</option>)}
</select>
<div className="sd-mobile-customer__metric" aria-label="选择指标">
<button type="button" aria-pressed={metric === 'volume'} className={metric === 'volume' ? 'is-active' : ''} onClick={() => onMetricChange('volume')}>数量</button>
<button type="button" aria-pressed={metric === 'fee'} className={metric === 'fee' ? 'is-active' : ''} onClick={() => onMetricChange('fee')}>金额</button>
</div>
<strong>{monthLabel(month)}合计:{metric === 'volume' ? `${formatKg(total)} kg` : `¥${formatMoney(total)}`}</strong>
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索全部客户" aria-label="搜索全部客户" />
</div>
<p className="sd-mobile-list__hint">点客户展开近 12 个月 · 合计不随搜索缩减</p>
{!sortedCustomers.length ? <p className="sd-mobile-list__state">{search ? '未找到匹配客户' : '该月暂无客户数据'}</p> : <div className="sd-mobile-list__rows">
{sortedCustomers.map((customer) => <details className="sd-mobile-list__detail sd-mobile-customer__detail" key={customer.customerName}>
<summary><span>{customer.customerName}</span><strong className={customerMetricValue(customer, month) === 0 ? 'is-zero' : ''}>{metric === 'volume' ? `${formatKg(customerMetricValue(customer, month))} kg` : `¥${formatMoney(customerMetricValue(customer, month))}`}</strong></summary>
<div className="sd-mobile-customer__trend" aria-label={`${customer.customerName}十二个月趋势`}>
{months.map((item) => {
const value = customerMetricValue(customer, item);
const max = Math.max(...months.map((key) => customerMetricValue(customer, key)), 0);
const width = max > 0 ? `${(value / max) * 100}%` : '0%';
return <div key={item}><span>{item}</span><i aria-hidden style={{ width }} className={value === 0 ? 'is-zero' : ''} /><strong>{metric === 'volume' ? `${formatKg(value)} kg` : `¥${formatMoney(value)}`}</strong></div>;
})}
</div>
</details>)}
</div>}
</section>;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { customerMonthTotal, formatMoney, mobileCustomerRows, mobileDailyRows } from './station-mobile-list-model';
test('日列表倒序且从全量查询读取实际前日值', () => {
const rows = [{ date: '2026-08-11', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 }];
const result = mobileDailyRows(rows, [...rows, { date: '2026-08-10', quantityKg: 7, amountYuan: 70, vehicleCount: 1, unitPrice: 10 }]);
assert.equal(result[0].previousKg, 7);
});
test('客户按当前月指标排序,搜索覆盖全部客户且零值保持零', () => {
const customers = [
{ customerName: '甲运输', months: { '2026-08': 0 } },
{ customerName: '乙物流', months: { '2026-08': 12 } },
];
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '').map((item) => item.customerName), ['乙物流', '甲运输']);
assert.equal(customerMonthTotal(customers, '2026-08'), 12);
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '甲').map((item) => item.customerName), ['甲运输']);
});
test('日报区分真实前日零和缺失前日,倒序展示不改变源数组', () => {
const rows = [
{ date: '2026-03-01', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 },
{ date: '2026-03-02', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 },
];
const result = mobileDailyRows(rows, [...rows, { date: '2026-02-28', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 }]);
assert.deepEqual(result.map(row => row.date), ['2026-03-02', '2026-03-01']);
assert.equal(result[1].previousKg, 0);
assert.equal(mobileDailyRows(rows, rows)[1].previousKg, null);
assert.equal(rows[0].date, '2026-03-01');
});
test('货币统一保留两位小数,包括真实零', () => {
assert.equal(formatMoney(0), '0.00');
assert.equal(formatMoney(1234.5), '1,234.50');
});
@@ -0,0 +1,63 @@
/** Mobile list derivations are deliberately data-only so the view never invents zeroes. */
export interface StationDailyVolumeRow {
date: string;
quantityKg: number;
amountYuan: number;
vehicleCount: number;
unitPrice: number;
}
export interface StationMonthlyCustomer {
customerName: string;
months: Record<string, number>;
}
export type CustomerMetric = 'volume' | 'fee';
export function formatKg(value: number): string {
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 });
}
export function formatMoney(value: number): string {
return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Latest first; allRows supplies the true previous-day quantity even outside the seven-day view. */
export function mobileDailyRows(rows: StationDailyVolumeRow[], allRows: StationDailyVolumeRow[]) {
const quantitiesByDate = new Map(allRows.map((row) => [row.date, row.quantityKg]));
return [...rows]
.sort((left, right) => right.date.localeCompare(left.date))
.map((row) => {
const previous = new Date(`${row.date}T00:00:00Z`);
previous.setUTCDate(previous.getUTCDate() - 1);
const previousDate = previous.toISOString().slice(0, 10);
const previousKg = quantitiesByDate.get(previousDate);
return { ...row, previousKg: Number.isFinite(previousKg) ? previousKg : null };
});
}
export function customerMetricValue(customer: StationMonthlyCustomer, month: string): number {
const value = customer.months[month];
return Number.isFinite(value) ? value : 0;
}
export function mobileCustomerRows(
customers: StationMonthlyCustomer[],
month: string,
search: string,
): StationMonthlyCustomer[] {
const keyword = search.trim().toLocaleLowerCase('zh-CN');
return customers
.filter((customer) => !keyword || customer.customerName.toLocaleLowerCase('zh-CN').includes(keyword))
.sort((left, right) => customerMetricValue(right, month) - customerMetricValue(left, month)
|| left.customerName.localeCompare(right.customerName, 'zh-CN'));
}
export function customerMonthTotal(customers: StationMonthlyCustomer[], month: string): number {
return customers.reduce((total, customer) => total + customerMetricValue(customer, month), 0);
}
export function monthLabel(month: string): string {
const matched = /^(\d{4})-(\d{2})$/.exec(month);
return matched ? `${matched[1]}年${Number(matched[2])}月` : month;
}
@@ -0,0 +1,37 @@
/* Pilot is intentionally isolated: the host chooses where to render it. */
.sd-mobile-list-pilot { display: none; }
.sd-mobile-list__hint { margin: 4px 0 8px; color: #64748b; font-size: 11px; line-height: 1.5; }
@media (max-width: 767px) {
.sd-mobile-list-pilot { display: block; box-sizing: border-box; width: 100%; color: #24344e; font-family: var(--sd-font, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif); }
.sd-mobile-list__head, .sd-mobile-daily__row { display: grid; grid-template-columns: .82fr .9fr 1.28fr; align-items: center; gap: 7px; }
.sd-mobile-list__head { padding: 0 2px 6px; color: #71819a; font-size: 10px; }
.sd-mobile-list__head span:not(:first-child), .sd-mobile-daily__row > span:not(:first-child) { text-align: right; }
.sd-mobile-list__detail { border-top: 1px solid #edf1f6; }
.sd-mobile-list__detail summary { list-style: none; cursor: pointer; min-height: 48px; }
.sd-mobile-list__detail summary::-webkit-details-marker { display: none; }
.sd-mobile-daily__row > span { display: flex; min-width: 0; align-items: baseline; justify-content: flex-end; gap: 3px; }
.sd-mobile-daily__row > span:first-child { justify-content: flex-start; }
.sd-mobile-daily__row strong { color: #1e3556; font-size: 13px; font-variant-numeric: tabular-nums; }
.sd-mobile-daily__row small { color: #75859c; font-size: 9px; }
.sd-mobile-list-pilot .is-zero { color: #94a3b8; }
.sd-mobile-list__expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 0 0 10px; color: #64748b; font-size: 11px; }
.sd-mobile-list__expanded span:last-of-type { grid-column: 1 / -1; }
.sd-mobile-list__expanded button { grid-column: 1 / -1; min-height: 44px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; color: #2563eb; font: inherit; font-weight: 700; }
.sd-mobile-list__state { margin: 0; color: #64748b; font-size: 12px; text-align: center; }
.sd-mobile-list__state.is-error { color: #b42318; }
.sd-mobile-customer__controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.sd-mobile-customer__controls select, .sd-mobile-customer__controls input, .sd-mobile-customer__metric button { min-width: 0; min-height: 44px; border: 1px solid #d8e2ef; border-radius: 8px; background: #fff; color: #34445f; font: inherit; font-size: 12px; }
.sd-mobile-customer__metric { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.sd-mobile-customer__metric button.is-active { border-color: #2563eb; background: #eff6ff; color: #2563eb; font-weight: 700; }
.sd-mobile-customer__controls > strong, .sd-mobile-customer__controls input { grid-column: 1 / -1; }
.sd-mobile-customer__controls > strong { color: #1e3556; font-size: 12px; }
.sd-mobile-customer__controls input { box-sizing: border-box; padding: 0 10px; }
.sd-mobile-customer__detail summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; }
.sd-mobile-customer__detail summary span { display: -webkit-box; overflow: hidden; color: #263650; font-size: 12px; font-weight: 650; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.sd-mobile-customer__detail summary strong { color: #1e3556; font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }
.sd-mobile-customer__trend { display: grid; gap: 4px; padding: 0 0 10px; }
.sd-mobile-customer__trend > div { display: grid; grid-template-columns: 50px minmax(0, 1fr) 82px; align-items: center; gap: 6px; color: #71819a; font-size: 10px; }
.sd-mobile-customer__trend i { display: block; height: 5px; min-width: 0; border-radius: 99px; background: #60a5fa; }
.sd-mobile-customer__trend i.is-zero { width: 0 !important; }
.sd-mobile-customer__trend strong { color: #51627b; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; }
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { customerMonthLabel, customerMonthRange, dateRangeLabel, inclusiveDayCount } from './station-month-range';
test('客户月度范围保持截至结束日的连续 12 个月,并包含无数据的 5 月', () => {
assert.deepEqual(customerMonthRange('2026-08-31'), [
'2025-09', '2025-10', '2025-11', '2025-12',
'2026-01', '2026-02', '2026-03', '2026-04',
'2026-05', '2026-06', '2026-07', '2026-08',
]);
});
test('完整年月键不会随截止月份漂移', () => {
assert.deepEqual(customerMonthRange('2027-03-01', 3), ['2027-01', '2027-02', '2027-03']);
assert.equal(customerMonthLabel('2026-05'), '2026年5月');
});
test('日期区间文案反映闭区间实际天数', () => {
assert.equal(inclusiveDayCount('2026-08-01', '2026-08-15'), 15);
assert.equal(dateRangeLabel('2026-08-01', '2026-08-15'), '2026-08-01 至 2026-08-15(共 15 天)');
assert.equal(inclusiveDayCount('2026-08-15', '2026-08-01'), 0);
});
@@ -0,0 +1,37 @@
/**
* 客户月度接口固定返回“截至查询结束日的近 12 个月”。
* 键保留完整 YYYY-MM,避免跨年时把不同年份的同月合并。
*/
export function customerMonthRange(asOfYmd: string, count = 12): string[] {
const matched = /^(\d{4})-(\d{2})-\d{2}$/.exec(asOfYmd);
if (!matched || count < 1) return [];
const endYear = Number(matched[1]);
const endMonth = Number(matched[2]);
if (endMonth < 1 || endMonth > 12) return [];
const months: string[] = [];
for (let offset = count - 1; offset >= 0; offset -= 1) {
const date = new Date(Date.UTC(endYear, endMonth - 1 - offset, 1));
months.push(`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`);
}
return months;
}
export function customerMonthLabel(monthKey: string): string {
const matched = /^(\d{4})-(\d{2})$/.exec(monthKey);
return matched ? `${matched[1]}年${Number(matched[2])}月` : monthKey;
}
/** 闭区间日数;用于让卡片副标题与实际查询口径保持一致。 */
export function inclusiveDayCount(start: string, end: string): number {
const startTime = Date.parse(`${start}T00:00:00Z`);
const endTime = Date.parse(`${end}T00:00:00Z`);
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return 0;
return Math.floor((endTime - startTime) / 86_400_000) + 1;
}
export function dateRangeLabel(start: string, end: string): string {
const days = inclusiveDayCount(start, end);
return days > 0 ? `${start} 至 ${end}(共 ${days} 天)` : `${start} 至 ${end}`;
}
File diff suppressed because it is too large Load Diff