refactor(stage5): 合并重复实现,移除失效的演示能力

Excel 导出(原先 4 份各自拼装 workbook)
- 新增 src/shared/xlsx.ts 作为唯一实现:文件名统一 .xlsx、sheet 名截断到 31 字符,
  对外提供 buildAoaSheet / buildJsonSheet / writeWorkbook / exportAoaSheet / exportJsonSheet。
  只收敛"组装与写出"这一层,各调用方仍自行决定列宽、冻结与数字格式,导出样式不变。
- 迁移 assets(内联 json_to_sheet + writeFile)、mileage/xlsx-export、
  hydrogen 的两份 helper(prototype-download.ts 与 download-xls.js,均已删除)。

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

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

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

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

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

lint / test(137) / build 全绿,可达性仍为 0 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:28:32 +08:00
parent 5d14a28b0f
commit 507bc90ed2
33 changed files with 334 additions and 318 deletions
@@ -20,7 +20,7 @@ import {
X,
Zap,
} from 'lucide-react';
import { downloadExcelAoa } from '../common/download-xls';
import { exportAoaSheet } from '../../../../shared/xlsx';
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
import {
SOURCE_LABEL,
@@ -3241,7 +3241,7 @@ function HostDailyView({
const fileDateStr = `${startDate.replace(/[/]/g, '')}-${endDate.replace(/[/]/g, '')}`;
const fleetName = fleetType === 'all' ? '全部车辆' : fleetType === 'own' ? '羚牛车辆' : '外部车辆';
downloadExcelAoa(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细');
exportAoaSheet(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细');
};
return (
@@ -1,41 +0,0 @@
/**
* 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;
@@ -1,12 +0,0 @@
import * as XLSX from 'xlsx';
export function downloadExcelAoa(
rows: Array<Array<string | number | boolean | null | undefined>>,
fileName: string,
sheetName: string,
) {
const workbook = XLSX.utils.book_new();
const sheet = XLSX.utils.aoa_to_sheet(rows);
XLSX.utils.book_append_sheet(workbook, sheet, sheetName.slice(0, 31));
XLSX.writeFile(workbook, fileName);
}
@@ -2,8 +2,9 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Download, RefreshCw, Truck } from "lucide-react";
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import { formatFixed } from "../model/display-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
import { downloadExcelAoa } from "../common/prototype-download";
import { exportAoaSheet } from "../../../../shared/xlsx";
import "./real-daily-mobile.css";
import type {
H2BiDailyResponse,
@@ -13,11 +14,6 @@ import type {
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) => {
@@ -226,7 +222,7 @@ export function PrototypeRealDailyView({
};
const exportRows = () => {
if (!daily) return;
downloadExcelAoa(
exportAoaSheet(
dailySummaryRows(daily),
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
"日期汇总",
@@ -342,7 +338,7 @@ export function PrototypeRealDailyView({
<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>
{formatFixed(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
</div>
<div className="ehb-daily-kpi-sub">
{startDate} {endDate}
@@ -351,7 +347,7 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">
¥{format(daily?.kpis.totalCost ?? 0)}
¥{formatFixed(daily?.kpis.totalCost ?? 0)}
</div>
<div className="ehb-daily-kpi-sub"></div>
</div>
@@ -359,7 +355,7 @@ export function PrototypeRealDailyView({
<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
{formatFixed(daily?.kpis.averageDailyKg ?? 0)} Kg
</div>
</div>
<div className="ehb-daily-kpi-card">
@@ -395,12 +391,12 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-summary-pills">
<div className="ehb-daily-pill-item">
<span></span>
<strong>{peak ? `${peak.date} ${format(peak.kg)} Kg` : "—"}</strong>
<strong>{peak ? `${peak.date} ${formatFixed(peak.kg)} Kg` : "—"}</strong>
</div>
<div className="ehb-daily-pill-item">
<span></span>
<strong>
{trough ? `${trough.date} ${format(trough.kg)} Kg` : "—"}
{trough ? `${trough.date} ${formatFixed(trough.kg)} Kg` : "—"}
</strong>
</div>
<div className="ehb-daily-pill-item">
@@ -416,7 +412,7 @@ export function PrototypeRealDailyView({
}}
>
<span className="ehb-daily-avg-label">
{format(averageKg)} Kg
{formatFixed(averageKg)} Kg
</span>
</div>
{trend.map((row) => {
@@ -430,7 +426,7 @@ export function PrototypeRealDailyView({
key={row.date}
className="ehb-daily-bar-col"
onClick={() => openDate(row.date, true)}
title={`${row.date} 加氢总量 ${format(row.kg)} Kg;点击展开并定位当日明细`}
title={`${row.date} 加氢总量 ${formatFixed(row.kg)} Kg;点击展开并定位当日明细`}
aria-label={`展开并定位${row.date}当日加氢明细`}
>
<div
@@ -539,8 +535,8 @@ export function PrototypeRealDailyView({
<tr style={{ background: "#f8fafc", fontWeight: 700 }}>
<td></td>
<td />
<td>{format(daily?.kpis.totalKg ?? 0)}</td>
<td>¥{format(daily?.kpis.totalCost ?? 0)}</td>
<td>{formatFixed(daily?.kpis.totalKg ?? 0)}</td>
<td>¥{formatFixed(daily?.kpis.totalCost ?? 0)}</td>
<td></td>
</tr>
{(daily?.days ?? []).map((day) => {
@@ -567,9 +563,9 @@ export function PrototypeRealDailyView({
</DailyTreeButton>
</td>
<td></td>
<td>{format(day.kg)}</td>
<td>{formatFixed(day.kg)}</td>
<td>
<strong className="ehb-daily-cost">{format(day.cost)}</strong>
<strong className="ehb-daily-cost">{formatFixed(day.cost)}</strong>
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
</td>
<td></td>
@@ -597,8 +593,8 @@ export function PrototypeRealDailyView({
</DailyTreeButton>
</td>
<td></td>
<td>{format(station.kg)}</td>
<td>¥{format(station.cost)}</td>
<td>{formatFixed(station.kg)}</td>
<td>¥{formatFixed(station.cost)}</td>
<td></td>
</tr>
{stationOpen && station.customers.length === 0 ? <DailyBranchState
@@ -636,8 +632,8 @@ export function PrototypeRealDailyView({
</DailyTreeButton>
</td>
<td></td>
<td>{format(customer.kg)}</td>
<td>¥{format(customer.cost)}</td>
<td>{formatFixed(customer.kg)}</td>
<td>¥{formatFixed(customer.cost)}</td>
<td></td>
</tr>
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
@@ -672,15 +668,15 @@ export function PrototypeRealDailyView({
</span>
</td>
<td>
{format(
{formatFixed(
Number(record.unitPrice ?? 0),
)}
</td>
<td>
{format(Number(record.kg ?? 0))}
{formatFixed(Number(record.kg ?? 0))}
</td>
<td>
¥{format(Number(record.cost ?? 0))}
¥{formatFixed(Number(record.cost ?? 0))}
</td>
<td></td>
</tr>
@@ -4,8 +4,9 @@ import { createPortal } from "react-dom";
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
import { MobileListFullscreenButton } from "../common/MobileListFullscreenButton";
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "../api";
import { downloadExcelAoa } from "../common/prototype-download";
import { exportAoaSheet } from "../../../../shared/xlsx";
import { bearingLabels } from "../model/bearing-labels";
import { formatFixed } from "../model/display-format";
import type {
H2BiDrillGroupBy,
H2BiDrillGroupRow,
@@ -79,11 +80,6 @@ type DrillState = Pick<
stationName?: string;
};
const formatNumber = (value: unknown, digits = 2) =>
Number(value ?? 0).toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope =>
value === "own" ? "lingniu" : value;
const verifyLabel = (status: unknown) => {
@@ -461,7 +457,7 @@ function ExpandedChildren({ data, state, query, kind, label, depth = 1 }: {
function ExpandedMetrics({ row, kind, label, amountScope }: {
row: { kg?: unknown; cost?: unknown; revenue?: unknown }; kind: DrillKind; label: string; amountScope: H2BiAmountScope;
}) {
const cell = (value: unknown, money = false) => <td style={{textAlign:"right"}}>{money ? "¥" : ""}{formatNumber(value)}</td>;
const cell = (value: unknown, money = false) => <td style={{textAlign:"right"}}>{money ? "¥" : ""}{formatFixed(value)}</td>;
if (label === "加氢利润") return <>{cell(row.revenue, true)}{cell(row.cost, true)}{cell(Number(row.revenue ?? 0) - Number(row.cost ?? 0), true)}</>;
if (kind === "customer") return <>{cell(row.cost, true)}{cell(row.revenue, true)}<td></td><td></td></>;
return <>{cell(row.kg)}{cell(amountScope === "customer" ? row.revenue : row.cost, true)}{label === "本日加氢" || label === "本月加氢" ? <td style={{textAlign:"right"}}></td> : null}</>;
@@ -481,7 +477,7 @@ function ExpandedChild({ row, state, query, kind, label, depth }: {
<button type="button" className="ehb-inline-child-toggle" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${row.name}`} onClick={() => { setExpanded(!expanded); setPage(1); }}>{expanded ? "▾" : "▸"}</button>
<span>{row.name}</span>
</div></td>
<td>{titleFor(state.level)}</td><td><BearingTags row={row} /></td><td></td><td></td><td style={{textAlign:"right"}}>{formatNumber(row.recordCount, 0)} </td>
<td>{titleFor(state.level)}</td><td><BearingTags row={row} /></td><td></td><td></td><td style={{textAlign:"right"}}>{formatFixed(row.recordCount, 0)} </td>
<ExpandedMetrics row={row} kind={kind} label={label} amountScope={state.amountScope} />
</tr>
{expanded ? live.loading || live.error || !live.data ? <tr className="ehb-inline-child-status"><td colSpan={columns}>{live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"}</td></tr> : <>
@@ -543,8 +539,8 @@ function GroupTable({
{region ? <td>{index + 1}</td> : null}
<td style={{ fontWeight: 650 }}>{row.name}</td>
<td>{stationCustomer ? <span className={`ehb-tag ${row.lingniuKg > 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}>{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"}</span> : row.province || "未归属"}</td>
{monthMetric === "加氢量" ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.lingniuKg)}</td><td className="ehb-key-external" style={{ textAlign: "right" }}>{formatNumber(row.externalKg)}</td></> : null}
<td className={monthMetric === "客户收入" ? "ehb-key-income" : monthMetric === "成本支出" ? "ehb-key-cost" : "ehb-key-volume"} style={{ textAlign: "right" }}>{monthMetric === "客户收入" ? `¥${formatNumber(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatNumber(row.cost)}` : formatNumber(row.kg)}</td>
{monthMetric === "加氢量" ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.lingniuKg)}</td><td className="ehb-key-external" style={{ textAlign: "right" }}>{formatFixed(row.externalKg)}</td></> : null}
<td className={monthMetric === "客户收入" ? "ehb-key-income" : monthMetric === "成本支出" ? "ehb-key-cost" : "ehb-key-volume"} style={{ textAlign: "right" }}>{monthMetric === "客户收入" ? `¥${formatFixed(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatFixed(row.cost)}` : formatFixed(row.kg)}</td>
{stationCustomer || region ? <td style={{ textAlign: "right" }}>{totalKg > 0 ? `${((Number(row.kg) / totalKg) * 100).toFixed(1)}%` : "0.0%"}</td> : null}
</tr>
))}</tbody>
@@ -552,10 +548,10 @@ function GroupTable({
);
}
if (kind === "station" && state.level === "date") {
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th></th><th style={{ textAlign: "right" }}></th><th style={{ textAlign: "right" }}> (Kg)</th><th></th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> (/Kg)</th></tr></thead><tbody>{data.groups.map((row, index) => { const previous = Number(data.groups[index + 1]?.kg || 0); const change = previous > 0 ? ((Number(row.kg) - previous) / previous) * 100 : null; return <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} </td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className={change !== null && change >= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td style={{ textAlign: "right" }}>¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}</td></tr>; })}</tbody></table>;
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th></th><th style={{ textAlign: "right" }}></th><th style={{ textAlign: "right" }}> (Kg)</th><th></th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> (/Kg)</th></tr></thead><tbody>{data.groups.map((row, index) => { const previous = Number(data.groups[index + 1]?.kg || 0); const change = previous > 0 ? ((Number(row.kg) - previous) / previous) * 100 : null; return <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td style={{ textAlign: "right" }}>{formatFixed(row.recordCount, 0)} </td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className={change !== null && change >= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td style={{ textAlign: "right" }}>¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}</td></tr>; })}</tbody></table>;
}
if (kind === "customer" && state.level === "date") {
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th> / </th><th></th><th></th><th style={{ textAlign: "right" }}> (Kg)</th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> ()</th><th></th><th></th></tr></thead><tbody>{data.groups.map((row) => <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td></td><td><span className="ehb-bearer-tag is-cust"></span></td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td></td><td></td></tr>)}</tbody></table>;
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th> / </th><th></th><th></th><th style={{ textAlign: "right" }}> (Kg)</th><th style={{ textAlign: "right" }}> ()</th><th style={{ textAlign: "right" }}> ()</th><th></th><th></th></tr></thead><tbody>{data.groups.map((row) => <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle"></span>📅 {row.name}</td><td></td><td><span className="ehb-bearer-tag is-cust"></span></td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td></td><td></td></tr>)}</tbody></table>;
}
if (state.level === "record") {
return (
@@ -628,9 +624,9 @@ function GroupTable({
<td style={{ textAlign: "right" }}>
1
</td>
<td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(record.kg)}</td>
<td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(record.kg)}</td>
<td className="ehb-key-income" style={{ textAlign: "right" }}>
¥{formatNumber(amountFor(record))}
¥{formatFixed(amountFor(record))}
</td>
</tr>
))}
@@ -748,7 +744,7 @@ function GroupTable({
</span>
) : state.level === "station" ? (
<span className="ehb-tree-node-sub">
{formatNumber(row.customerCount, 0)}
{formatFixed(row.customerCount, 0)}
</span>
) : (
<span className="ehb-tree-node-sub"></span>
@@ -767,8 +763,8 @@ function GroupTable({
{state.level === "station" ? "汇总" : "点击展开"}
</span>
</td>
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} </td>
{isProfit ? <><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>¥{formatNumber(Number(row.revenue) - Number(row.cost))}</td></> : isMonth || isDay ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}></td></> : isCustomerBill ? <><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td></td><td></td></> : <><td className="ehb-key-volume" style={{ textAlign: "right", fontWeight: 700 }}>{formatNumber(row.kg)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td></>}
<td style={{ textAlign: "right" }}>{formatFixed(row.recordCount, 0)} </td>
{isProfit ? <><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>¥{formatFixed(Number(row.revenue) - Number(row.cost))}</td></> : isMonth || isDay ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(amountFor(row))}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}></td></> : isCustomerBill ? <><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td></td><td></td></> : <><td className="ehb-key-volume" style={{ textAlign: "right", fontWeight: 700 }}>{formatFixed(row.kg)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(amountFor(row))}</td></>}
</tr>
{expanded && !expandedLoading && !expandedError && expandedData ? <ExpandedChildren data={expandedData} state={nextState(state, row)} query={query} kind={kind} label={label} /> : null}
{expanded ? (
@@ -810,17 +806,17 @@ function DrillSummaryCards({ kind, label: labelInput, data }: { kind: DrillKind;
const externalKg = groups.reduce((sum, row) => sum + Number(row.externalKg || 0), 0);
const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1];
let cards: Array<[string, string, string?]>;
if (label === "加氢利润") cards = [["收入合计", `¥${formatNumber(revenue)}`, "income"], ["成本合计", `¥${formatNumber(cost)}`, "cost"], ["加氢利润", `¥${formatNumber(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本月加氢") cards = [["本月加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本日加氢") cards = [["本日加氢量", `${formatNumber(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatNumber(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatNumber(revenue)}`, "income"], ["站均收入", `¥${formatNumber(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatNumber(cost)}`, "cost"], ["站均成本", `¥${formatNumber(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]];
else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`]];
else if (/^区域(?:市|省)/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省)/, "")], ["加氢总量", `${formatNumber(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount}`]];
else if (kind === "station") cards = [["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatNumber(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatNumber(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatNumber(summary?.recordCount ?? 0, 0)}`]];
else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatNumber(revenue)}`, "income"], ["已收", "未接入"], ["未收", "未接入"]];
else cards = [["数据归集总量", `${formatNumber(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatNumber(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount}`], ["来源记录完整度", `${summary?.recordCount ? formatNumber((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]];
if (label === "加氢利润") cards = [["收入合计", `¥${formatFixed(revenue)}`, "income"], ["成本合计", `¥${formatFixed(cost)}`, "cost"], ["加氢利润", `¥${formatFixed(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本月加氢") cards = [["本月加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本日加氢") cards = [["本日加氢量", `${formatFixed(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatFixed(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatFixed(revenue)}`, "income"], ["站均收入", `¥${formatFixed(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatFixed(cost)}`, "cost"], ["站均成本", `¥${formatFixed(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]];
else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`]];
else if (/^区域(?:市|省)/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省)/, "")], ["加氢总量", `${formatFixed(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount}`]];
else if (kind === "station") cards = [["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatFixed(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatFixed(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatFixed(summary?.recordCount ?? 0, 0)}`]];
else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatFixed(revenue)}`, "income"], ["已收", "未接入"], ["未收", "未接入"]];
else cards = [["数据归集总量", `${formatFixed(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatFixed(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount}`], ["来源记录完整度", `${summary?.recordCount ? formatFixed((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]];
return <div className="ehb-modal-meta-bar">{cards.map(([title, value, tone]) => <div className="ehb-modal-meta-item" key={title}><span className="ehb-modal-meta-label">{title}</span><span className={`ehb-modal-meta-val ${tone ? `ehb-summary-${tone}` : ""}`}>{value}</span></div>)}</div>;
}
@@ -1082,7 +1078,7 @@ export function PrototypeDrillModal({
rows.push([row.name, row.kg, row.cost, row.revenue]),
);
}
downloadExcelAoa(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
exportAoaSheet(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
};
const exportCurrent = () => {
if (data) exportData(data, `${page}`);
@@ -1244,8 +1240,8 @@ export function PrototypeDrillModal({
{cleanLabel === "加氢利润" && customerBearingScope ? (
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
¥
{formatNumber(live.data?.summary.revenue)} ¥
{formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)}
{formatFixed(live.data?.summary.revenue)} ¥
{formatFixed(live.data?.summary.cost)} = ¥{formatFixed(profit)}
</div>
) : null}
<div className="ehb-real-drill-primary-actions">
@@ -1,13 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finiteNumber, formatNumber, formatScaled } from "./display-format";
import { formatFixed, isFiniteNumberValue } from "./display-format";
test("能源看板格式化边界区分真实零值与不可用值", () => {
test("默认口径:缺失值按 0 展示,与氢能明细表既有行为一致", () => {
assert.equal(formatFixed(0), "0.00");
assert.equal(formatFixed(null), "0.00");
assert.equal(formatFixed(undefined), "0.00");
assert.equal(formatFixed(1234.5), "1,234.50");
assert.equal(formatFixed(1234.5, 0), "1,235");
});
test("显式开启 blankForMissing 时区分真实零值与不可用值", () => {
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(isFiniteNumberValue(value), false);
assert.equal(formatFixed(value, 2, { blankForMissing: true }), "—");
}
assert.equal(formatNumber(0), "0.00");
assert.equal(formatScaled(0, 1000), "0.00");
assert.equal(formatFixed(0, 2, { blankForMissing: true }), "0.00");
});
@@ -1,17 +1,33 @@
export const finiteNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
/**
* 氢能看板的数值格式化。
*
* 此前两个下钻视图各自复制了一份同样的实现(共 42 处调用),另有一份
* 无人使用的 "—" 版本只被自己的测试引用。这里收敛为唯一实现,并把
* "缺失值是否显示为 0" 变成显式选项,而不是靠不同的函数名区分口径。
*/
export const formatNumber = (value: unknown, digits = 2): string => {
const safe = finiteNumber(value);
return safe === null
? "—"
: safe.toLocaleString("zh-CN", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
};
export interface FormatOptions {
/**
* 缺失或不可用(非有限数)时返回 "—" 而不是 0。
* 默认 false:明细表按 0 展示,与既有氢能账本口径一致;
* 需要区分"真实零值"与"接口未返回"时显式开启。
*/
blankForMissing?: boolean;
}
export const formatScaled = (value: unknown, divisor: number, digits = 2) => {
const safe = finiteNumber(value);
return safe === null ? "—" : formatNumber(safe / divisor, digits);
};
/** 该值本身是否为可参与计算的有限数字(不把 null / undefined / "0" 视为数字)。 */
export function isFiniteNumberValue(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/**
* 固定小数位的千分位文案。
* 默认路径与既有实现逐字一致:`Number(value ?? 0)` 后按固定小数位格式化。
*/
export function formatFixed(value: unknown, digits = 2, options: FormatOptions = {}): string {
if (options.blankForMissing && !isFiniteNumberValue(value)) return '—';
return Number(value ?? 0).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
@@ -5,7 +5,7 @@
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react';
import { downloadExcelAoa } from '../common/download-xls';
import { exportAoaSheet } from '../../../../shared/xlsx';
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
import {
SPOT_PAY_METHOD_LABEL,
@@ -364,7 +364,7 @@ export const StationDailyDetailView: React.FC<{
r.amountYuan,
]),
];
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
exportAoaSheet(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
} catch (reason) {
if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试');
} finally {