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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user