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:
@@ -0,0 +1,182 @@
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeQuery,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillReadOptions,
|
||||
H2BiFullDrillOptions,
|
||||
H2BiFullDrillResponse,
|
||||
H2BiDrillQuery,
|
||||
H2BiDrillResponse,
|
||||
H2BiMetaResponse,
|
||||
H2BiOverviewResponse,
|
||||
H2BiQuery,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy/h2/v2';
|
||||
|
||||
function queryString(query: Record<string, unknown>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function request<T>(path: string, query: object = {}, options?: RequestInit) {
|
||||
const qs = queryString(query as Record<string, unknown>);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
|
||||
}
|
||||
|
||||
export function fetchH2BiMeta() {
|
||||
return request<H2BiMetaResponse>('meta');
|
||||
}
|
||||
|
||||
export function fetchH2BiOverview(query: H2BiQuery) {
|
||||
return request<H2BiOverviewResponse>('overview', query);
|
||||
}
|
||||
|
||||
export function fetchH2BiDaily(query: H2BiQuery) {
|
||||
return request<H2BiDailyResponse>('daily', query).catch(async () => {
|
||||
// The date-group drill is backed by the same read-only ledger and has a
|
||||
// simpler query plan. Keep the date view usable when the aggregate daily
|
||||
// endpoint times out/fails, without substituting mock data.
|
||||
const drill = await request<H2BiDrillResponse>('drill', {
|
||||
...query,
|
||||
groupBy: 'date',
|
||||
pageSize: 400,
|
||||
});
|
||||
const startDate = query.startDate ?? `${query.year}-01-01`;
|
||||
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
|
||||
const days = [] as H2BiDailyResponse['trend'];
|
||||
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
||||
const date = cursor.toISOString().slice(0, 10);
|
||||
const row = byDate.get(date);
|
||||
days.push({
|
||||
date,
|
||||
kg: Number(row?.kg ?? 0),
|
||||
lingniuKg: Number(row?.lingniuKg ?? 0),
|
||||
externalKg: Number(row?.externalKg ?? 0),
|
||||
cost: Number(row?.cost ?? 0),
|
||||
recordCount: Number(row?.recordCount ?? 0),
|
||||
});
|
||||
}
|
||||
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
|
||||
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
|
||||
return {
|
||||
range: { startDate, endDate },
|
||||
watermark: { ledgerAt: null, paymentAt: null },
|
||||
filters: query,
|
||||
kpis: {
|
||||
totalKg,
|
||||
totalCost,
|
||||
averageDailyKg: totalKg / Math.max(1, days.length),
|
||||
stationCount: Number(drill.summary.stationCount ?? 0),
|
||||
activeDays: days.filter((row) => row.kg > 0).length,
|
||||
},
|
||||
trend: days,
|
||||
days: [...days].reverse(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||
}
|
||||
|
||||
export async function fetchH2BiDrill(
|
||||
query: H2BiDrillQuery,
|
||||
options?: H2BiDrillReadOptions,
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
|
||||
const response = await request<H2BiDrillResponse>(
|
||||
'drill',
|
||||
{ ...query, page, pageSize },
|
||||
options,
|
||||
);
|
||||
const itemCount = query.groupBy === 'record'
|
||||
? response.records.length
|
||||
: response.groups.length;
|
||||
// Older servers only set hasMore for record pages. A full page is therefore
|
||||
// also treated as potentially incomplete; the final empty/short page proves
|
||||
// completion without silently dropping grouped rows.
|
||||
return {
|
||||
...response,
|
||||
page: {
|
||||
...response.page,
|
||||
page,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
|
||||
},
|
||||
} satisfies H2BiDrillResponse;
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('已取消全量读取');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every page only after a caller explicitly asks for a complete result.
|
||||
* It never returns a partial collection: server errors, cancellation, and the
|
||||
* page guard all reject before an export can be created.
|
||||
*/
|
||||
export async function fetchAllH2BiDrill(
|
||||
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
|
||||
options: H2BiFullDrillOptions = {},
|
||||
): Promise<H2BiFullDrillResponse> {
|
||||
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
|
||||
const maxPages = Math.max(1, options.maxPages ?? 250);
|
||||
const maxRows = Math.max(1, options.maxRows ?? 50_000);
|
||||
const groups: H2BiDrillResponse['groups'] = [];
|
||||
const records: H2BiDrillResponse['records'] = [];
|
||||
let first: H2BiDrillResponse | null = null;
|
||||
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
const result = await fetchH2BiDrill(
|
||||
{ ...query, page, pageSize },
|
||||
{ signal: options.signal },
|
||||
);
|
||||
if (!first) first = result;
|
||||
groups.push(...result.groups);
|
||||
records.push(...result.records);
|
||||
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
|
||||
if (itemCount > maxRows)
|
||||
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
if (!result.page.hasMore) {
|
||||
return {
|
||||
...first,
|
||||
groups,
|
||||
records,
|
||||
page: {
|
||||
...result.page,
|
||||
page: 1,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: false,
|
||||
pagesRead: page,
|
||||
complete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
}
|
||||
|
||||
/** Reusable contract for station-detail consumers that need every raw record. */
|
||||
export function fetchAllH2BiDrillRecords(
|
||||
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
|
||||
options?: H2BiFullDrillOptions,
|
||||
) {
|
||||
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export function downloadExcelAoa(
|
||||
rows: Array<Array<string | number | boolean | null | undefined>>,
|
||||
fileName: string,
|
||||
sheetName: string,
|
||||
) {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheet = XLSX.utils.aoa_to_sheet(rows);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, sheetName.slice(0, 31));
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { devMockResponse } from "./dev-mock-api";
|
||||
|
||||
test("开发 mock 覆盖健康检查与氢能 v2 只读接口", () => {
|
||||
for (const path of [
|
||||
"/api/health",
|
||||
"/api/energy/h2/v2/meta",
|
||||
"/api/energy/h2/v2/overview",
|
||||
"/api/energy/h2/v2/daily",
|
||||
"/api/energy/h2/v2/daily-tree",
|
||||
"/api/energy/h2/v2/drill",
|
||||
]) assert.ok(devMockResponse(path), path);
|
||||
|
||||
assert.equal(devMockResponse("/api/unknown"), undefined);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const station = {
|
||||
id: "101",
|
||||
name: "本地验收加氢站",
|
||||
province: "浙江省",
|
||||
city: "嘉兴市",
|
||||
kg: 12345.67,
|
||||
lingniuKg: 10000,
|
||||
externalKg: 2345.67,
|
||||
cost: 23456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
recordCount: 42,
|
||||
customerCount: 1,
|
||||
share: 100,
|
||||
};
|
||||
|
||||
const customer = {
|
||||
id: 1,
|
||||
name: "本地验收客户",
|
||||
kg: 12345.67,
|
||||
customerBearingKg: 10000,
|
||||
companyBearingKg: 2000,
|
||||
otherBearingKg: 345.67,
|
||||
bearer: "both",
|
||||
cost: 23456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
recordCount: 42,
|
||||
};
|
||||
|
||||
const range = { startDate: "2026-01-01", endDate: "2026-08-31" };
|
||||
const watermark = { ledgerAt: "2026-08-31 16:00:00", paymentAt: null };
|
||||
const kpis = {
|
||||
totalKg: 12345.67,
|
||||
totalCost: 23456.78,
|
||||
customerBearingKg: 10000,
|
||||
companyBearingKg: 2000,
|
||||
otherBearingKg: 345.67,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
totalRevenue: 34567.89,
|
||||
customerGrossProfit: 16567.89,
|
||||
monthKg: 3456.78,
|
||||
monthCost: 6789.01,
|
||||
todayKg: 123.45,
|
||||
todayCost: 234.56,
|
||||
monthShareOfRange: 28,
|
||||
todayShareOfMonth: 3.57,
|
||||
recordCount: 42,
|
||||
stationCount: 1,
|
||||
};
|
||||
|
||||
const overview = {
|
||||
range,
|
||||
watermark,
|
||||
filters: {},
|
||||
kpis,
|
||||
monthly: [{
|
||||
month: "2026-08",
|
||||
totalKg: 12345.67,
|
||||
lingniuKg: 10000,
|
||||
externalKg: 2345.67,
|
||||
cost: 23456.78,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerGrossProfit: 16567.89,
|
||||
}],
|
||||
topStations: [station],
|
||||
regions: [{ region: "嘉兴市", kg: 12345.67, share: 100 }],
|
||||
stations: [station],
|
||||
customers: [customer],
|
||||
};
|
||||
|
||||
const dailyPoint = {
|
||||
date: "2026-08-31",
|
||||
kg: 123.45,
|
||||
lingniuKg: 100,
|
||||
externalKg: 23.45,
|
||||
cost: 234.56,
|
||||
recordCount: 2,
|
||||
stationCount: 1,
|
||||
};
|
||||
|
||||
export function devMockResponse(pathname: string) {
|
||||
if (pathname === "/api/health") return { status: "ok", source: "dev-mock" };
|
||||
if (pathname.endsWith("/meta")) return {
|
||||
years: [{ value: 2026, startDate: range.startDate, endDate: range.endDate }],
|
||||
stations: [{ id: station.id, name: station.name }],
|
||||
watermark,
|
||||
};
|
||||
if (pathname.endsWith("/overview")) return overview;
|
||||
if (pathname.endsWith("/daily-tree")) return {
|
||||
date: dailyPoint.date,
|
||||
stations: [{
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
kg: dailyPoint.kg,
|
||||
cost: dailyPoint.cost,
|
||||
recordCount: 2,
|
||||
customers: [{ id: customer.id, name: customer.name, kg: dailyPoint.kg, cost: dailyPoint.cost, recordCount: 2 }],
|
||||
}],
|
||||
};
|
||||
if (pathname.endsWith("/daily")) return {
|
||||
range,
|
||||
watermark,
|
||||
filters: {},
|
||||
kpis: { totalKg: 12345.67, totalCost: 23456.78, averageDailyKg: 823.04, stationCount: 1, activeDays: 15 },
|
||||
trend: [dailyPoint],
|
||||
days: [dailyPoint],
|
||||
};
|
||||
if (pathname.endsWith("/drill")) return {
|
||||
groupBy: "record",
|
||||
amountScope: "all",
|
||||
filters: {},
|
||||
summary: { recordCount: 1, kg: 123.45, cost: 4000, revenue: 4320 },
|
||||
groups: [{ ...station, stationCount: 1, customerCount: 1 }],
|
||||
records: [{
|
||||
id: 1,
|
||||
time: "2026-08-31 12:00:00",
|
||||
orderNo: "DEV-001",
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
plateNo: "浙FDEV01",
|
||||
source: "dev-mock",
|
||||
verifyStatus: "verified",
|
||||
vehicleId: 1,
|
||||
kg: 123.45,
|
||||
unitPrice: 35,
|
||||
cost: 4000,
|
||||
revenue: 4320,
|
||||
}],
|
||||
page: { page: 1, pageSize: 100, hasMore: false },
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, body: unknown) {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export function energyDevMockApi(): Plugin {
|
||||
return {
|
||||
name: "energy-dev-mock-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request: IncomingMessage, response: ServerResponse, next) => {
|
||||
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/favicon.ico") {
|
||||
response.statusCode = 204;
|
||||
return response.end();
|
||||
}
|
||||
const body = devMockResponse(pathname);
|
||||
if (body === undefined) return next();
|
||||
sendJson(response, body);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fetchAllH2BiDrill, fetchAllH2BiDrillRecords } from "./api";
|
||||
import type { H2BiDrillResponse } from "./types";
|
||||
|
||||
const query = {
|
||||
year: 2026,
|
||||
vehicleScope: "all" as const,
|
||||
verifyScope: "all" as const,
|
||||
};
|
||||
|
||||
function response(overrides: Partial<H2BiDrillResponse> = {}): H2BiDrillResponse {
|
||||
return {
|
||||
groupBy: "station",
|
||||
amountScope: "all",
|
||||
filters: {},
|
||||
summary: {},
|
||||
groups: [],
|
||||
records: [],
|
||||
page: { page: 1, pageSize: 2, hasMore: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function withFetch(
|
||||
handler: (url: URL) => H2BiDrillResponse | Promise<H2BiDrillResponse>,
|
||||
run: () => Promise<void>,
|
||||
) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input), "http://ln-bi.local");
|
||||
return new Response(JSON.stringify(await handler(url)), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("完整分组读取跨页,并以短页而非旧服务 hasMore 字段确认结束", async () => {
|
||||
const pages: number[] = [];
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
pages.push(page);
|
||||
return response({
|
||||
groups: page === 1
|
||||
? ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }))
|
||||
: [{ id: "3", name: "丙", province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 });
|
||||
assert.deepEqual(result.groups.map((row) => row.name), ["甲", "乙", "丙"]);
|
||||
assert.equal(result.page.complete, true);
|
||||
assert.equal(result.page.pagesRead, 2);
|
||||
});
|
||||
assert.deepEqual(pages, [1, 2]);
|
||||
});
|
||||
|
||||
test("完整记录读取跨页后保留全部订单", async () => {
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
return response({
|
||||
groupBy: "record",
|
||||
records: page === 1
|
||||
? [{ id: "a" }, { id: "b" }]
|
||||
: [{ id: "c" }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrillRecords(query, { pageSize: 2 });
|
||||
assert.deepEqual(result.records.map((row) => row.id), ["a", "b", "c"]);
|
||||
assert.equal(result.page.hasMore, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("全量读取在中途请求失败时拒绝,不返回部分结果", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const page = new URL(String(input), "http://ln-bi.local").searchParams.get("page");
|
||||
if (page === "2") return new Response("failed", { status: 502, statusText: "Bad Gateway" });
|
||||
return new Response(JSON.stringify(response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
})), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 }),
|
||||
/API error: 502/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("空结果是完整结果而不是加载失败", async () => {
|
||||
await withFetch(() => response(), async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" });
|
||||
assert.deepEqual(result.groups, []);
|
||||
assert.equal(result.page.pagesRead, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("取消的全量读取不会发起请求或生成部分数据", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
|
||||
test("全量读取在第一页完成后也会响应取消,不会请求下一页", async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
await withFetch(() => {
|
||||
calls += 1;
|
||||
controller.abort();
|
||||
return response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
});
|
||||
}, async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal, pageSize: 2 }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("maxRows 和 maxPages 均拒绝不完整的全量读取", async () => {
|
||||
await withFetch(() => response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
}), async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxRows: 1 }),
|
||||
/超过 1 条保护上限/,
|
||||
);
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxPages: 1 }),
|
||||
/超过 1 页保护上限/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
|
||||
export function DailyTreeButton({ open, label, children, onClick }: {
|
||||
open: boolean; label: string; children: ReactNode; onClick: () => void;
|
||||
}) {
|
||||
const Icon = open ? ChevronDown : ChevronRight;
|
||||
return <button type="button" className="ehb-daily-disclosure" aria-expanded={open}
|
||||
aria-label={`${open ? "收起" : "展开"}${label}`} onClick={onClick}>
|
||||
<Icon size={15} aria-hidden="true" /><span>{children}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function DailyBranchState({ columns, error, empty, onRetry }: {
|
||||
columns: number; error?: string; empty?: boolean; onRetry: () => void;
|
||||
}) {
|
||||
return <tr className="ehb-daily-branch-state"><td colSpan={columns}>
|
||||
<div role={error ? "alert" : "status"}>
|
||||
{error || (empty ? "当前范围暂无明细" : "正在加载明细…")}
|
||||
{error ? <button type="button" onClick={onRetry}><RefreshCw size={14} aria-hidden="true" />重试</button> : null}
|
||||
</div>
|
||||
</td></tr>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
|
||||
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
|
||||
import type { H2BiDailyResponse } from "../types";
|
||||
|
||||
test("每日环比区分真实零值、缺失值和非数值", () => {
|
||||
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
|
||||
assert.equal(formatDailyChange(0), "环比 0.00%");
|
||||
assert.equal(formatDailyChange(-12.5), "环比 -12.50%");
|
||||
assert.equal(formatDailyChange(12.5), "环比 +12.50%");
|
||||
});
|
||||
|
||||
test("日期汇总导出包含全部日期,不依赖已展开明细并保留真零", () => {
|
||||
const daily = {
|
||||
kpis: { stationCount: 2, totalKg: 15.25, totalCost: 450 },
|
||||
days: [
|
||||
{ date: "2026-09-03", stationCount: 2, kg: 15.25, cost: 450 },
|
||||
{ date: "2026-09-02", stationCount: 0, kg: 0, cost: 0 },
|
||||
],
|
||||
} as H2BiDailyResponse;
|
||||
const rows = dailySummaryRows(daily);
|
||||
assert.equal(rows.length, 4);
|
||||
assert.deepEqual(rows[1], ["区间合计", 2, 15.25, 450]);
|
||||
assert.deepEqual(rows[3], ["2026-09-02", 0, 0, 0]);
|
||||
});
|
||||
|
||||
test("层级按钮包含键盘原生语义与明确展开状态", () => {
|
||||
for (const open of [false, true]) {
|
||||
const html = renderToStaticMarkup(createElement(DailyTreeButton, {
|
||||
open, label: "测试站客户明细", children: "测试站", onClick() {},
|
||||
}));
|
||||
assert.match(html, /<button type="button"/);
|
||||
assert.ok(html.includes(`aria-expanded="${open}"`));
|
||||
assert.ok(html.includes(`${open ? "收起" : "展开"}测试站客户明细`));
|
||||
}
|
||||
});
|
||||
|
||||
test("展开状态区分加载、空数据、失败及重试入口", () => {
|
||||
const render = (props: Partial<Parameters<typeof DailyBranchState>[0]>) => renderToStaticMarkup(createElement(DailyBranchState, {
|
||||
columns: 3, onRetry() {}, ...props,
|
||||
}));
|
||||
assert.match(render({}), /正在加载明细/);
|
||||
assert.match(render({ empty: true }), /当前范围暂无明细/);
|
||||
const error = render({ error: "站点明细加载失败,请重试" });
|
||||
assert.match(error, /role="alert"/);
|
||||
assert.match(error, /<button type="button"/);
|
||||
assert.match(error, /colSpan="3"/i);
|
||||
assert.doesNotMatch(error, /正在加载明细/);
|
||||
});
|
||||
@@ -0,0 +1,688 @@
|
||||
@import './drill-workspace.css';
|
||||
.ehb-drill-modal--unified .ehb-modal-body { padding: 18px; background: #f6f8fb; }
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs { display: flex; justify-content: flex-end; margin: 0 0 12px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0;
|
||||
padding: 0; overflow: hidden; border: 1px solid #dfe7f0; border-radius: 8px; background: #fff;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item { min-width: 0; padding: 12px 16px; border-right: 1px solid #dfe7f0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-val { color: #172238; font-family: var(--bi-font-mono); font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-row {
|
||||
gap: 8px; padding: 10px 12px; margin: 12px 0; border: 1px solid #dfe7f0;
|
||||
border-radius: 8px; background: #fff;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
|
||||
|
||||
/* Reserve space for pagination instead of clipping it below a fixed-height table. */
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 100px;
|
||||
}
|
||||
.ehb-drill-page-controls {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ehb-drill-page-controls .ehb-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 72px;
|
||||
width: 72px !important;
|
||||
min-height: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table th {
|
||||
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
|
||||
background: #f0f3f8; color: #52627a;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td {
|
||||
height: 48px; padding-block: 10px; border-bottom-color: #e5ebf2; color: #334155;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; }
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle {
|
||||
display: inline-grid; width: 24px; min-width: 24px; height: 24px; place-items: center;
|
||||
padding: 0; border: 0; border-radius: 5px; background: transparent; color: #2f6bff;
|
||||
font-weight: 800; cursor: pointer;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle:hover,
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle:focus-visible { background: #eaf1ff; outline: none; }
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link {
|
||||
margin-left: auto; padding: 0; border: 0; background: transparent; color: #2f6bff;
|
||||
font: inherit; font-size: 12px; font-weight: 700; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link:hover,
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link:focus-visible { color: #174ebc; text-decoration: underline; outline: none; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] {
|
||||
color: #334155; font-family: var(--bi-font-mono); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume { color: #2f6bff !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income { color: #2c8a78 !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-cost { color: #e28a24 !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-profit { color: #2f6bff !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-external { color: #7ea5ec !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-summary-volume,
|
||||
.ehb-drill-modal--unified .ehb-summary-profit { color: #2f6bff; }
|
||||
.ehb-drill-modal--unified .ehb-summary-income { color: #2c8a78; }
|
||||
.ehb-drill-modal--unified .ehb-summary-cost { color: #e28a24; }
|
||||
.ehb-drill-modal--unified .ehb-flat-drill-table tbody tr:nth-child(even) td { background: #fafbfd; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--date td { background: #fff; }
|
||||
.ehb-drill-modal--unified .ehb-day-change.is-up { color: #18a67a; font-weight: 700; }
|
||||
.ehb-drill-modal--unified .ehb-day-change.is-down { color: #e26464; font-weight: 700; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-cust { color: #b86606; border: 1px solid #f2d26c; background: #fffbea; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu { color: #2f6bff; border: 1px solid #bdd0fb; background: #eef4ff; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-other { color: #64748b; border: 1px solid #cbd5e1; background: #f8fafc; }
|
||||
.ehb-drill-modal--unified .ehb-tag--source-api,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-station,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-lingniu {
|
||||
border: 1px solid #cbd9e8; background: #eef4f9; color: #526b88;
|
||||
}
|
||||
.ehb-drill-local-error {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
}
|
||||
.ehb-drill-local-error strong { color: #b42318; font-size: 16px; }
|
||||
.ehb-drill-local-error span { max-width: 520px; line-height: 1.6; }
|
||||
.ehb-drill-loading {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
animation: ehb-drill-fade-in .18s ease-out both;
|
||||
}
|
||||
.ehb-drill-loading__progress {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 38%;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, #2f6bff 45%, #67b5ff, transparent);
|
||||
animation: ehb-drill-progress 1.15s ease-in-out infinite;
|
||||
}
|
||||
.ehb-drill-loading__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid #e7edf5;
|
||||
color: #1e293b;
|
||||
}
|
||||
.ehb-drill-loading__label > span:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.ehb-drill-loading__label strong { font-size: 14px; }
|
||||
.ehb-drill-loading__label small { color: #7b8aa0; font-size: 12px; }
|
||||
.ehb-drill-loading__spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
border: 2px solid #dbe7fb;
|
||||
border-top-color: #2f6bff;
|
||||
border-radius: 50%;
|
||||
animation: ehb-drill-spin .72s linear infinite;
|
||||
}
|
||||
.ehb-drill-loading__rows { padding: 4px 18px 18px; }
|
||||
.ehb-drill-loading__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 2.2fr) repeat(3, minmax(90px, 1fr));
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
min-width: 680px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.ehb-drill-loading__row i {
|
||||
display: block;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(100deg, #edf2f8 20%, #f8fafc 42%, #e7eef8 64%);
|
||||
background-size: 220% 100%;
|
||||
animation: ehb-drill-shimmer 1.25s ease-in-out infinite;
|
||||
}
|
||||
.ehb-drill-loading__row i:nth-child(2) { width: 72%; }
|
||||
.ehb-drill-loading__row i:nth-child(3) { width: 58%; }
|
||||
.ehb-drill-loading__row i:nth-child(4) { width: 82%; }
|
||||
.ehb-modal-table-wrap.is-ready > .ehb-modal-table {
|
||||
animation: ehb-drill-content-in .24s ease-out both;
|
||||
}
|
||||
@keyframes ehb-drill-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes ehb-drill-progress {
|
||||
0% { transform: translateX(-110%); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { transform: translateX(370%); opacity: 0; }
|
||||
}
|
||||
@keyframes ehb-drill-shimmer { to { background-position: -220% 0; } }
|
||||
@keyframes ehb-drill-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes ehb-drill-content-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ehb-drill-loading,
|
||||
.ehb-drill-loading__progress,
|
||||
.ehb-drill-loading__spinner,
|
||||
.ehb-drill-loading__row i,
|
||||
.ehb-modal-table-wrap.is-ready > .ehb-modal-table { animation: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; }
|
||||
}
|
||||
.ehb-real-drill-filter-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-head {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__title {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger {
|
||||
position: static;
|
||||
min-width: 76px;
|
||||
height: 32px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
|
||||
width: 235px;
|
||||
min-width: 235px;
|
||||
}
|
||||
|
||||
/* 宽表左右移动时首列保留完整的站点/客户名称、层级三角和同省标识。 */
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { overflow: auto !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table { min-width: max-content; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
background: #fff;
|
||||
box-shadow: 6px 0 10px -10px #0f172a;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table thead th:first-child {
|
||||
z-index: 5;
|
||||
background: #f0f3f8;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--station > td:first-child { background: #edf3fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td:first-child { background: #f5f7fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td:first-child { background: #fafbfd; }
|
||||
|
||||
.ehb-real-drill-filter-summary {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #2f6bff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden;
|
||||
color: #1e293b;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > svg {
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > svg.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-panel:not(.is-open) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-panel.is-open {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text {
|
||||
width: 100%;
|
||||
overflow: visible !important;
|
||||
white-space: normal !important;
|
||||
text-overflow: clip !important;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-search-input,
|
||||
.ehb-drill-modal--unified .ehb-modal-search-input input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏横向空间充足时,把摘要和筛选压成一行,优先留高度给下钻表格。 */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.ehb-drill-modal--unified .ehb-modal-head { padding-block: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-head__sub { display: inline; margin-left: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-body { padding: 6px 8px 8px; }
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs { margin-bottom: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
padding: 6px 10px;
|
||||
border-right: 1px solid #dfe7f0;
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-row { margin-bottom: 6px; padding: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text { display: none; }
|
||||
.ehb-drill-modal--unified .ehb-drill-loading { min-height: 150px; }
|
||||
}
|
||||
|
||||
/* “横屏查看”是页面布局模式:不依赖手机方向锁定。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 7px 8px 8px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar {
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-item {
|
||||
padding: 6px 9px;
|
||||
border-right: 1px solid #dfe7f0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-hint-text,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 下钻路径是导航,不是装饰:允许直接回到任一上级。 */
|
||||
.ehb-drill-breadcrumbs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 18px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid #dfe7f0;
|
||||
background: #fff;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.ehb-drill-breadcrumbs::-webkit-scrollbar { display: none; }
|
||||
.ehb-drill-breadcrumb { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
||||
.ehb-drill-breadcrumb i { color: #9aabc0; font-style: normal; }
|
||||
.ehb-drill-breadcrumb button {
|
||||
max-width: 210px;
|
||||
padding: 4px 7px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #3564a5;
|
||||
font: 650 12px/1.3 var(--bi-font);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ehb-drill-breadcrumb button:not(:disabled):hover { background: #edf4ff; color: #1f5fe0; }
|
||||
.ehb-drill-breadcrumb button[aria-current="page"] {
|
||||
background: #edf3fc;
|
||||
color: #24344e;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-head { flex: 0 0 auto !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-close-btn { display: none !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-back-btn {
|
||||
min-width: 108px !important;
|
||||
min-height: 42px !important;
|
||||
justify-content: center;
|
||||
border-color: rgb(255 255 255 / 28%) !important;
|
||||
background: rgb(255 255 255 / 10%) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
.ehb-drill-breadcrumbs {
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 3px 10px rgb(32 55 89 / 6%);
|
||||
}
|
||||
.ehb-drill-breadcrumb button { max-width: 150px; min-height: 30px; font-size: 11px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
flex: 1 1 auto !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link { font-size: 10px !important; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row { min-height: 58px; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row > td:first-child { min-width: 220px !important; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-title { display: flex; align-items: center; gap: 4px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 0 !important; background: #f8fbff; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content { padding: 8px 14px 10px 38px; border-bottom: 1px solid #dbe7f5; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content ul { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 5px 10px; margin: 0; padding: 0; list-style: none; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 7px; color: #31578c; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all { margin: 8px 7px 0; padding: 3px 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all:hover { text-decoration: underline; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content small { color: #64748b; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) and (orientation: landscape) {
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child { width: 300px; min-width: 300px; }
|
||||
}
|
||||
|
||||
/* 竖屏宽表保持原方向;表格容器负责横向滚动,绝不旋转整个页面。 */
|
||||
@media (max-width: 767px) and (orientation: portrait) {
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
padding: 0 !important;
|
||||
overflow: auto !important;
|
||||
background: #f4f7fb !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table-wrap {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 240px !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table {
|
||||
min-width: 960px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td {
|
||||
min-width: 112px !important;
|
||||
height: 38px !important;
|
||||
min-height: 38px !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th:first-child,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td:first-child {
|
||||
width: 190px !important;
|
||||
min-width: 190px !important;
|
||||
max-width: 190px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback::after {
|
||||
right: 8px !important;
|
||||
bottom: 8px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback * {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 原生横屏与旋转兼容统一进入专注阅读状态。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-breadcrumbs,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-root-tabs,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-usage-guide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
min-height: 42px !important;
|
||||
height: 42px !important;
|
||||
padding: 4px 142px 4px 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__sub {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 专注阅读仍是可导航的下钻页面;必须能逐层返回,不能要求先退出宽表。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn {
|
||||
display: inline-flex !important;
|
||||
width: 44px !important;
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
padding: 2px 88px 2px 8px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn > span {
|
||||
display: none;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group {
|
||||
display: grid !important;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group > div {
|
||||
min-width: 0;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title {
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
width: 80px !important;
|
||||
min-width: 80px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* 让三角、名称和轻量下钻提示在同一行网格内分配空间;长名称仅在名称格内换行。 */
|
||||
.ehb-drill-modal--unified .ehb-tree-node-title {
|
||||
display: grid !important;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 24px;
|
||||
align-items: start;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle { width: 28px; min-width: 28px; height: 28px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name .ehb-tree-node-sub { margin-left: 4px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link {
|
||||
display: inline-grid !important;
|
||||
width: 24px !important;
|
||||
min-width: 24px !important;
|
||||
height: 28px !important;
|
||||
place-items: center;
|
||||
margin: 0 !important;
|
||||
overflow: hidden;
|
||||
color: transparent !important;
|
||||
font-size: 0 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link::after { content: "›" !important; color: #2563eb; font-size: 20px; line-height: 1; }
|
||||
|
||||
/* 普通弹层的筛选和宽表入口各占一个网格列;退出宽表后不保留绝对定位层。 */
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .ehb-real-drill-primary-actions {
|
||||
position: static !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
width: 112px !important;
|
||||
min-width: 112px !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions {
|
||||
position: absolute !important;
|
||||
z-index: 280 !important;
|
||||
top: 5px !important;
|
||||
right: 8px !important;
|
||||
display: block !important;
|
||||
width: 126px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
position: static !important;
|
||||
display: inline-flex !important;
|
||||
width: 126px !important;
|
||||
min-width: 126px !important;
|
||||
height: 32px !important;
|
||||
min-height: 32px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
|
||||
display: flex !important;
|
||||
min-height: 0 !important;
|
||||
flex: 1 1 auto !important;
|
||||
padding: 5px 6px 6px !important;
|
||||
gap: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
flex: 1 1 auto !important;
|
||||
margin: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* Shared drill workspace: navigation stays visible, data owns the remaining height. */
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] > .ehb-modal-body {
|
||||
flex: 1 1 0 !important;
|
||||
min-height: 0 !important;
|
||||
padding: 4px 8px !important;
|
||||
gap: 3px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table {
|
||||
width: 100% !important;
|
||||
min-width: 1050px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table th,
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table td {
|
||||
padding: 7px 9px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-drill-page-controls .ehb-btn {
|
||||
min-height: 32px !important;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-inline-child-row > td { background: #f8fbff; border-bottom: 1px solid #e2eaf4; }
|
||||
.ehb-drill-modal--unified .ehb-inline-child-row > td:first-child { background: #f8fbff !important; }
|
||||
.ehb-inline-child-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.ehb-inline-child-name > span { overflow-wrap: anywhere; font-weight: 600; }
|
||||
.ehb-inline-child-name small { display: block; color: #64748b; }
|
||||
.ehb-inline-child-toggle { flex: 0 0 32px; width: 32px; height: 36px; border: 0; border-radius: 6px; color: #2563eb; background: #eaf2ff; cursor: pointer; }
|
||||
.ehb-drill-modal--unified .ehb-inline-child-status > td,
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 8px 16px !important; background: #f8fbff; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all { border: 0; background: transparent; color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group > div { min-width: 0; }
|
||||
@media (max-width: 767px) {
|
||||
.ehb-modal-card.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) > .ehb-modal-head {
|
||||
min-height: 68px;
|
||||
padding: 10px 12px !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn {
|
||||
width: 44px !important;
|
||||
min-width: 44px !important;
|
||||
height: 44px !important;
|
||||
min-height: 44px !important;
|
||||
flex: 0 0 44px;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 6%) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn > span { display: none; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.5;
|
||||
color: #a7b6cc;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #f0f3f8;
|
||||
box-shadow: inset 0 -1px 0 #d7e1ed;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th:first-child {
|
||||
z-index: 11;
|
||||
}
|
||||
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
|
||||
width: 96vw !important;
|
||||
max-width: 1800px !important;
|
||||
height: 94dvh !important;
|
||||
max-height: 94dvh !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head {
|
||||
padding: 10px 16px !important;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-drill-breadcrumbs { padding: 5px 16px; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 6px !important;
|
||||
padding: 8px 16px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > * { flex: 0 0 auto; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-hint-text {
|
||||
flex: 0 0 auto !important;
|
||||
min-width: 0;
|
||||
margin: 0 !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary > span {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-root-tabs { margin: 0 !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar { margin: 0 !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { padding: 6px 12px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 18px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-primary-actions {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d7e1ed;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel:not(.is-open) { display: none !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel.is-open {
|
||||
display: flex !important;
|
||||
max-height: 30dvh;
|
||||
overflow: auto;
|
||||
margin: 0 !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-usage-guide { font-size: 12px; margin: 0; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-table-wrap {
|
||||
flex: 1 1 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-page-controls { margin-top: 0 !important; padding: 0; }
|
||||
@media (max-width: 767px), (max-height: 500px) {
|
||||
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
|
||||
width: 100vw !important; height: 100dvh !important; max-height: 100dvh !important;
|
||||
border-radius: 0 !important; margin: 0 !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head { padding: 6px 10px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
|
||||
padding: 6px 8px max(6px, env(safe-area-inset-bottom)) !important; gap: 4px !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { flex: 0 0 auto; width: auto !important; min-width: 120px; padding: 5px 9px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 16px !important; }
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
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 "../model/daily-detail-format";
|
||||
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
|
||||
import { downloadExcelAoa } from "../common/prototype-download";
|
||||
import "./real-daily-mobile.css";
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillResponse,
|
||||
H2BiQuery,
|
||||
H2BiVehicleScope,
|
||||
} from "../types";
|
||||
|
||||
const format = (value: number, digits = 2) =>
|
||||
value.toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
|
||||
scope === "own" ? "lingniu" : scope;
|
||||
const sourceLabel = (source: unknown) => {
|
||||
const value = String(source || "").toLowerCase();
|
||||
if (value === "lingniu") return "羚牛上报";
|
||||
if (value === "api") return "API接入";
|
||||
if (value === "station") return "站点上报";
|
||||
return String(source || "未知来源");
|
||||
};
|
||||
/** Keep raw API statuses out of the UI. Missing/unknown statuses are unverified. */
|
||||
const verifyLabel = (status: unknown) => {
|
||||
const value = String(status ?? "").trim().toLowerCase();
|
||||
return value === "verified" || value === "pass" || value === "已验证"
|
||||
? "已验证"
|
||||
: "未验证";
|
||||
};
|
||||
|
||||
export function PrototypeRealDailyView({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
fleetScope,
|
||||
onFleetScopeChange,
|
||||
verifyScope,
|
||||
stationId = null,
|
||||
onRefresh,
|
||||
refreshToken = 0,
|
||||
onLoadingChange,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onStartDateChange: (value: string) => void;
|
||||
onEndDateChange: (value: string) => void;
|
||||
fleetScope: "all" | "own" | "external";
|
||||
onFleetScopeChange: (value: "all" | "own" | "external") => void;
|
||||
verifyScope: "all" | "verified";
|
||||
stationId?: string | number | null;
|
||||
onRefresh: () => void;
|
||||
refreshToken?: number;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
}) {
|
||||
const query = useMemo<H2BiQuery>(
|
||||
() => ({
|
||||
year: Number(startDate.slice(0, 4)),
|
||||
startDate,
|
||||
endDate,
|
||||
vehicleScope: toScope(fleetScope),
|
||||
verifyScope,
|
||||
stationId,
|
||||
}),
|
||||
[endDate, fleetScope, startDate, stationId, verifyScope],
|
||||
);
|
||||
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [trees, setTrees] = useState<Record<string, H2BiDailyTreeResponse>>({});
|
||||
const [expandedDate, setExpandedDate] = useState<string | null>(null);
|
||||
const [expandedStation, setExpandedStation] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedCustomer, setExpandedCustomer] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedStationLists, setExpandedStationLists] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedCustomerLists, setExpandedCustomerLists] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedRecordLists, setExpandedRecordLists] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [customerRecords, setCustomerRecords] = useState<
|
||||
Record<string, H2BiDrillResponse>
|
||||
>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
|
||||
const [detailMode, setDetailMode] = useState<"key" | "full">("key");
|
||||
const [branchErrors, setBranchErrors] = useState<Record<string, string>>({});
|
||||
const requestGeneration = useRef(0);
|
||||
const pendingBranches = useRef(new Set<string>());
|
||||
const tableWrapRef = useRef<HTMLDivElement>(null);
|
||||
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
requestGeneration.current += 1;
|
||||
pendingBranches.current.clear();
|
||||
setBranchErrors({});
|
||||
let finishTimer: number | undefined;
|
||||
const loadingStartedAt = Date.now();
|
||||
setIsLoading(true);
|
||||
onLoadingChange?.(true);
|
||||
// The selected range is a new data contract. Clear the former response so
|
||||
// an API error can never be mistaken for fresh data or business zeroes.
|
||||
setDaily(null);
|
||||
setError(null);
|
||||
setTrees({});
|
||||
setExpandedDate(null);
|
||||
setExpandedStation({});
|
||||
setExpandedCustomer({});
|
||||
setExpandedStationLists({});
|
||||
setExpandedCustomerLists({});
|
||||
setExpandedRecordLists({});
|
||||
setCustomerRecords({});
|
||||
void fetchH2BiDaily(query)
|
||||
.then((result) => alive && setDaily(result))
|
||||
.catch(
|
||||
(reason: unknown) => {
|
||||
if (!alive) return;
|
||||
setDaily(null);
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "按日数据加载失败",
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (!alive) return;
|
||||
const remaining = Math.max(0, 500 - (Date.now() - loadingStartedAt));
|
||||
finishTimer = window.setTimeout(() => {
|
||||
if (!alive) return;
|
||||
setIsLoading(false);
|
||||
onLoadingChange?.(false);
|
||||
}, remaining);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
requestGeneration.current += 1;
|
||||
if (finishTimer !== undefined) window.clearTimeout(finishTimer);
|
||||
};
|
||||
}, [query, reloadToken, refreshToken, onLoadingChange]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setReloadToken((value) => value + 1);
|
||||
onRefresh();
|
||||
};
|
||||
const ensureDateTree = (date: string) => {
|
||||
if (trees[date] || pendingBranches.current.has(date)) return;
|
||||
const generation = requestGeneration.current;
|
||||
pendingBranches.current.add(date);
|
||||
setBranchErrors((items) => ({ ...items, [date]: "" }));
|
||||
void fetchH2BiDailyTree(date, {
|
||||
vehicleScope: query.vehicleScope,
|
||||
verifyScope,
|
||||
stationId,
|
||||
}).then((tree) => {
|
||||
if (generation === requestGeneration.current) setTrees((items) => ({ ...items, [date]: tree }));
|
||||
}).catch(() => {
|
||||
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [date]: "站点明细加载失败,请重试" }));
|
||||
}).finally(() => {
|
||||
if (generation === requestGeneration.current) pendingBranches.current.delete(date);
|
||||
});
|
||||
};
|
||||
const openDate = (date: string, scrollIntoDate = false) => {
|
||||
setExpandedDate(date);
|
||||
ensureDateTree(date);
|
||||
if (!scrollIntoDate) return;
|
||||
setHighlightedDate(date);
|
||||
window.setTimeout(() => {
|
||||
dateRowRefs.current[date]?.scrollIntoView({
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 40);
|
||||
window.setTimeout(() => {
|
||||
setHighlightedDate((current) => (current === date ? null : current));
|
||||
}, 2200);
|
||||
};
|
||||
const toggleDate = (date: string) => {
|
||||
if (expandedDate === date) {
|
||||
setExpandedDate(null);
|
||||
return;
|
||||
}
|
||||
openDate(date);
|
||||
};
|
||||
const loadCustomer = (
|
||||
date: string,
|
||||
stationId: string | number,
|
||||
customerId: number,
|
||||
) => {
|
||||
const key = `${date}:${stationId}:${customerId}`;
|
||||
if (customerRecords[key] || pendingBranches.current.has(key)) return;
|
||||
const generation = requestGeneration.current;
|
||||
pendingBranches.current.add(key);
|
||||
setBranchErrors((items) => ({ ...items, [key]: "" }));
|
||||
void fetchH2BiDrill({
|
||||
...query,
|
||||
date,
|
||||
stationId,
|
||||
customerId,
|
||||
groupBy: "record",
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}).then((result) => {
|
||||
if (generation === requestGeneration.current) setCustomerRecords((items) => ({ ...items, [key]: result }));
|
||||
}).catch(() => {
|
||||
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [key]: "车辆明细加载失败,请重试" }));
|
||||
}).finally(() => {
|
||||
if (generation === requestGeneration.current) pendingBranches.current.delete(key);
|
||||
});
|
||||
};
|
||||
const toggleCustomer = (date: string, station: string | number, customer: number) => {
|
||||
const key = `${date}:${station}:${customer}`;
|
||||
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
|
||||
loadCustomer(date, station, customer);
|
||||
};
|
||||
const exportRows = () => {
|
||||
if (!daily) return;
|
||||
downloadExcelAoa(
|
||||
dailySummaryRows(daily),
|
||||
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
|
||||
"日期汇总",
|
||||
);
|
||||
};
|
||||
const trend = daily?.trend ?? [];
|
||||
const maxKg = Math.max(...trend.map((row) => row.kg), 1);
|
||||
const averageKg = daily?.kpis.averageDailyKg ?? 0;
|
||||
const peak = trend.reduce(
|
||||
(best, row) => (row.kg > best.kg ? row : best),
|
||||
trend[0],
|
||||
);
|
||||
const trough = trend
|
||||
.filter((row) => row.kg > 0)
|
||||
.reduce(
|
||||
(best, row) => (!best || row.kg < best.kg ? row : best),
|
||||
undefined as (typeof trend)[number] | undefined,
|
||||
);
|
||||
return (
|
||||
<div className="ehb-daily-container">
|
||||
{isLoading && !daily ? (
|
||||
<div className="ehb-live-data-state is-loading" role="status" aria-live="polite">
|
||||
<span className="ehb-live-data-spinner" aria-hidden />
|
||||
<strong>正在读取真实日期统计</strong>
|
||||
<span>加载完成前不展示业务零值</span>
|
||||
</div>
|
||||
) : null}
|
||||
<section className="ehb-daily-filter-card">
|
||||
<div className="ehb-daily-filter-row">
|
||||
<div className="ehb-daily-filter-group">
|
||||
<div className="ehb-pill-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-pill-btn"
|
||||
onClick={() => {
|
||||
const today = endDate;
|
||||
onStartDateChange(today.slice(0, 8) + "01");
|
||||
}}
|
||||
>
|
||||
本月
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-pill-btn"
|
||||
onClick={() => {
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const begin = new Date(end);
|
||||
begin.setDate(end.getDate() - 14);
|
||||
onStartDateChange(begin.toISOString().slice(0, 10));
|
||||
}}
|
||||
>
|
||||
近15天
|
||||
</button>
|
||||
<button type="button" className="ehb-pill-btn is-active">
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="ehb-modal-select"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => onStartDateChange(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="ehb-modal-select"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => onEndDateChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ehb-daily-filter-group">
|
||||
<div className="ehb-fleet-segmented">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${fleetScope === "all" ? "is-active" : ""}`}
|
||||
onClick={() => onFleetScopeChange("all")}
|
||||
>
|
||||
全部车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${fleetScope === "own" ? "is-active" : ""}`}
|
||||
onClick={() => onFleetScopeChange("own")}
|
||||
>
|
||||
<Truck size={14} />
|
||||
羚牛车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${fleetScope === "external" ? "is-active" : ""}`}
|
||||
onClick={() => onFleetScopeChange("external")}
|
||||
>
|
||||
<Truck size={14} />
|
||||
外部车辆
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--ghost"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "is-spinning" : ""} />
|
||||
{isLoading ? "加载中…" : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{error ? <div className="ehb-empty">{error}</div> : null}
|
||||
{daily ? <>
|
||||
<section className="ehb-daily-kpi-grid">
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">区间加氢量</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
{format(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">
|
||||
{startDate} 至 {endDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">区间成本</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
¥{format(daily?.kpis.totalCost ?? 0)}
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">真实成本台账汇总</div>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">有效天数</div>
|
||||
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
|
||||
<div className="ehb-daily-kpi-sub">
|
||||
日均 {format(daily?.kpis.averageDailyKg ?? 0)} Kg
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">涉及加氢站</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
{daily?.kpis.stationCount ?? 0} <small>站</small>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">按明细站点去重</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="ehb-daily-chart-section">
|
||||
<div className="ehb-daily-chart-head">
|
||||
<div className="ehb-daily-chart-title">
|
||||
每日加氢量{" "}
|
||||
<span className="ehb-title-sub">
|
||||
(点击柱体下锚定位到对应日期明细)
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-daily-chart-meta-group">
|
||||
<div className="ehb-daily-chart-legend">
|
||||
<span className="ehb-legend-item">
|
||||
<span className="ehb-legend-dot is-own" />
|
||||
内部客户
|
||||
</span>
|
||||
<span className="ehb-legend-item">
|
||||
<span className="ehb-legend-dot is-ext" />
|
||||
外部客户
|
||||
</span>
|
||||
</div>
|
||||
<span className="ehb-daily-chart-meta">时间单位:日 · 单位 Kg</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-summary-pills">
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>峰值日</span>
|
||||
<strong>{peak ? `${peak.date} ${format(peak.kg)} Kg` : "—"}</strong>
|
||||
</div>
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>低谷日</span>
|
||||
<strong>
|
||||
{trough ? `${trough.date} ${format(trough.kg)} Kg` : "—"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>零数日</span>
|
||||
<strong>{trend.filter((row) => row.kg === 0).length} 天</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-bar-container">
|
||||
<div
|
||||
className="ehb-daily-avg-line"
|
||||
style={{
|
||||
bottom: `${Math.min(92, Math.round((averageKg / maxKg) * 100))}%`,
|
||||
}}
|
||||
>
|
||||
<span className="ehb-daily-avg-label">
|
||||
均值 {format(averageKg)} Kg
|
||||
</span>
|
||||
</div>
|
||||
{trend.map((row) => {
|
||||
const total = row.kg || 1;
|
||||
const ownRatio = (row.lingniuKg / total) * 100;
|
||||
const extRatio = (row.externalKg / total) * 100;
|
||||
const active = expandedDate === row.date;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={row.date}
|
||||
className="ehb-daily-bar-col"
|
||||
onClick={() => openDate(row.date, true)}
|
||||
title={`${row.date} 加氢总量 ${format(row.kg)} Kg;点击展开并定位当日明细`}
|
||||
aria-label={`展开并定位${row.date}当日加氢明细`}
|
||||
>
|
||||
<div
|
||||
className="ehb-daily-bar-val"
|
||||
style={{
|
||||
color: active ? "#0284c7" : undefined,
|
||||
fontWeight: active ? 700 : undefined,
|
||||
}}
|
||||
>
|
||||
{Math.round(row.kg)}
|
||||
</div>
|
||||
<div
|
||||
className={`ehb-daily-bar-fill is-stacked ${active ? "is-active" : ""}`}
|
||||
style={{ height: `${Math.max(0, (row.kg / maxKg) * 100)}%` }}
|
||||
>
|
||||
{row.externalKg > 0 ? (
|
||||
<div
|
||||
className="ehb-bar-segment is-ext"
|
||||
style={{ height: `${extRatio}%` }}
|
||||
/>
|
||||
) : null}
|
||||
{row.lingniuKg > 0 ? (
|
||||
<div
|
||||
className="ehb-bar-segment is-own"
|
||||
style={{ height: `${ownRatio}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="ehb-daily-bar-label"
|
||||
style={{
|
||||
color: active ? "#0284c7" : undefined,
|
||||
fontWeight: active ? 700 : undefined,
|
||||
}}
|
||||
>
|
||||
{row.date.slice(5)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</> : null}
|
||||
<section className="ehb-daily-table-card ehb-real-daily-detail" data-detail-mode={detailMode}>
|
||||
<div className="ehb-daily-table-head">
|
||||
<div className="ehb-daily-table-title">
|
||||
每日加氢数据明细{" "}
|
||||
<span className="ehb-title-sub">
|
||||
(可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--outline ehb-export-btn"
|
||||
onClick={exportRows}
|
||||
disabled={!daily || isLoading || !daily.days.length}
|
||||
title="导出所选区间的全部日期汇总,不含客户和车辆流水"
|
||||
>
|
||||
<Download size={14} />
|
||||
导出日期汇总
|
||||
</button>
|
||||
</div>
|
||||
<div className="ehb-daily-detail-toolbar">
|
||||
<div className="ehb-daily-view-switch" role="group" aria-label="明细显示方式">
|
||||
{([['key', '重点指标'], ['full', '完整表格']] as const).map(([mode, label]) =>
|
||||
<button type="button" key={mode} aria-pressed={detailMode === mode} onClick={() => {
|
||||
setDetailMode(mode);
|
||||
if (tableWrapRef.current) tableWrapRef.current.scrollLeft = 0;
|
||||
}}>{label}</button>)}
|
||||
</div>
|
||||
<label className="ehb-daily-date-jump">
|
||||
<span>定位日期</span>
|
||||
<select value={expandedDate ?? ""} disabled={!daily?.days.length} onChange={(event) => {
|
||||
if (event.target.value) openDate(event.target.value, true);
|
||||
}}>
|
||||
<option value="">选择日期</option>
|
||||
{(daily?.days ?? []).map(day => <option key={day.date} value={day.date}>{day.date}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="ehb-daily-collapse" disabled={!expandedDate} onClick={() => {
|
||||
setExpandedDate(null); setExpandedStation({}); setExpandedCustomer({});
|
||||
setExpandedStationLists({}); setExpandedCustomerLists({}); setExpandedRecordLists({});
|
||||
}}>全部收起</button>
|
||||
</div>
|
||||
<div
|
||||
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{detailMode === "key" ? "点击名称逐层查看:日期 → 站点 → 客户 → 车辆" : "首列已固定 · 左右滑动查看全部指标"}
|
||||
</div>
|
||||
{!daily ? <div className="ehb-daily-detail-empty" role={error ? "alert" : "status"}>
|
||||
{error ? <>明细暂时无法加载<button type="button" onClick={handleRefresh}>重新加载</button></> : "正在加载日期明细…"}
|
||||
</div> : !daily.days.length ? <div className="ehb-daily-detail-empty" role="status">所选日期和车辆范围内暂无加氢记录,请调整筛选条件。</div> :
|
||||
<div ref={tableWrapRef} className="ehb-table-wrap" role="region" aria-label="每日加氢明细,可左右滚动" tabIndex={0}>
|
||||
<table className="ehb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期 / 明细</th>
|
||||
<th>单价(元/Kg)</th>
|
||||
<th>加氢量(Kg)</th>
|
||||
<th>成本(元) / 环比</th>
|
||||
<th>预充值余额 / 数据来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style={{ background: "#f8fafc", fontWeight: 700 }}>
|
||||
<td>合计</td>
|
||||
<td />
|
||||
<td>{format(daily?.kpis.totalKg ?? 0)}</td>
|
||||
<td>¥{format(daily?.kpis.totalCost ?? 0)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{(daily?.days ?? []).map((day) => {
|
||||
const tree = trees[day.date];
|
||||
const open = expandedDate === day.date;
|
||||
return (
|
||||
<Fragment key={day.date}>
|
||||
<tr
|
||||
ref={(node) => {
|
||||
dateRowRefs.current[day.date] = node;
|
||||
}}
|
||||
id={`daily-row-${day.date}`}
|
||||
className={`ehb-daily-date-row${highlightedDate === day.date ? " is-highlighted" : ""}`}
|
||||
style={{
|
||||
background: open ? "#f0f9ff" : undefined,
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<DailyTreeButton open={open} label={`${day.date}加氢站明细`} onClick={() => toggleDate(day.date)}>
|
||||
{day.date}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({day.stationCount ?? 0} 个加氢站)
|
||||
</span>
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(day.kg)}</td>
|
||||
<td>
|
||||
<strong className="ehb-daily-cost">{format(day.cost)}</strong>
|
||||
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
|
||||
</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{open && (!tree || tree.stations.length === 0) ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} error={branchErrors[day.date]}
|
||||
empty={!!tree} onRetry={() => ensureDateTree(day.date)} /> : null}
|
||||
{open &&
|
||||
tree?.stations
|
||||
.slice(0, expandedStationLists[day.date] ? undefined : 10)
|
||||
.map((station) => {
|
||||
const stationKey = `${day.date}:${station.id}`;
|
||||
const stationOpen = !!expandedStation[stationKey];
|
||||
return (
|
||||
<Fragment key={stationKey}>
|
||||
<tr
|
||||
style={{
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<td className="ehb-tree-cell-l1">
|
||||
<DailyTreeButton open={stationOpen} label={`${station.name}客户明细`} onClick={() =>
|
||||
setExpandedStation(items => ({ ...items, [stationKey]: !items[stationKey] }))}>
|
||||
<small className="ehb-daily-level-label">加氢站</small>{station.name}
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(station.kg)}</td>
|
||||
<td>¥{format(station.cost)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{stationOpen && station.customers.length === 0 ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} empty onRetry={() => {}} /> : null}
|
||||
{stationOpen &&
|
||||
station.customers
|
||||
.slice(
|
||||
0,
|
||||
expandedCustomerLists[stationKey]
|
||||
? undefined
|
||||
: 10,
|
||||
)
|
||||
.map((customer) => {
|
||||
const customerKey = `${stationKey}:${customer.id}`;
|
||||
const customerOpen =
|
||||
!!expandedCustomer[customerKey];
|
||||
const allRecords =
|
||||
customerRecords[customerKey]?.records ?? [];
|
||||
const records = allRecords.slice(
|
||||
0,
|
||||
expandedRecordLists[customerKey]
|
||||
? undefined
|
||||
: 20,
|
||||
);
|
||||
return (
|
||||
<Fragment key={customerKey}>
|
||||
<tr>
|
||||
<td className="ehb-tree-cell-l2">
|
||||
<DailyTreeButton open={customerOpen} label={`${customer.name}车辆明细`}
|
||||
onClick={() => toggleCustomer(day.date, station.id, customer.id)}>
|
||||
<small className="ehb-daily-level-label">客户</small>{customer.name}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({customer.recordCount} 笔)
|
||||
</span>
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(customer.kg)}</td>
|
||||
<td>¥{format(customer.cost)}</td>
|
||||
<td>点击查看真实流水</td>
|
||||
</tr>
|
||||
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} error={branchErrors[customerKey]}
|
||||
empty={!!customerRecords[customerKey]} onRetry={() => loadCustomer(day.date, station.id, customer.id)} /> : null}
|
||||
{customerOpen &&
|
||||
records.map((record) => (
|
||||
<tr key={String(record.id)}>
|
||||
<td className="ehb-tree-cell-l3">
|
||||
<small className="ehb-daily-level-label">车辆 · {String(record.time || "—").slice(11, 16)}</small>
|
||||
<strong>
|
||||
{String(record.plateNo || "无车牌")}
|
||||
</strong>{" "}
|
||||
<span
|
||||
className={`ehb-daily-record-tag ${record.vehicleScope === "lingniu" ? "is-own" : "is-external"}`}
|
||||
>
|
||||
{record.vehicleScope === "lingniu"
|
||||
? "羚牛车辆"
|
||||
: "外部车辆"}
|
||||
</span>
|
||||
<span
|
||||
className="ehb-daily-record-tag is-source"
|
||||
title={String(record.source || "未知来源")}
|
||||
>
|
||||
{sourceLabel(record.source)}
|
||||
</span>
|
||||
<span
|
||||
className={`ehb-daily-record-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "is-verified" : "is-unverified"}`}
|
||||
title={verifyLabel(record.verifyStatus)}
|
||||
>
|
||||
{verifyLabel(record.verifyStatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{format(
|
||||
Number(record.unitPrice ?? 0),
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{format(Number(record.kg ?? 0))}
|
||||
</td>
|
||||
<td>
|
||||
¥{format(Number(record.cost ?? 0))}
|
||||
</td>
|
||||
<td>暂无预充值余额</td>
|
||||
</tr>
|
||||
))}
|
||||
{customerOpen && allRecords.length > 20 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={detailMode === "key" ? 3 : 5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpandedRecordLists((items) => ({
|
||||
...items,
|
||||
[customerKey]: !items[customerKey],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expandedRecordLists[customerKey]
|
||||
? "收起车辆明细"
|
||||
: `更多车辆明细(还有 ${allRecords.length - 20} 笔)`}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{stationOpen && station.customers.length > 10 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={detailMode === "key" ? 3 : 5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpandedCustomerLists((items) => ({
|
||||
...items,
|
||||
[stationKey]: !items[stationKey],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expandedCustomerLists[stationKey]
|
||||
? "收起客户"
|
||||
: `更多客户(还有 ${station.customers.length - 10} 个)`}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{open && tree && tree.stations.length > 10 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={detailMode === "key" ? 3 : 5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpandedStationLists((items) => ({
|
||||
...items,
|
||||
[day.date]: !items[day.date],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expandedStationLists[day.date]
|
||||
? "收起加氢站"
|
||||
: `更多加氢站(还有 ${tree.stations.length - 10} 个)`}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
/* Scoped to the live daily table; keep the other boards and drill dialogs intact. */
|
||||
.ehb-real-daily-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.ehb-daily-detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.ehb-daily-view-switch {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.ehb-real-daily-detail button,
|
||||
.ehb-real-daily-detail select { font: inherit; }
|
||||
.ehb-daily-view-switch button,
|
||||
.ehb-daily-collapse,
|
||||
.ehb-daily-branch-state button,
|
||||
.ehb-daily-detail-empty button {
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ehb-daily-view-switch button[aria-pressed="true"] {
|
||||
background: #fff;
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 1px 4px #0f172a14;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ehb-daily-date-jump { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #64748b; }
|
||||
.ehb-daily-date-jump select { min-height: 40px; border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; color: #334155; }
|
||||
.ehb-real-daily-detail button:disabled { opacity: .45; cursor: default; }
|
||||
.ehb-real-daily-detail :is(button, select, [tabindex]):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
|
||||
.ehb-real-daily-detail .ehb-daily-disclosure {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ehb-daily-disclosure > svg { flex-shrink: 0; margin-top: 3px; color: #64748b; }
|
||||
.ehb-daily-disclosure > span { min-width: 0; }
|
||||
.ehb-daily-disclosure[aria-expanded="true"] { font-weight: 600; }
|
||||
.ehb-daily-disclosure[aria-expanded="true"] > svg { color: #2563eb; }
|
||||
.ehb-daily-level-label,
|
||||
.ehb-daily-change { display: block; font-size: 11px; font-weight: 400; color: #64748b; line-height: 1.6; }
|
||||
.ehb-daily-cost { font-weight: 500; }
|
||||
.ehb-daily-branch-state > td > div { display: flex; align-items: center; gap: 8px; }
|
||||
.ehb-daily-branch-state button { display: inline-flex; align-items: center; gap: 5px; color: #1d4ed8; background: #eff6ff; }
|
||||
.ehb-daily-detail-empty { padding: 24px 16px; text-align: center; color: #64748b; font-size: 13px; }
|
||||
.ehb-daily-detail-empty button { display: block; margin: 8px auto 0; color: #1d4ed8; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):is(:nth-child(2), :nth-child(5)) { display: none; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):first-child:not([colspan]) { width: 36%; }
|
||||
.ehb-real-daily-detail .ehb-table td:not(:first-child) { text-align: right; }
|
||||
.ehb-real-daily-detail .ehb-table th:not(:first-child) { text-align: right; }
|
||||
.ehb-real-daily-detail .ehb-table-wrap {
|
||||
isolation: isolate;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table {
|
||||
table-layout: fixed;
|
||||
min-width: 940px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table tr {
|
||||
background-color: #fff;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th:first-child,
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 260px;
|
||||
background-color: inherit;
|
||||
box-shadow: 1px 0 0 #e2e8f0, 5px 0 8px -6px #64748b;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
/* Give nested day → station → customer → vehicle rows a viewport-sized workspace. */
|
||||
.ehb-real-daily-detail.ehb-daily-table-card > .ehb-table-wrap {
|
||||
height: 80vh;
|
||||
height: 80dvh;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ehb-real-daily-detail .ehb-table th:first-child {
|
||||
z-index: 4;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th {
|
||||
z-index: 3;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td:not(:first-child) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 767px), (max-width: 1024px) and (max-height: 500px) {
|
||||
.ehb-real-daily-detail.ehb-daily-table-card {
|
||||
padding: 12px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-head {
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-title .ehb-title-sub {
|
||||
display: none;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-export-btn {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-detail-toolbar { padding: 0 12px; gap: 8px; }
|
||||
.ehb-real-daily-detail .ehb-daily-view-switch { width: 100%; }
|
||||
.ehb-real-daily-detail .ehb-daily-view-switch button { flex: 1; min-height: 44px; }
|
||||
.ehb-real-daily-detail .ehb-daily-date-jump { flex: 1; }
|
||||
.ehb-real-daily-detail .ehb-daily-date-jump select { min-height: 44px; }
|
||||
.ehb-real-daily-detail .ehb-daily-collapse { min-height: 44px; padding: 6px; font-size: 12px; }
|
||||
.ehb-real-daily-detail .ehb-daily-table-scroll-hint {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table-wrap {
|
||||
max-height: 65dvh;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table {
|
||||
min-width: 760px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th:first-child,
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
|
||||
width: 142px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td {
|
||||
height: 48px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; width: 100%; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td) { padding: 10px 8px; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table td:not(:first-child) { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:has(.ehb-daily-disclosure) { padding-top: 4px; padding-bottom: 4px; }
|
||||
.ehb-real-daily-detail .ehb-table th {
|
||||
padding: 10px 12px;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td:first-child .ehb-title-sub {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l1 { padding-left: 16px; }
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l2 { padding-left: 22px; }
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l3 { padding-left: 28px; }
|
||||
.ehb-real-daily-detail .ehb-daily-record-tag {
|
||||
display: inline-block;
|
||||
margin: 2px 3px 0 0;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 \/>/);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EnergyBiBoardApp } from './board/EnergyBiBoardApp';
|
||||
|
||||
/**
|
||||
* The accepted hydrogen fee BI surface. It is shared with the independent
|
||||
* acceptance route so the menu entry and /energy/hydrogen-board cannot drift.
|
||||
*/
|
||||
export default function HydrogenModule() {
|
||||
return <EnergyBiBoardApp />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { bearingLabels } from "./bearing-labels";
|
||||
|
||||
test("records display their ledger bearing type", () => {
|
||||
assert.deepEqual(bearingLabels({ settlementType: 1 }).map(x => x.label), ["客户承担"]);
|
||||
assert.deepEqual(bearingLabels({ settlementType: "2" }).map(x => x.label), ["我司承担"]);
|
||||
assert.deepEqual(bearingLabels({ settlementType: 3 }).map(x => x.label), ["客户自行结算"]);
|
||||
});
|
||||
test("groups display every distinct bearing type, including unknown", () => {
|
||||
assert.deepEqual(bearingLabels({ settlementTypes: "1,2,3,unknown,1" }).map(x => x.label),
|
||||
["客户承担", "我司承担", "客户自行结算", "未明确"]);
|
||||
});
|
||||
test("missing and unrecognized values do not imply an actual payer", () => {
|
||||
for (const settlementType of [null, undefined, "", 4, "all"]) {
|
||||
assert.deepEqual(bearingLabels({ settlementType }).map(x => x.label), ["未明确"]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const labels: Record<string, { label: string; className: string }> = {
|
||||
"1": { label: "客户承担", className: "is-cust" },
|
||||
"2": { label: "我司承担", className: "is-lingniu" },
|
||||
"3": { label: "客户自行结算", className: "is-other" },
|
||||
};
|
||||
|
||||
// Use ledger settlement types, never the selected filter or monetary amounts.
|
||||
export function bearingLabels(row: { settlementTypes?: unknown; settlementType?: unknown }) {
|
||||
const types = String(row.settlementTypes ?? row.settlementType ?? "")
|
||||
.split(",").map((value) => value.trim());
|
||||
const results = types.map((value) => labels[value] ?? { label: "未明确", className: "is-other" });
|
||||
return [...new Map(results.map((item) => [item.label, item])).values()];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { H2BiDailyResponse } from "../types";
|
||||
|
||||
export function formatDailyChange(value: unknown): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
|
||||
return `环比 ${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/** Export the entire selected range, independently of which branches were opened. */
|
||||
export function dailySummaryRows(daily: H2BiDailyResponse): Array<Array<string | number>> {
|
||||
return [
|
||||
["日期", "加氢站数", "加氢量(Kg)", "成本(元)"],
|
||||
["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost],
|
||||
...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { finiteNumber, formatNumber, formatScaled } from "./display-format";
|
||||
|
||||
test("能源看板格式化边界区分真实零值与不可用值", () => {
|
||||
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
|
||||
assert.equal(finiteNumber(value), null);
|
||||
assert.equal(formatNumber(value), "—");
|
||||
assert.equal(formatScaled(value, 1000), "—");
|
||||
}
|
||||
assert.equal(formatNumber(0), "0.00");
|
||||
assert.equal(formatScaled(0, 1000), "0.00");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export const finiteNumber = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
export const formatNumber = (value: unknown, digits = 2): string => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null
|
||||
? "—"
|
||||
: safe.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
};
|
||||
|
||||
export const formatScaled = (value: unknown, divisor: number, digits = 2) => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null ? "—" : formatNumber(safe / divisor, digits);
|
||||
};
|
||||
@@ -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
@@ -0,0 +1,297 @@
|
||||
export type H2BiScope = "global" | "station";
|
||||
export type H2BiView = "overview" | "daily";
|
||||
export type H2BiVehicleScope = "all" | "lingniu" | "external";
|
||||
export type H2BiVerifyScope = "all" | "verified" | "unverified";
|
||||
/** 账本 settlement_type 的承担口径;下钻、KPI 与趋势图必须使用同一口径。 */
|
||||
export type H2BiAmountScope = "all" | "customer" | "company" | "other";
|
||||
export type H2BiRegionGranularity = "province" | "city";
|
||||
|
||||
export interface H2BiQuery {
|
||||
year: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
date?: string;
|
||||
month?: string;
|
||||
stationId?: string | number | null;
|
||||
customerId?: number | null;
|
||||
customerName?: string | null;
|
||||
plateNo?: string | null;
|
||||
region?: string | null;
|
||||
regionGranularity?: H2BiRegionGranularity;
|
||||
vehicleScope: H2BiVehicleScope;
|
||||
verifyScope: H2BiVerifyScope;
|
||||
}
|
||||
|
||||
export interface H2BiRange {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
export interface H2BiWatermark {
|
||||
ledgerAt: string | null;
|
||||
paymentAt: string | null;
|
||||
}
|
||||
|
||||
export interface H2BiStationOption {
|
||||
id: string | number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface H2BiMetaResponse {
|
||||
years: Array<{ value: number; startDate: string; endDate: string }>;
|
||||
stations: H2BiStationOption[];
|
||||
watermark: H2BiWatermark;
|
||||
}
|
||||
|
||||
export interface H2BiKpis {
|
||||
totalKg: number;
|
||||
totalCost: number;
|
||||
/** 加氢量:账本 settlement_type=1(客户承担)。 */
|
||||
customerBearingKg: number;
|
||||
/** 加氢量:账本 settlement_type=2(我司承担)。 */
|
||||
companyBearingKg: number;
|
||||
/** 加氢量:其他承担方式(含客户自行结算、未知)。 */
|
||||
otherBearingKg: number;
|
||||
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
|
||||
customerRevenue: number;
|
||||
/** 成本金额:账本 settlement_type=1 的 cost_total。 */
|
||||
customerCost: number;
|
||||
/** 成本金额:账本 settlement_type=2 的 cost_total。 */
|
||||
companyCost: number;
|
||||
/** 成本金额:其他承担方式的 cost_total。 */
|
||||
otherCost: number;
|
||||
totalRevenue: number;
|
||||
customerGrossProfit: number;
|
||||
monthKg: number;
|
||||
monthCost: number;
|
||||
todayKg: number;
|
||||
todayCost: number;
|
||||
monthShareOfRange: number;
|
||||
todayShareOfMonth: number;
|
||||
recordCount: number;
|
||||
stationCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiMonthlyPoint {
|
||||
month: string;
|
||||
totalKg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
cost: number;
|
||||
customerCost: number;
|
||||
companyCost: number;
|
||||
otherCost: number;
|
||||
revenue: number;
|
||||
customerRevenue: number;
|
||||
customerGrossProfit: number;
|
||||
}
|
||||
|
||||
export interface H2BiRegionRow {
|
||||
region: string;
|
||||
kg: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface H2BiStationRow {
|
||||
rank?: number;
|
||||
id: string | number | null;
|
||||
name: string;
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
kg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
cost: number;
|
||||
revenue: number;
|
||||
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
|
||||
customerRevenue: number;
|
||||
customerCost: number;
|
||||
companyCost: number;
|
||||
otherCost: number;
|
||||
recordCount: number;
|
||||
customerCount: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface H2BiCustomerRow {
|
||||
rank?: number;
|
||||
id: number | null;
|
||||
name: string;
|
||||
kg: number;
|
||||
/** 加氢订单中账本 settlement_type=1(客户承担)的量。 */
|
||||
customerBearingKg: number;
|
||||
/** 加氢订单中账本 settlement_type=2(我司承担)的量。 */
|
||||
companyBearingKg: number;
|
||||
/** 其他承担方式(含客户自行结算、未知)的量。 */
|
||||
otherBearingKg: number;
|
||||
bearer: "company" | "customer" | "both" | "other";
|
||||
cost: number;
|
||||
revenue: number;
|
||||
customerRevenue: number;
|
||||
customerCost: number;
|
||||
companyCost: number;
|
||||
otherCost: number;
|
||||
recordCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiOverviewResponse {
|
||||
range: H2BiRange;
|
||||
watermark: H2BiWatermark;
|
||||
filters: Partial<H2BiQuery>;
|
||||
kpis: H2BiKpis;
|
||||
monthly: H2BiMonthlyPoint[];
|
||||
topStations: H2BiStationRow[];
|
||||
regions: H2BiRegionRow[];
|
||||
stations: H2BiStationRow[];
|
||||
customers: H2BiCustomerRow[];
|
||||
}
|
||||
|
||||
export interface H2BiDailyPoint {
|
||||
date: string;
|
||||
kg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
cost: number;
|
||||
recordCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyRow extends H2BiDailyPoint {
|
||||
stationCount?: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyKpis {
|
||||
totalKg: number;
|
||||
totalCost: number;
|
||||
averageDailyKg: number;
|
||||
stationCount: number;
|
||||
activeDays: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyResponse {
|
||||
range: H2BiRange;
|
||||
watermark: H2BiWatermark;
|
||||
filters: Partial<H2BiQuery>;
|
||||
kpis: H2BiDailyKpis;
|
||||
trend: H2BiDailyPoint[];
|
||||
days: H2BiDailyRow[];
|
||||
}
|
||||
|
||||
export type H2BiDrillMetric =
|
||||
| "totalKg"
|
||||
| "totalCost"
|
||||
| "totalRevenue"
|
||||
| "customerGrossProfit"
|
||||
| "monthKg"
|
||||
| "todayKg"
|
||||
| "station"
|
||||
| "customer"
|
||||
| "day";
|
||||
export type H2BiDrillGroupBy =
|
||||
"station" | "customer" | "date" | "vehicle" | "record";
|
||||
|
||||
export interface H2BiDrillQuery extends H2BiQuery {
|
||||
groupBy: H2BiDrillGroupBy;
|
||||
amountScope?: H2BiAmountScope;
|
||||
customerId?: number | null;
|
||||
plateNo?: string | null;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface H2BiDrillSummary {
|
||||
label?: string;
|
||||
value?: number;
|
||||
count?: number;
|
||||
[key: string]: string | number | null | undefined;
|
||||
}
|
||||
|
||||
export type H2BiDrillRecord = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
||||
export interface H2BiDrillGroupRow {
|
||||
/** Distinct ledger settlement types for this group; never the UI filter. */
|
||||
settlementTypes?: string | null;
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
id: string;
|
||||
name: string;
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
recordCount: number;
|
||||
stationCount: number;
|
||||
customerCount: number;
|
||||
kg: number;
|
||||
cost: number;
|
||||
revenue: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
}
|
||||
|
||||
export interface H2BiDrillResponse {
|
||||
groupBy: H2BiDrillGroupBy;
|
||||
amountScope: H2BiAmountScope;
|
||||
filters: Partial<H2BiQuery>;
|
||||
summary: H2BiDrillSummary;
|
||||
groups: H2BiDrillGroupRow[];
|
||||
records: H2BiDrillRecord[];
|
||||
page: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** Number of rows returned on this page (groups or records). */
|
||||
itemCount?: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options shared by paged drill reads and explicit full exports. */
|
||||
export interface H2BiDrillReadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliberately bounded, complete drill read. This is for exports and
|
||||
* dedicated detail views only; normal drill tables should use one page.
|
||||
*/
|
||||
export interface H2BiFullDrillResponse extends H2BiDrillResponse {
|
||||
page: H2BiDrillResponse["page"] & {
|
||||
pagesRead: number;
|
||||
complete: true;
|
||||
};
|
||||
}
|
||||
|
||||
export interface H2BiFullDrillOptions extends H2BiDrillReadOptions {
|
||||
/** Protect the browser from an accidental unbounded export. Default: 250. */
|
||||
maxPages?: number;
|
||||
/** Reject instead of retaining an unexpectedly huge complete result. Default: 50000. */
|
||||
maxRows?: number;
|
||||
/** API page size, capped by the server at 200. Default: 200. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeCustomer {
|
||||
id: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
cost: number;
|
||||
recordCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeStation {
|
||||
id: string | number;
|
||||
name: string;
|
||||
kg: number;
|
||||
cost: number;
|
||||
recordCount: number;
|
||||
customers: H2BiDailyTreeCustomer[];
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeResponse {
|
||||
date: string;
|
||||
stations: H2BiDailyTreeStation[];
|
||||
}
|
||||
|
||||
export type H2BiDailyTreeQuery = Omit<
|
||||
Partial<H2BiQuery>,
|
||||
"date" | "startDate" | "endDate" | "month"
|
||||
>;
|
||||
Reference in New Issue
Block a user