Files
ln-bi/src/modules/energy/hydrogen/board/EnergyBiBoardApp.tsx
T
shishengliang c2f21a3a8a
ci/woodpecker/push/woodpecker Pipeline was successful
fix: restore post-v1.1.14 features while preserving asset updates
Undo the full-tree rollback in 6ea1b3d and restore the pre-rollback v1.2.0 code. Preserve the new asset flow rules, abnormal inventory separation, range drilldowns and date picker; adapt the regression test to the restored routes layout.
2026-09-16 10:48:32 +08:00

3766 lines
165 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Activity,
AlertTriangle,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Download,
Fuel,
Maximize2,
ReceiptText,
RefreshCw,
Search,
TrendingUp,
Truck,
Wallet,
X,
Zap,
} from 'lucide-react';
import { exportAoaSheet } from '../../../../shared/xlsx';
import { HYDROGEN_VERIFY_START_DATE } from '../../../../shared/hydrogen-verify';
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
import {
SOURCE_LABEL,
companyRowsForStats,
computeHostKpi,
costDimCards,
costDimLabel,
customerAttrAgg,
filterOrders,
formatKg,
formatYuan,
stationMonthAgg,
type DimFilter,
unverified,
} from './data/aggregates';
import { DEFAULT_YEAR, HOST_KPI, MOCK_ORDERS } from './data/mockBoard';
import {
DAILY_VERIFY_LABEL,
MOCK_DAILY_15DAYS,
SOURCE_TYPE_LABEL,
STATION_TYPE_LABEL,
calculateDailyKpis,
filterDailyDataByFleet,
getDailyDataForRange,
type FleetCategory,
type FleetCategoryFilter,
} from './data/mockDaily';
import {
BORNE_BY_LABEL,
BORNE_BY_ORDER,
type BorneBy,
type FleetScope,
type HostView,
type H2OrderRow,
} from './types';
import { StationDailyApp } from '../station-daily/StationDailyApp';
import '../station-daily/styles.css';
import './styles/energy-bi-board.css';
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiMeta, fetchH2BiOverview } from '../api';
import { PrototypeRealDailyView } from '../drill/prototype-real-daily';
import { PrototypeDrillModal, prototypeFleetScope } from '../drill/prototype-real-drills';
type BoardScope = 'global' | 'station';
type StatsTab = 'siteMonth' | 'customer';
type DailyRangePreset = 'week' | 'month' | '15days' | 'custom';
type DrillTreeAxis = 'station' | 'customer';
const CHART_BLUE = '#2f6bff';
const CHART_EXTERNAL = '#8fb4ff';
const CHART_INCOME = '#2f9fb3';
const CHART_COST = '#7c83e6';
// Keep the compact desktop treatment while ensuring keyboard/touch users get a reliable target.
const ACCESSIBLE_CONTROL_STYLE = { minHeight: 44 };
const DRILL_CUSTOMER_BORNE: Record<string, BorneBy> = {
'c-zp': 'customer',
'c-ys': 'customer',
'c-zq': 'customer',
'c-ln': 'company',
'c-qb': 'customer',
'c-yj': 'customer',
'c-js': 'pending',
'c-gz': 'customer',
};
function renderBorneTag(borneBy: BorneBy | null | undefined) {
if (!borneBy) return <span style={{ color: '#94a3b8' }}>-</span>;
const compactLabel: Record<BorneBy, string> = {
company: '羚牛',
customer: '客户',
pending: '待核',
};
return (
<span className={`ehb-bearer-tag is-${borneBy}`} title={BORNE_BY_LABEL[borneBy]}>
{compactLabel[borneBy]}
</span>
);
}
function summaryBorneBy(bearer: 'cust' | 'lingniu', customerName: string): BorneBy {
if (customerName === '车辆异动') return 'pending';
return bearer === 'cust' ? 'customer' : 'company';
}
function mobileProvinceLabel(province: string) {
return province === 'all' ? '全国' : province.slice(0, 2);
}
interface BiYearSelectProps {
value: number;
onChange: (year: number) => void;
}
function BiYearSelect({ value, onChange }: BiYearSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 提供从 2026 到 2020 年份列表,满足历史多年数据查阅诉求
const years = [2026, 2025, 2024, 2023, 2022, 2021, 2020];
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className="ehb-year-select-wrapper" ref={ref}>
<button
type="button"
className={`ehb-year-select-btn ${isOpen ? 'is-active' : ''}`}
onClick={() => setIsOpen(!isOpen)}
aria-label="年份选择"
>
<Calendar className="ehb-year-calendar" size={14} aria-hidden />
<span className="ehb-year-text">{value} <span className="ehb-desktop-year-suffix"></span></span>
<ChevronDown
size={13}
style={{
transition: 'transform 0.2s ease',
transform: isOpen ? 'rotate(180deg)' : 'none',
color: '#64748b',
}}
/>
</button>
{isOpen && (
<div className="ehb-year-dropdown">
<div className="ehb-year-dropdown__header">切换数据年份</div>
<div className="ehb-year-dropdown__list">
{years.map((y) => (
<button
key={y}
type="button"
className={`ehb-year-dropdown__item ${y === value ? 'is-selected' : ''}`}
onClick={() => {
onChange(y);
setIsOpen(false);
}}
>
<span>{y} </span>
{y === value && <span className="ehb-year-check"></span>}
</button>
))}
</div>
</div>
)}
</div>
);
}
/** BI 皮 · 可搜索选择器(禁 V2;穿透筛专用) */
interface BiSearchSelectOption {
value: string;
label: string;
}
function BiSearchSelect({
value,
onChange,
options,
allLabel,
placeholder = '搜索…',
width = 180,
disabled = false,
}: {
value: string;
onChange: (next: string) => void;
options: BiSearchSelectOption[];
allLabel: string;
placeholder?: string;
width?: number;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
if (open) document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
useEffect(() => {
if (open) {
setQuery('');
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
const selectedLabel =
value === 'all' ? allLabel : options.find((o) => o.value === value)?.label || allLabel;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
}, [options, query]);
return (
<div className={`ehb-bi-search-select ${disabled ? 'is-disabled' : ''}`} ref={ref} style={{ width }}>
<button
type="button"
className={`ehb-bi-search-select__trigger ${open ? 'is-open' : ''} ${value !== 'all' ? 'has-value' : ''}`}
onClick={() => !disabled && setOpen((v) => !v)}
disabled={disabled}
title={selectedLabel}
>
<span className="ehb-bi-search-select__label">{selectedLabel}</span>
<ChevronDown size={13} className="ehb-bi-search-select__chevron" />
</button>
{open && !disabled && (
<div className="ehb-bi-search-select__dropdown">
<div className="ehb-bi-search-select__search">
<Search size={13} />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="ehb-bi-search-select__list">
<button
type="button"
className={`ehb-bi-search-select__item ${value === 'all' ? 'is-selected' : ''}`}
onClick={() => {
onChange('all');
setOpen(false);
}}
>
{allLabel}
</button>
{filtered.map((o) => (
<button
key={o.value}
type="button"
className={`ehb-bi-search-select__item ${value === o.value ? 'is-selected' : ''}`}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
title={o.label}
>
{o.label}
</button>
))}
{filtered.length === 0 && <div className="ehb-bi-search-select__empty">无匹配项</div>}
</div>
</div>
)}
</div>
);
}
interface OverviewTrendsProps {
year: number;
fleetScope: FleetScope;
verifyScope: 'all' | 'verified';
onOpenDrill: (label: string) => void;
onOpenCustomerBill: (custName: string) => void;
onOpenStationBill: (stName: string, province: string) => void;
overview: any;
}
function OverviewTrendsDashboard({ year, fleetScope, verifyScope, onOpenDrill, onOpenCustomerBill, onOpenStationBill, overview }: OverviewTrendsProps) {
const overviewRangeText = overview?.range?.startDate && overview?.range?.endDate
? `${overview.range.startDate}${overview.range.endDate}`
: `${year}-01-01 至 ${year}-12-31`;
// 月度加氢量数据 (根据年份、车辆范围、核对范围加权)
const monthlyData = useMemo(() => {
if (overview) {
return overview.monthly.map((item: any) => ({
month: `${Number(String(item.month).slice(-2))}月`,
ownKg: Number(item.lingniuKg) || 0,
extKg: Number(item.externalKg) || 0,
totalKg: Number(item.totalKg) || 0,
}));
}
const is2026 = year === 2026;
const is2025 = year === 2025;
const factor = is2026 ? 1 : is2025 ? 0.85 : 0.7;
// 仅已核对:外部车在示意数据中不参与核对 → 外部归零;羚牛约 3/4 已核
const ownVerifyFactor = verifyScope === 'verified' ? 0.75 : 1;
const extVerifyFactor = verifyScope === 'verified' ? 0 : 1;
const baseMonths = [
{ m: '1月', own: 56800, ext: 28400 },
{ m: '2月', own: 34600, ext: 17400 },
{ m: '3月', own: 75200, ext: 37600 },
{ m: '4月', own: 90000, ext: 45000 },
{ m: '5月', own: 85300, ext: 42700 },
{ m: '6月', own: 78600, ext: 39400 },
{ m: '7月', own: 81300, ext: 40700 },
{ m: '8月', own: 18600, ext: 9400 },
];
return baseMonths.map((item) => {
let ownKg = Math.round(item.own * factor * ownVerifyFactor);
let extKg = Math.round(item.ext * factor * extVerifyFactor);
if (fleetScope === 'own') extKg = 0;
if (fleetScope === 'external') ownKg = 0;
const totalKg = ownKg + extKg;
return { month: item.m, ownKg, extKg, totalKg };
});
}, [year, fleetScope, verifyScope, overview]);
const maxMonthlyKg = useMemo(() => {
return Math.max(...monthlyData.map((d) => d.totalKg), 1);
}, [monthlyData]);
// 基础客户列表池,用于计算月度客户加氢金额排行
const baseCustomers = [
{ name: '嘉兴市乍浦港口经营有限公司', ratio: 0.408 },
{ name: '嘉兴益顺冷链物流有限公司', ratio: 0.176 },
{ name: '嘉兴智奇供应链管理有限公司', ratio: 0.118 },
{ name: '四川群彬物流有限公司', ratio: 0.078 },
{ name: '浙江洋井供应链管理有限公司', ratio: 0.053 },
{ name: '重庆金时源供应链有限公司', ratio: 0.039 },
{ name: '四川拱照物流有限公司', ratio: 0.030 },
{ name: '嘉兴羚利供应链科技有限公司', ratio: 0.027 },
{ name: '宁波港集装箱运输嘉兴分公司', ratio: 0.023 },
{ name: '成都诺和物流有限公司', ratio: 0.015 },
{ name: '嘉兴市飞宇物流有限公司', ratio: 0.012 },
{ name: '嘉兴港区众通快递有限公司', ratio: 0.010 },
{ name: '浙江集佑供应链有限公司', ratio: 0.008 },
{ name: '日邮物流(中国)有限公司', ratio: 0.003 },
];
// 月度收支对比数据及客户加氢金额明细 & 成本支出结构明细
const monthlyRevenueData = useMemo(() => {
if (overview) {
return overview.monthly.map((item: any) => {
const income = Number(item.customerRevenue) || 0;
const cost = Number(item.cost) || 0;
return {
m: `${Number(String(item.month).slice(-2))}月`, income, cost,
top9: [], restAmount: 0, restCount: 0,
costDetails: [
{ label: '客户承担', amount: Number(item.customerCost) || 0 },
{ label: '我司承担', amount: Number(item.companyCost) || 0 },
{ label: '其他成本', amount: Number(item.otherCost) || 0 },
],
};
});
}
const is2026 = year === 2026;
const factor = is2026 ? 1 : 0.8;
const fleetFactor = fleetScope === 'all' ? 1 : fleetScope === 'own' ? 0.67 : 0.33;
const verifyFactor = verifyScope === 'verified' ? (fleetScope === 'external' ? 0 : 0.75) : 1;
const scale = factor * fleetFactor * verifyFactor;
const base = [
{ m: '1月', income: 82000, cost: 78000 },
{ m: '2月', income: 38000, cost: 36000 },
{ m: '3月', income: 98000, cost: 92000 },
{ m: '4月', income: 105000, cost: 99000 },
{ m: '5月', income: 112000, cost: 104000 },
{ m: '6月', income: 118000, cost: 109000 },
{ m: '7月', income: 115000, cost: 108000 },
{ m: '8月', income: 16500, cost: 15800 },
];
return base.map((b) => {
const income = Math.round(b.income * scale);
const cost = Math.round(b.cost * scale);
// 计算每个客户当月收入金额
const rawList = baseCustomers.map((c) => ({
name: c.name,
amount: Math.round(income * c.ratio),
}));
// 按从高到低排序
rawList.sort((x, y) => y.amount - x.amount);
// 截取 TOP9
const top9 = rawList.slice(0, 9);
const restList = rawList.slice(9);
const restAmount = restList.reduce((sum, item) => sum + item.amount, 0);
// 计算成本支出结构细项 (包氢、我司承担、物流、运维异动、运维调拨)
const costDetails = [
{ label: '包氢项目', amount: Math.round(cost * 0.36) },
{ label: '我司承担', amount: Math.round(cost * 0.32) },
{ label: '物流成本', amount: Math.round(cost * 0.18) },
{ label: '运维异动', amount: Math.round(cost * 0.08) },
{ label: '运维调拨', amount: Math.round(cost * 0.06) },
];
return {
m: b.m,
income,
cost,
top9,
restAmount,
restCount: restList.length,
costDetails,
};
});
}, [year, fleetScope, verifyScope, overview]);
const maxRevenueVal = useMemo(() => {
return Math.max(...monthlyRevenueData.flatMap((d) => [d.income, d.cost]), 1);
}, [monthlyRevenueData]);
// Top5 站列表 (带内部 vs 外部堆积;跟随车辆/核对筛选)
const topStations = useMemo(() => {
if (overview) {
const rows = overview.topStations.slice(0, 5).map((item: any, index: number) => ({
rank: index + 1,
name: item.name,
ownKg: Number(item.lingniuKg) || 0,
extKg: Number(item.externalKg) || 0,
val: Number(item.kg) || 0,
}));
const maxVal = Math.max(...rows.map((item: any) => item.val), 1);
return rows.map((item: any) => ({ ...item, pct: Math.round(item.val / maxVal * 100) }));
}
const ownVerifyFactor = verifyScope === 'verified' ? 0.75 : 1;
const extVerifyFactor = verifyScope === 'verified' ? 0 : 1;
const raw = [
{ rank: 1, name: '嘉兴中石化滨海加氢站', ownKg: 163250, extKg: 80411 },
{ rank: 2, name: '嘉兴嘉锦加氢站', ownKg: 128020, extKg: 54869 },
{ rank: 3, name: '嘉兴嘉燃加氢站', ownKg: 18350, extKg: 9884 },
{ rank: 4, name: '桐乡中石化绿能加氢站', ownKg: 15648, extKg: 10432 },
{ rank: 5, name: '成都中石化天府机场北站', ownKg: 16050, extKg: 6879 },
];
return raw
.map((st) => {
let ownKg = Math.round(st.ownKg * ownVerifyFactor);
let extKg = Math.round(st.extKg * extVerifyFactor);
if (fleetScope === 'own') extKg = 0;
if (fleetScope === 'external') ownKg = 0;
const val = ownKg + extKg;
return { ...st, ownKg, extKg, val, pct: 100 };
})
.map((st, _, arr) => {
const maxVal = Math.max(...arr.map((x) => x.val), 1);
return { ...st, pct: Math.round((st.val / maxVal) * 100) };
});
}, [fleetScope, verifyScope, overview]);
// 区域维度控制: 按市 ('city') | 按省 ('province')
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
// 省份筛选控制: 'all' | '浙江省' | '四川省' | '广东省' | '江苏省' | '湖北省' 等
const [selectedProvince, setSelectedProvince] = useState<string>('all');
const [mobileDetailTab, setMobileDetailTab] = useState<'station' | 'customer'>('station');
const [stationFullscreenOpen, setStationFullscreenOpen] = useState(false);
useEffect(() => {
if (!stationFullscreenOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setStationFullscreenOpen(false);
};
const onFullscreenChange = () => {
if (!document.fullscreenElement) setStationFullscreenOpen(false);
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('fullscreenchange', onFullscreenChange);
document.documentElement.classList.remove('ehb-landscape-session');
screen.orientation?.unlock?.();
};
}, [stationFullscreenOpen]);
const openStationFullscreen = async () => {
setStationFullscreenOpen(true);
document.documentElement.classList.add('ehb-landscape-session');
const root = document.documentElement;
if (root.requestFullscreen && !document.fullscreenElement) {
await root.requestFullscreen().catch(() => undefined);
}
const orientation = screen.orientation as ScreenOrientation & {
lock?: (mode: string) => Promise<void>;
};
if (orientation?.lock) await orientation.lock('landscape').catch(() => undefined);
};
const closeStationFullscreen = () => {
setStationFullscreenOpen(false);
document.documentElement.classList.remove('ehb-landscape-session');
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
};
// 已有加氢站的省份去重列表
const availableProvinces = useMemo(() => {
const list: string[] = ['all'];
(overview?.stations ?? []).forEach((st: any) => {
const province = st.province;
if (province && !list.includes(province)) {
list.push(province);
}
});
return list;
}, [overview]);
// 根据选定省份精准过滤加氢站列表
const filteredStationList = useMemo(() => {
const source = overview
? overview.stations.map((st: any, idx: number) => ({
rank: idx + 1, name: st.name, province: st.province || '未归属',
kgT: ((Number(st.kg) || 0) / 1000).toFixed(2),
kgPct: Number(st.share) || 0,
incomeWan: ((Number(st.customerRevenue) || 0) / 10000).toFixed(2),
incomePct: overview.kpis.customerRevenue
? (Number(st.customerRevenue) || 0) / Number(overview.kpis.customerRevenue) * 100 : 0,
}))
: [];
if (selectedProvince === 'all') return source;
return source.filter((st: any) => st.province === selectedProvince);
}, [selectedProvince, overview]);
// 根据过滤结果计算总站数 (全国 65 站基准,按比例联动)
const stationCountDisplay = useMemo(() => {
if (overview) return `共 ${filteredStationList.length} 站`;
if (selectedProvince === 'all') return '共 65 站';
if (selectedProvince === '浙江省') return '共 28 站';
if (selectedProvince === '广东省') return '共 16 站';
if (selectedProvince === '四川省') return '共 11 站';
if (selectedProvince === '江苏省') return '共 7 站';
return `共 ${filteredStationList.length} 站`;
}, [selectedProvince, filteredStationList, overview]);
// 按市区域占比数据 (规范地级市名称)
const cityRegions = [
{ label: '嘉兴市', pct: '65.2%', color: CHART_BLUE, dashArray: '155 238', dashOffset: '0' },
{ label: '成都市', pct: '7.4%', color: CHART_EXTERNAL, dashArray: '18 238', dashOffset: '-156' },
{ label: '佛山市', pct: '3.7%', color: '#35a889', dashArray: '9 238', dashOffset: '-175' },
{ label: '昆山市', pct: '2.8%', color: '#f09a61', dashArray: '7 238', dashOffset: '-185' },
{ label: '常熟市', pct: '2.2%', color: '#8c7bd6', dashArray: '5 238', dashOffset: '-193' },
{ label: '广州市', pct: '2.1%', color: '#d47c9b', dashArray: '5 238', dashOffset: '-199' },
{ label: '深圳市', pct: '1.9%', color: '#55aebc', dashArray: '4 238', dashOffset: '-205' },
{ label: '无锡市', pct: '1.9%', color: '#8dbd68', dashArray: '4 238', dashOffset: '-210' },
{ label: '其他城市', pct: '12.7%', color: '#9aa7b8', dashArray: '30 238', dashOffset: '-215' },
];
// 按省区域占比数据
const provinceRegions = [
{ label: '浙江省', pct: '73.2%', color: CHART_BLUE, dashArray: '175 238', dashOffset: '0' },
{ label: '四川省', pct: '11.8%', color: CHART_EXTERNAL, dashArray: '28 238', dashOffset: '-176' },
{ label: '广东省', pct: '7.5%', color: '#35a889', dashArray: '18 238', dashOffset: '-205' },
{ label: '江苏省', pct: '5.4%', color: '#f09a61', dashArray: '13 238', dashOffset: '-224' },
{ label: '其他省份', pct: '2.1%', color: '#9aa7b8', dashArray: '5 238', dashOffset: '-238' },
];
const liveCityRegions = overview?.regions?.map((item: any, index: number) => ({
label: item.region || '未归属', pct: `${Number(item.share || 0).toFixed(1)}%`,
kg: Number(item.kg) || 0,
color: [CHART_BLUE, CHART_EXTERNAL, '#35a889', '#f09a61', '#8c7bd6', '#d47c9b', '#55aebc', '#8dbd68', '#9aa7b8'][index % 9],
dashArray: `${Math.max(0, Number(item.share || 0) * 2.38)} 238`, dashOffset: '0',
}));
const liveProvinceRegions = overview ? Object.values(overview.stations.reduce((acc: any, st: any) => {
const label = st.province || '未归属';
acc[label] = acc[label] || { label, kg: 0 };
acc[label].kg += Number(st.kg) || 0;
return acc;
}, {})).sort((a: any, b: any) => b.kg - a.kg).map((item: any, index: number) => {
const share = overview.kpis.totalKg ? item.kg / overview.kpis.totalKg * 100 : 0;
return { label: item.label, kg: item.kg, pct: `${share.toFixed(1)}%`, color: [CHART_BLUE, CHART_EXTERNAL, '#35a889', '#f09a61', '#9aa7b8'][index % 5], dashArray: `${share * 2.38} 238`, dashOffset: '0' };
}) : null;
const uncollapsedRegionBase = regionGranularity === 'province'
? (liveProvinceRegions ?? provinceRegions)
: (liveCityRegions ?? cityRegions);
const regionLimit = regionGranularity === 'province' ? 4 : 8;
const activeRegionBase = overview && uncollapsedRegionBase.length > regionLimit
? [
...uncollapsedRegionBase.slice(0, regionLimit),
{
label: '其他',
kg: uncollapsedRegionBase.slice(regionLimit).reduce((sum: number, item: any) => sum + Number(item.kg || 0), 0),
pct: `${uncollapsedRegionBase.slice(regionLimit).reduce((sum: number, item: any) => sum + parseFloat(item.pct || '0'), 0).toFixed(1)}%`,
color: '#9aa7b8',
dashArray: '0 238',
dashOffset: '0',
},
]
: uncollapsedRegionBase;
let liveDashCursor = 0;
const activeRegions = activeRegionBase.map((region: any) => {
if (!overview) return region;
const segment = Math.max(0, parseFloat(region.pct) * 2.38);
const mapped = { ...region, dashArray: `${segment} 238`, dashOffset: `${-liveDashCursor}` };
liveDashCursor += segment;
return mapped;
});
return (
<div className="ehb-overview-charts">
{/* 1. 月度加氢量趋势柱图 */}
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{year} 年月度加氢量</div>
<div className="ehb-chart-legend-inline">
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq" style={{ background: CHART_BLUE }} />
<span className="ehb-desktop-legend-label">羚牛车辆</span><span className="ehb-mobile-legend-label">羚牛车辆</span>
</span>
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq" style={{ background: CHART_EXTERNAL }} />
<span className="ehb-desktop-legend-label">外部车辆</span><span className="ehb-mobile-legend-label">外部车辆</span>
</span>
<span className="ehb-chart-box-meta">
统计范围:{overviewRangeText} · 单位 Kg
</span>
</div>
</div>
<div className="ehb-mbar-chart">
{monthlyData.map((d) => {
const heightPct = Math.min(100, Math.round((d.totalKg / maxMonthlyKg) * 100));
const ownRatio = d.totalKg > 0 ? Math.round((d.ownKg / d.totalKg) * 100) : 60;
const extRatio = Math.max(0, 100 - ownRatio);
return (
<div
key={d.month}
className="ehb-mbar-col"
onClick={() => onOpenDrill(`${year}${d.month}加氢量`)}
style={{ cursor: 'pointer' }}
title="查看该月各加氢站内部/外部车辆加氢量"
>
{/* 悬浮柱状图时显示羚牛车辆、外部车辆加氢量卡片 */}
<div className="ehb-mbar-tooltip">
<div className="ehb-mbar-tooltip__head">
{year}{d.month}
</div>
<div className="ehb-mbar-tooltip__row">
<span className="ehb-mbar-tooltip__left">
<span className="ehb-mbar-tooltip__dot is-own" />
羚牛车辆
</span>
<span className="ehb-mbar-tooltip__val">
{d.ownKg.toLocaleString('zh-CN')} Kg
</span>
</div>
<div className="ehb-mbar-tooltip__row">
<span className="ehb-mbar-tooltip__left">
<span className="ehb-mbar-tooltip__dot is-ext" />
外部车辆
</span>
<span className="ehb-mbar-tooltip__val">
{d.extKg.toLocaleString('zh-CN')} Kg
</span>
</div>
<div
className="ehb-mbar-tooltip__row"
style={{
marginTop: 2,
paddingTop: 3,
borderTop: '1px dashed rgba(255, 255, 255, 0.15)',
}}
>
<span
className="ehb-mbar-tooltip__left"
style={{ color: '#ffffff', fontWeight: 600 }}
>
月度合计
</span>
<span className="ehb-mbar-tooltip__val" style={{ color: '#73a2ff' }}>
{d.totalKg.toLocaleString('zh-CN')} Kg
</span>
</div>
</div>
<div className="ehb-mbar-val">{(d.totalKg / 1000).toFixed(1)}k</div>
<div
className="ehb-mbar-fill"
style={{
height: `${heightPct}%`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: 'transparent',
borderRadius: '4px 4px 0 0',
}}
>
<div
style={{
height: `${extRatio}%`,
background: CHART_EXTERNAL,
}}
/>
<div
style={{
height: `${ownRatio}%`,
background: CHART_BLUE,
}}
/>
</div>
<div className="ehb-mbar-label">{d.month}</div>
</div>
);
})}
</div>
</div>
{/* 2. 月度收支对比柱图 */}
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{year} 年月度收支对比</div>
<div className="ehb-chart-legend-inline">
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq is-income" />
客户收入
</span>
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq is-cost" />
成本支出
</span>
<span className="ehb-chart-box-meta">
统计范围:{overviewRangeText} · 单位
</span>
</div>
</div>
<div className="ehb-rev-chart">
{monthlyRevenueData.map((d) => {
const incPct = Math.min(100, Math.round((d.income / maxRevenueVal) * 100));
const costPct = Math.min(100, Math.round((d.cost / maxRevenueVal) * 100));
return (
<div key={d.m} className="ehb-rev-col-group">
<div className="ehb-rev-bars">
<div
className="ehb-rev-bar is-cost"
style={{ height: `${costPct}%` }}
onClick={() => onOpenDrill(`${year}${d.m}成本支出`)}
title="查看该月各加氢站成本支出明细"
>
{/* 悬浮成本支出时,显示包氢、物流、运维异动等成本结构明细 */}
<div className="ehb-rev-cost-tooltip">
<div className="ehb-rev-cost-tooltip__head">
<span>{year}{d.m} 成本支出明细</span>
<span style={{ fontSize: 10, opacity: 0.8 }}>成本构成</span>
</div>
<div className="ehb-rev-cost-tooltip__list">
{d.costDetails.map((item) => (
<div key={item.label} className="ehb-rev-cost-tooltip__item">
<span className="ehb-rev-cost-tooltip__tag">
<span className="ehb-rev-cost-tooltip__dot" />
{item.label}
</span>
<span className="ehb-rev-cost-tooltip__val">
¥{item.amount.toLocaleString('zh-CN')}
</span>
</div>
))}
</div>
<div className="ehb-rev-cost-tooltip__foot">
<span style={{ color: '#cbd5e1' }}>成本合计</span>
<span style={{ color: CHART_COST, fontFamily: 'var(--bi-font-mono)' }}>
¥{d.cost.toLocaleString('zh-CN')}
</span>
</div>
</div>
</div>
<div
className="ehb-rev-bar is-income"
style={{ height: `${incPct}%` }}
onClick={() => onOpenDrill(`${year}${d.m}客户收入`)}
title="查看该月各加氢站客户收入明细"
>
{/* 悬浮客户收入时,显示按金额从高到低排列的客户明细 (TOP9 + 其他客户) */}
<div className="ehb-rev-income-tooltip">
<div className="ehb-rev-income-tooltip__head">
<span>{year}{d.m} 客户加氢收入明细</span>
<span style={{ fontSize: 10, opacity: 0.8 }}>金额高→低</span>
</div>
<div className="ehb-rev-income-tooltip__list">
{d.top9.map((item, idx) => (
<div key={item.name} className="ehb-rev-income-tooltip__item">
<span className="ehb-rev-income-tooltip__cust-name" title={item.name}>
{idx + 1}. {item.name}
</span>
<span className="ehb-rev-income-tooltip__cust-val">
¥{item.amount.toLocaleString('zh-CN')}
</span>
</div>
))}
{d.restCount > 0 && (
<div className="ehb-rev-income-tooltip__item">
<span className="ehb-rev-income-tooltip__cust-name is-other">
10. 其他客户 ({d.restCount})
</span>
<span className="ehb-rev-income-tooltip__cust-val">
¥{d.restAmount.toLocaleString('zh-CN')}
</span>
</div>
)}
</div>
<div className="ehb-rev-income-tooltip__foot">
<span style={{ color: '#cbd5e1' }}>收入合计</span>
<span style={{ color: CHART_INCOME, fontFamily: 'var(--bi-font-mono)' }}>
¥{d.income.toLocaleString('zh-CN')}
</span>
</div>
</div>
</div>
</div>
<div className="ehb-rev-label">{d.m}</div>
</div>
);
})}
</div>
</div>
{/* 3 & 4. 下方并排:Top5 站加氢量 + 各区域加氢占比 */}
<div className="ehb-two-charts-row">
{/* Top5 站 */}
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">加氢站加氢量 Top5</div>
<div className="ehb-chart-legend-inline">
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq" style={{ background: CHART_BLUE }} />
<span className="ehb-desktop-legend-label">羚牛车辆</span><span className="ehb-mobile-legend-label">羚牛车辆</span>
</span>
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq" style={{ background: CHART_EXTERNAL }} />
<span className="ehb-desktop-legend-label">外部车辆</span><span className="ehb-mobile-legend-label">外部车辆</span>
</span>
<span className="ehb-chart-box-meta">
统计范围:{overviewRangeText} · 单位 Kg
</span>
</div>
</div>
<div className="ehb-top-stations-list">
{topStations.map((st) => {
const ownRatio = Math.round((st.ownKg / st.val) * 100);
const extRatio = 100 - ownRatio;
return (
<div
key={st.rank}
className="ehb-top-station-item"
onClick={() => onOpenDrill(`加氢站客户量:${st.name}`)}
style={{ cursor: 'pointer' }}
title="查看该加氢站内部/外部车辆加氢总量"
>
<span className={`ehb-top-rank ${st.rank > 2 ? 'is-sub' : ''}`}>{st.rank}</span>
<span className="ehb-top-station-name" title={st.name}>
{st.name}
</span>
<div className="ehb-top-bar-bg">
{/* 精细高保真 Hover 悬浮弹出卡片 */}
<div className="ehb-top-bar-tooltip">
<div className="ehb-top-bar-tooltip__head">{st.name}</div>
<div className="ehb-top-bar-tooltip__row">
<span className="ehb-top-bar-tooltip__left">
<span className="ehb-top-bar-tooltip__dot is-own" />
羚牛车辆
</span>
<span className="ehb-top-bar-tooltip__val">
{st.ownKg.toLocaleString('zh-CN')} Kg <span style={{ fontSize: 10, opacity: 0.7, fontWeight: 400 }}>({ownRatio}%)</span>
</span>
</div>
<div className="ehb-top-bar-tooltip__row">
<span className="ehb-top-bar-tooltip__left">
<span className="ehb-top-bar-tooltip__dot is-ext" />
外部车辆
</span>
<span className="ehb-top-bar-tooltip__val">
{st.extKg.toLocaleString('zh-CN')} Kg <span style={{ fontSize: 10, opacity: 0.7, fontWeight: 400 }}>({extRatio}%)</span>
</span>
</div>
<div className="ehb-top-bar-tooltip__foot">
<span>加氢总量</span>
<span style={{ color: '#73a2ff', fontFamily: 'var(--bi-font-mono)' }}>
{st.val.toLocaleString('zh-CN')} Kg
</span>
</div>
</div>
<div className="ehb-top-bar-fill" style={{ width: `${st.pct}%` }}>
<div className="ehb-top-bar-seg is-own" style={{ width: `${ownRatio}%` }} />
<div className="ehb-top-bar-seg is-ext" style={{ width: `${extRatio}%` }} />
</div>
</div>
<span className="ehb-top-station-val">{st.val.toLocaleString('zh-CN')}</span>
</div>
);
})}
</div>
</div>
{/* 各区域加氢占比 (支持按省 / 按市快速切换) */}
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">各区域加氢占比</div>
<div className="ehb-mini-tabs">
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
onClick={() => setRegionGranularity('province')}
>
按省
</button>
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className={`ehb-mini-tab ${regionGranularity === 'city' ? 'is-active' : ''}`}
onClick={() => setRegionGranularity('city')}
>
按城市
</button>
</div>
</div>
<div className="ehb-donut-section">
<div className="ehb-donut-chart-wrap">
<svg width="130" height="130" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="38" fill="none" stroke="#f1f5f9" strokeWidth="16" />
{activeRegions.map((reg) => (
<circle
key={reg.label}
cx="50"
cy="50"
r="38"
fill="none"
stroke={reg.color}
strokeWidth="16"
strokeDasharray={reg.dashArray}
strokeDashoffset={reg.dashOffset}
style={{ transition: 'all 0.3s ease', cursor: 'pointer' }}
onClick={() =>
onOpenDrill(
`区域${regionGranularity === 'city' ? '市' : '省'}${reg.label}`,
)
}
>
<title>{`查看${reg.label}各加氢站加氢总量与占比`}</title>
</circle>
))}
</svg>
<div className="ehb-donut-center-text">
<div className="title">年合计</div>
<div className="val">{overview ? `${(Number(overview.kpis.totalKg || 0) / 1000).toFixed(2)}T` : '697.17T'}</div>
</div>
</div>
<div className="ehb-region-legend-grid">
{activeRegions.map((reg) => (
<div
key={reg.label}
className="ehb-region-legend-item"
onClick={() =>
onOpenDrill(`区域${regionGranularity === 'city' ? '市' : '省'}${reg.label}`)
}
style={{ cursor: 'pointer' }}
title={`查看${reg.label}各加氢站加氢总量与占比`}
>
<div className="ehb-region-legend-left">
<span className="ehb-region-dot" style={{ background: reg.color }} />
<span>{reg.label}</span>
</div>
<span className="ehb-region-legend-val">{reg.pct}</span>
</div>
))}
</div>
</div>
</div>
</div>
<section className="ehb-mobile-detail-tabs-card" data-mobile-fullscreen-list>
<header className="ehb-mobile-detail-tabs-head">
<div className="ehb-mobile-detail-tabs-title">数据明细</div>
<MobileListFullscreenButton label="横屏全屏查看数据明细" />
<div className="ehb-mobile-detail-tabs" role="tablist" aria-label="数据明细类型">
<button
type="button"
role="tab"
style={ACCESSIBLE_CONTROL_STYLE}
aria-selected={mobileDetailTab === 'station'}
className={mobileDetailTab === 'station' ? 'is-active' : ''}
onClick={() => setMobileDetailTab('station')}
>
加氢站 {overview?.stations?.length ?? 65}
</button>
<button
type="button"
role="tab"
style={ACCESSIBLE_CONTROL_STYLE}
aria-selected={mobileDetailTab === 'customer'}
className={mobileDetailTab === 'customer' ? 'is-active' : ''}
onClick={() => setMobileDetailTab('customer')}
>
客户费用 {overview?.customers?.length ?? 30}
</button>
</div>
</header>
{/* 5. 趋势图下方:加氢站加氢汇总表 (支持区域按省筛选切换) */}
<div className={`ehb-sum-table-card ehb-station-summary-card ehb-mobile-detail-panel ${mobileDetailTab === 'station' ? 'is-active' : ''}`}>
<div className="ehb-sum-table-card__head" style={{ flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div className="ehb-sum-table-card__title">加氢站加氢汇总</div>
{/* 区域省份切换控制 (仅展示已有加氢站的省份) */}
<div className="ehb-mini-tabs">
{availableProvinces.map((prov) => (
<button
key={prov}
type="button"
className={`ehb-mini-tab ${selectedProvince === prov ? 'is-active' : ''}`}
onClick={() => setSelectedProvince(prov)}
>
<span className="ehb-province-label--desktop">{prov === 'all' ? '全国' : prov}</span>
<span className="ehb-province-label--mobile">{mobileProvinceLabel(prov)}</span>
</button>
))}
</div>
</div>
<button
type="button"
className="ehb-station-fullscreen-trigger"
aria-label="横屏全屏查看加氢站汇总"
onClick={openStationFullscreen}
>
<span>横屏查看</span>
<Maximize2 size={17} aria-hidden />
</button>
<div className="ehb-sum-table-card__meta">
统计范围:{overviewRangeText} · {stationCountDisplay}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}>加氢站(查看明细)</th>
<th style={{ textAlign: 'left' }}>所属省份</th>
<th style={{ textAlign: 'right' }}>加氢量</th>
<th style={{ textAlign: 'right' }}>占比</th>
<th style={{ textAlign: 'right' }}>氢费收入</th>
<th style={{ textAlign: 'right' }}>收入占比</th>
</tr>
</thead>
<tbody>
{filteredStationList.map((st, idx) => (
<tr
key={st.name}
onClick={() => onOpenStationBill(st.name, st.province)}
style={{ cursor: 'pointer' }}
title="查看加氢量、占比、氢费收入和收入占比"
>
<td className="col-idx">{idx + 1}</td>
<td className="ehb-station-name">
<span className="ehb-entity-name">{st.name}</span>{' '}
<span className="ehb-entity-action ehb-station-view">查看 </span>
</td>
<td>
<span className="ehb-station-province">
{st.province}
</span>
</td>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{st.kgT} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>T</span>
</td>
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div
className="ehb-mini-bar-fill is-blue"
style={{ width: `${Math.min(100, st.kgPct * 2.5)}%` }}
/>
</div>
<span className="ehb-ratio-text">{st.kgPct.toFixed(1)}%</span>
</div>
</td>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{st.incomeWan} <span style={{ fontSize: 11, fontWeight: 400 }}>万元</span>
</td>
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div
className="ehb-mini-bar-fill is-green"
style={{ width: `${Math.min(100, st.incomePct * 5)}%` }}
/>
</div>
<span className="ehb-ratio-text">{st.incomePct.toFixed(1)}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 6. 趋势图下方:客户费用汇总表 (Top 30) */}
<div className={`ehb-sum-table-card ehb-mobile-detail-panel ${mobileDetailTab === 'customer' ? 'is-active' : ''}`}>
<div className="ehb-sum-table-card__head">
<div className="ehb-sum-table-card__title">
客户费用汇总
</div>
<MobileListFullscreenButton label="横屏全屏查看客户费用汇总" />
<div className="ehb-sum-table-card__meta">
统计范围:{overviewRangeText} · {overview?.customers?.length ?? 30}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}>客户(查看明细)</th>
<th style={{ textAlign: 'center' }}>承担方</th>
<th style={{ textAlign: 'right' }}>加氢量</th>
<th style={{ textAlign: 'right' }}>成本支出</th>
<th style={{ textAlign: 'right' }}>应收</th>
</tr>
</thead>
<tbody>
{(overview ? overview.customers.map((item: any, index: number) => ({
rank: index + 1, name: item.name,
bearer: item.bearer === 'company' ? 'lingniu' : 'cust',
kgT: ((Number(item.kg) || 0) / 1000).toFixed(2),
costWan: ((Number(item.cost) || 0) / 10000).toFixed(2),
receivable: ${((Number(item.customerRevenue) || 0) / 10000).toFixed(2)} 万元`,
})) : []).map((cust: any) => (
<tr
key={cust.rank}
onClick={() => onOpenCustomerBill(cust.name)}
style={{ cursor: 'pointer' }}
title="查看承担方、加氢量、成本支出与收款明细"
>
<td className="col-idx">{cust.rank}</td>
<td>
<span className="ehb-entity-name">{cust.name}</span>{' '}
<span className="ehb-entity-action">查看 </span>
</td>
<td style={{ textAlign: 'center' }}>
{renderBorneTag(summaryBorneBy(cust.bearer, cust.name))}
</td>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{cust.kgT} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>T</span>
</td>
<td style={{ textAlign: 'right' }} className="col-orange-cost">
¥{cust.costWan} <span style={{ fontSize: 11, fontWeight: 400 }}>万元</span>
</td>
<td style={{ textAlign: 'right' }} className="col-green-fee">
{cust.receivable}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{stationFullscreenOpen ? (
<div className="ehb-station-fullscreen" role="dialog" aria-modal="true" aria-label="横屏加氢站加氢汇总">
<section className="ehb-station-fullscreen__panel">
<header className="ehb-station-fullscreen__head">
<div>
<strong>加氢站加氢汇总</strong>
<span>统计范围:{overviewRangeText} · {stationCountDisplay}</span>
</div>
<button type="button" onClick={closeStationFullscreen} aria-label="关闭横屏全屏列表"><X size={20} aria-hidden /></button>
</header>
<div className="ehb-station-fullscreen__filters" aria-label="省份筛选">
{availableProvinces.map((prov) => (
<button key={prov} type="button" className={selectedProvince === prov ? 'is-active' : ''} onClick={() => setSelectedProvince(prov)}>
<span className="ehb-province-label--desktop">{prov === 'all' ? '全国' : prov}</span>
<span className="ehb-province-label--mobile">{mobileProvinceLabel(prov)}</span>
</button>
))}
</div>
<div className="ehb-station-fullscreen__table-wrap">
<table>
<thead><tr><th>#</th><th>加氢站</th><th>所属省份</th><th>加氢量</th><th>占比</th><th>氢费收入</th><th>收入占比</th><th>操作</th></tr></thead>
<tbody>
{filteredStationList.map((st, idx) => (
<tr key={st.name}>
<td>{idx + 1}</td>
<td>{st.name}</td>
<td>{st.province}</td>
<td>{st.kgT} T</td>
<td>{st.kgPct.toFixed(1)}%</td>
<td>¥{st.incomeWan} 万元</td>
<td>{st.incomePct.toFixed(1)}%</td>
<td><button type="button" onClick={() => onOpenStationBill(st.name, st.province)}>查看 </button></td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
) : null}
</div>
);
}
function RegionRemainderModal({
kind,
items,
onSelect,
onClose,
}: {
kind: '市' | '省';
items: Array<{ label: string; kg: number; share: number }>;
onSelect: (label: string) => void;
onClose: () => void;
}) {
const totalKg = items.reduce((sum, item) => sum + item.kg, 0);
return (
<div className="ehb-modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="ehb-modal-card ehb-drill-modal--unified" onClick={(event) => event.stopPropagation()}>
<div className="ehb-modal-head">
<div className="ehb-modal-head__title-group">
<button type="button" className="ehb-modal-back-btn" onClick={onClose} title="返回">
<ChevronLeft size={18} /><span>返回</span>
</button>
<div>
<div className="ehb-modal-head__title">其他{kind === '市' ? '城市' : '省份'}明细</div>
<div className="ehb-modal-head__sub">点击区域继续查看其加氢站明细</div>
</div>
</div>
<button type="button" className="ehb-modal-close-btn" onClick={onClose} title="关闭"><X size={18} /></button>
</div>
<div className="ehb-modal-body">
<div className="ehb-modal-meta-bar">
<div className="ehb-modal-meta-item"><span className="ehb-modal-meta-label">归并区域</span><span className="ehb-modal-meta-val">{items.length} </span></div>
<div className="ehb-modal-meta-item"><span className="ehb-modal-meta-label">归并加氢量</span><span className="ehb-modal-meta-val">{(totalKg / 1000).toFixed(2)} T</span></div>
</div>
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table">
<thead><tr><th>区域</th><th style={{ textAlign: 'right' }}>加氢量 (Kg)</th><th style={{ textAlign: 'right' }}>全局占比</th><th>操作</th></tr></thead>
<tbody>
{items.map((item) => (
<tr key={item.label} onClick={() => onSelect(item.label)} style={{ cursor: 'pointer' }} title={`查看${item.label}加氢站明细`}>
<td><strong>{item.label}</strong></td>
<td style={{ textAlign: 'right' }}>{item.kg.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td style={{ textAlign: 'right' }}>{item.share.toFixed(1)}%</td>
<td><span className="ehb-kpi-drill-hint">继续下钻 </span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
/**
* 嵌入 bi-next `#hydrogen/overview`
* - 壳 / KPI / 洞察对齐宿主 zip 视觉
* - 独立功能块:三维度 + 站月/客户汇总 + 订单明细 · 禁用 OneOS V2
*/
export const EnergyBiBoardApp: React.FC = () => {
const [boardScope, setBoardScope] = useState<BoardScope>('global');
const [hostView, setHostView] = useState<HostView>('overview');
const [year, setYear] = useState(DEFAULT_YEAR);
const [fleetScope, setFleetScope] = useState<FleetScope>('all');
const [verifyScope, setVerifyScope] = useState<'all' | 'verified'>('all');
const [filtersOpen, setFiltersOpen] = useState(false);
const [dimFilter, setDimFilter] = useState<DimFilter>(null);
const [statsTab, setStatsTab] = useState<StatsTab>('siteMonth');
const [stationId, setStationId] = useState<string | null>(null);
const [stationLabel, setStationLabel] = useState<string | null>(null);
const [customerId, setCustomerId] = useState<string | null>(null);
const [customerLabel, setCustomerLabel] = useState<string | null>(null);
const [liveOverview, setLiveOverview] = useState<any>(null);
const [livePendingOverview, setLivePendingOverview] = useState<any>(null);
const [liveMeta, setLiveMeta] = useState<any>(null);
const [liveError, setLiveError] = useState<string | null>(null);
const [liveLoading, setLiveLoading] = useState(true);
const [liveReloadToken, setLiveReloadToken] = useState(0);
// KPI 点击下钻 Modal 状态
const [kpiDrillType, setKpiDrillType] = useState<string | null>(null);
// 头部加氢站占比 → 加氢量排名下拉
const [stationRankOpen, setStationRankOpen] = useState(false);
const stationRankRef = useRef<HTMLDivElement>(null);
// 客户账单专属下钻 Modal 状态 (客户 → 日期 → 车牌加氢记录)
const [selectedBillCustomer, setSelectedBillCustomer] = useState<string | null>(null);
// 加氢站账单专属下钻 Modal 状态(按日经营汇总 → 单笔加氢明细)
const [selectedStationForDrill, setSelectedStationForDrill] = useState<{ name: string; province: string } | null>(null);
// 按日视角日期区间状态 (提升至顶层供标题旁时间范围联动)
const localIsoDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const [dailyEndDate, setDailyEndDate] = useState(() => localIsoDate(new Date()));
const [dailyStartDate, setDailyStartDate] = useState(() => {
const date = new Date(); date.setDate(date.getDate() - 14); return localIsoDate(date);
});
const [dailyRangePreset, setDailyRangePreset] = useState<DailyRangePreset>('15days');
const [dailyFleetType, setDailyFleetType] = useState<FleetCategoryFilter>('all');
const [dailyReloadToken, setDailyReloadToken] = useState(0);
const [dailyRefreshing, setDailyRefreshing] = useState(false);
useEffect(() => {
let active = true;
setLiveLoading(true);
setLiveError(null);
setLiveMeta(null);
setLiveOverview(null);
setLivePendingOverview(null);
const vehicleScope = fleetScope === 'own' ? 'lingniu' : fleetScope;
Promise.all([
fetchH2BiMeta(),
fetchH2BiOverview({ year, vehicleScope, verifyScope, regionGranularity: 'city' }),
fetchH2BiOverview({ year, vehicleScope, verifyScope: 'unverified', regionGranularity: 'city' }),
]).then(([meta, overview, pendingOverview]) => {
if (!active) return;
setLiveMeta(meta);
setLiveOverview(overview);
setLivePendingOverview(pendingOverview);
setLiveLoading(false);
}).catch((error) => {
if (!active) return;
setLiveMeta(null);
setLiveOverview(null);
setLivePendingOverview(null);
setLiveError(error instanceof Error ? error.message : String(error));
setLiveLoading(false);
});
return () => { active = false; };
}, [year, fleetScope, verifyScope, liveReloadToken]);
const handleDailyPresetChange = (preset: DailyRangePreset) => {
setDailyRangePreset(preset);
const end = new Date();
const start = new Date(end);
if (preset === 'week') {
const weekday = (end.getDay() + 6) % 7;
start.setDate(end.getDate() - weekday);
} else if (preset === 'month') {
start.setDate(1);
} else if (preset === '15days') {
start.setDate(end.getDate() - 14);
} else {
return;
}
setDailyStartDate(localIsoDate(start));
setDailyEndDate(localIsoDate(end));
};
// 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管)
const timeRangeLabel = '统计时间范围';
const timeRangeText = useMemo(() => {
// 单站内容由 StationDailyApp 使用 dailyStartDate/dailyEndDate 驱动;不能沿用全局年度 overview 范围。
if (boardScope === 'station' || hostView === 'daily') {
return `${dailyStartDate}${dailyEndDate}`;
}
if (liveOverview?.range?.startDate && liveOverview?.range?.endDate) {
return `${liveOverview.range.startDate}${liveOverview.range.endDate}`;
}
return `${year}-01-01 至 ${year}-12-31`;
}, [boardScope, hostView, dailyStartDate, dailyEndDate, year, liveOverview]);
const mobileView: HostView = boardScope === 'station' ? 'daily' : hostView;
const activeMobileFleet = mobileView === 'daily' ? dailyFleetType : fleetScope;
const mobileFilterCount = mobileView === 'daily'
? (dailyRangePreset === 'custom' ? 1 : 0) + (dailyFleetType === 'all' ? 0 : 1)
: (verifyScope === 'verified' ? 1 : 0) + (fleetScope === 'all' ? 0 : 1);
const handleMobileViewChange = (nextView: HostView) => {
if (boardScope === 'station' && nextView === 'overview') {
setBoardScope('global');
setHostView('overview');
} else {
setHostView(nextView);
}
setFiltersOpen(false);
};
const clearEntity = () => {
setStationId(null);
setStationLabel(null);
setCustomerId(null);
setCustomerLabel(null);
};
const rows = useMemo(
() => filterOrders(MOCK_ORDERS, year, verifyScope, fleetScope),
[year, verifyScope, fleetScope],
);
const hostKpi = useMemo(() => {
if (!liveOverview) return computeHostKpi(rows, year, MOCK_ORDERS, HOST_KPI);
const k = liveOverview.kpis;
return {
totalKgT: Number((k.totalKg / 1000).toFixed(2)),
companyKgT: Number((k.companyBearingKg / 1000).toFixed(2)),
customerKgT: Number((k.customerBearingKg / 1000).toFixed(2)),
pendingKgT: Number((k.otherBearingKg / 1000).toFixed(2)),
totalFeeWan: Number((k.totalCost / 10000).toFixed(2)),
companyFeeWan: Number((k.companyCost / 10000).toFixed(2)),
customerFeeWan: Number((k.customerCost / 10000).toFixed(2)),
pendingFeeWan: Number((k.otherCost / 10000).toFixed(2)),
profitWan: Number((k.customerGrossProfit / 10000).toFixed(2)),
incomeWan: Number((k.customerRevenue / 10000).toFixed(2)),
costWan: Number((k.customerCost / 10000).toFixed(2)),
monthKgT: Number((k.monthKg / 1000).toFixed(2)),
monthFeeWan: Number((k.monthCost / 10000).toFixed(2)),
monthYearPct: Number(k.monthShareOfRange || 0).toFixed(2),
dayKg: Number(k.todayKg || 0),
dayFee: Number(k.todayCost || 0),
dayMonthPct: Number(k.todayShareOfMonth || 0).toFixed(2),
};
}, [rows, year, liveOverview]);
const totalKgForShare = hostKpi.totalKgT || 1;
const bearerShares = {
company: Number(((hostKpi.companyKgT / totalKgForShare) * 100).toFixed(2)),
customer: Number(((hostKpi.customerKgT / totalKgForShare) * 100).toFixed(2)),
pending: Number(((hostKpi.pendingKgT / totalKgForShare) * 100).toFixed(2)),
};
// 加氢站加氢量排名(高→低),跟随年份/车辆/核对筛选
const stationRankList = useMemo(() => {
if (liveOverview) {
const list = liveOverview.stations.map((st: any) => ({
name: st.name, province: st.province || '未归属', kg: Number(st.kg) || 0,
})).filter((st: any) => st.kg > 0).sort((a: any, b: any) => b.kg - a.kg);
const maxKg = list[0]?.kg || 1;
const totalKg = list.reduce((sum: number, item: any) => sum + item.kg, 0) || 1;
return list.map((st: any, index: number) => ({ ...st, rank: index + 1, barPct: Math.round(st.kg / maxKg * 100), sharePct: Math.round(st.kg / totalKg * 1000) / 10 }));
}
// 没有真实数据时返回空列表:不再用原型演示数据按年份/归属系数编造排名。
return [];
}, [liveOverview]);
const top5SharePct = useMemo(() => {
const top5 = stationRankList.slice(0, 5).reduce((s, x) => s + x.kg, 0);
const total = stationRankList.reduce((s, x) => s + x.kg, 0) || 1;
return Math.round((top5 / total) * 1000) / 10;
}, [stationRankList]);
useEffect(() => {
if (!stationRankOpen) return;
function onDoc(e: MouseEvent) {
if (stationRankRef.current && !stationRankRef.current.contains(e.target as Node)) {
setStationRankOpen(false);
}
}
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [stationRankOpen]);
const dims = useMemo(() => costDimCards(rows), [rows]);
const risk = livePendingOverview
? {
count: Number(livePendingOverview.kpis.recordCount || 0),
amount: Number(livePendingOverview.kpis.totalCost || 0),
}
: { count: 0, amount: 0 };
const unitProfitYuan = hostKpi.totalKgT > 0
? ((hostKpi.profitWan * 10000) / (hostKpi.totalKgT * 1000)).toFixed(2)
: '—';
const unverifiedWan = (risk.amount / 10000).toFixed(2);
const liveMonthComparison = useMemo(() => {
if (!liveOverview?.monthly?.length) return null;
let points = liveOverview.monthly.filter((item: any) => Number(item.totalKg) > 0);
const endMonth = String(liveOverview.range?.endDate || '').slice(0, 7);
if (points.length > 2 && points[points.length - 1]?.month === endMonth) points = points.slice(0, -1);
const current = points[points.length - 1];
const previous = points[points.length - 2];
if (!current || !previous || !Number(previous.totalKg)) return null;
const value = (Number(current.totalKg) - Number(previous.totalKg)) / Number(previous.totalKg) * 100;
return {
value,
label: `${Number(String(current.month).slice(-2))}月较${Number(String(previous.month).slice(-2))}月`,
};
}, [liveOverview]);
const companyScoped = useMemo(
() => companyRowsForStats(rows, dimFilter),
[rows, dimFilter],
);
/** 客户归属表:无维度筛时看全量归属;有维度筛时只看对应我司成本行 */
const customerSource = useMemo(() => {
if (!dimFilter) return rows;
return companyScoped;
}, [rows, dimFilter, companyScoped]);
const siteMonthRows = useMemo(() => stationMonthAgg(companyScoped), [companyScoped]);
const customerRows = useMemo(() => customerAttrAgg(customerSource), [customerSource]);
const detailRows = useMemo(() => {
let list = companyScoped;
if (stationId) list = list.filter((r) => r.stationId === stationId);
if (customerId) list = list.filter((r) => r.customerId === customerId);
return list;
}, [companyScoped, stationId, customerId]);
const remainderRegions = useMemo(() => {
if (!liveOverview) return { city: [], province: [] };
const city = (liveOverview.regions ?? [])
.map((item: any) => ({
label: String(item.region || '未归属'),
kg: Number(item.kg) || 0,
share: Number(item.share) || 0,
}))
.sort((left: any, right: any) => right.kg - left.kg)
.slice(8);
const totalKg = Number(liveOverview.kpis?.totalKg) || 0;
const provinceMap = new Map<string, number>();
(liveOverview.stations ?? []).forEach((station: any) => {
const label = String(station.province || '未归属');
provinceMap.set(label, (provinceMap.get(label) || 0) + (Number(station.kg) || 0));
});
const province = [...provinceMap.entries()]
.map(([label, kg]) => ({ label, kg, share: totalKg ? kg / totalKg * 100 : 0 }))
.sort((left, right) => right.kg - left.kg)
.slice(4);
return { city, province };
}, [liveOverview]);
const externalEmpty = fleetScope === 'external' && rows.length === 0;
const [updatedAt, setUpdatedAt] = useState('—');
useEffect(() => {
if (liveOverview?.watermark?.ledgerAt) setUpdatedAt(liveOverview.watermark.ledgerAt);
}, [liveOverview]);
useEffect(() => {
if (boardScope === 'station' && !stationId && liveOverview?.stations?.length) {
setStationId(String(liveOverview.stations[0].id));
setStationLabel(liveOverview.stations[0].name);
}
}, [boardScope, liveOverview, stationId]);
const handleRefreshData = () => {
if (mobileView === 'daily') {
setDailyRefreshing(true);
setDailyReloadToken((value) => value + 1);
return;
}
setLiveReloadToken((value) => value + 1);
};
if (liveLoading || liveError || !liveOverview) {
return (
<div className="ehb-shell" data-annotation-id="energy-h2-bi-board">
<div className="ehb-body">
{liveError ? (
<div className="ehb-api-error-overlay" role="presentation">
<section className="ehb-api-error-dialog" role="alertdialog" aria-modal="true" aria-labelledby="ehb-api-error-title">
<span className="ehb-api-error-icon" aria-hidden><AlertTriangle size={30} /></span>
<div className="ehb-api-error-copy">
<strong id="ehb-api-error-title">数据服务暂时不可用</strong>
<p>后端接口请求失败,本页已停止展示业务数据,避免将缓存值或模拟值误认为真实结果。</p>
<code>{liveError}</code>
</div>
<button type="button" className="ehb-api-error-retry" onClick={handleRefreshData}>
<RefreshCw size={17} aria-hidden />
重新加载
</button>
</section>
</div>
) : (
<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>
)}
</div>
</div>
);
}
return (
<div className="ehb-shell" data-annotation-id="energy-h2-bi-board">
<aside className="ehb-rail" aria-label="能源BI模块" aria-hidden="true" inert>
<button type="button" className="ehb-rail__item is-active" title="氢能" tabIndex={-1}>
<Fuel size={20} aria-hidden />
氢能
</button>
<button type="button" className="ehb-rail__item" disabled title="电能(宿主)" tabIndex={-1}>
<Zap size={20} aria-hidden />
电能
</button>
<button type="button" className="ehb-rail__item" disabled title="ETC(宿主)" tabIndex={-1}>
<Wallet size={20} aria-hidden />
ETC
</button>
</aside>
<div className="ehb-body">
{liveLoading ? (
<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}
{liveError ? <div className="ehb-live-data-state is-error">统计数据加载失败:{liveError}</div> : null}
<header className="ehb-chrome">
<div className="ehb-chrome__lead">
<span className="ehb-mobile-brand-icon" aria-hidden><Fuel size={20} /></span>
<div className="ehb-chrome__identity">
<div className="ehb-crumb">羚牛氢能 BI / 氢能</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 2, flexWrap: 'wrap' }}>
<h1 style={{ margin: 0 }}>氢能经营看板</h1>
<span className="ehb-desktop-live-badge">实时运营</span>
{boardScope === 'global' ? (
<span
className="ehb-time-range-pill"
style={{
fontSize: 12,
color: '#2f6bff',
background: '#eff6ff',
border: '1px solid #bae6fd',
padding: '3px 10px',
borderRadius: 16,
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
boxShadow: '0 1px 2px rgba(2, 132, 199, 0.06)',
}}
>
📅 {timeRangeLabel}{timeRangeText}
</span>
) : null}
</div>
<div className="ehb-desktop-time-range">统计时间范围:{timeRangeText}</div>
<div className="ehb-mobile-updated">数据更新:{updatedAt}</div>
</div>
<button
type="button"
className="ehb-mobile-context-switch is-scope"
aria-label={boardScope === 'global' ? '切换到单站' : '切换到全局'}
onClick={() => setBoardScope(boardScope === 'global' ? 'station' : 'global')}
>
<span aria-hidden></span>{boardScope === 'global' ? '全局' : '单站'}
<ChevronsUpDown size={14} aria-hidden />
</button>
</div>
<div className="ehb-chrome__tools">
<div className="ehb-scope-switches">
<span className="ehb-switch-label">范围</span>
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="范围">
<button
type="button"
role="tab"
style={ACCESSIBLE_CONTROL_STYLE}
className={boardScope === 'global' ? 'is-active' : ''}
aria-selected={boardScope === 'global'}
onClick={() => setBoardScope('global')}
>
全局网络
</button>
<button
type="button"
role="tab"
style={ACCESSIBLE_CONTROL_STYLE}
className={boardScope === 'station' ? 'is-active' : ''}
aria-selected={boardScope === 'station'}
onClick={() => setBoardScope('station')}
>
单站视角
</button>
</div>
</div>
{boardScope === 'global' ? (
<div className="ehb-view-switches">
<span className="ehb-switch-label">全局视图</span>
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="视图">
<button
type="button"
role="tab"
style={ACCESSIBLE_CONTROL_STYLE}
className={hostView === 'overview' ? 'is-active' : ''}
aria-selected={hostView === 'overview'}
onClick={() => setHostView('overview')}
>
经营总览
</button>
<button
type="button"
role="tab"
style={ACCESSIBLE_CONTROL_STYLE}
className={hostView === 'daily' ? 'is-active' : ''}
aria-selected={hostView === 'daily'}
onClick={() => setHostView('daily')}
>
日期
</button>
</div>
</div>
) : null}
</div>
<div className={`ehb-mobile-filter-panel ${filtersOpen ? 'is-open' : ''}`}>
<div className="ehb-mobile-view-mode">
<span>查看方式</span>
<div role="tablist" aria-label="移动端查看方式">
<button type="button" role="tab" style={ACCESSIBLE_CONTROL_STYLE} aria-selected={mobileView === 'overview'} className={mobileView === 'overview' ? 'is-active' : ''} onClick={() => handleMobileViewChange('overview')}>总览</button>
<button type="button" role="tab" style={ACCESSIBLE_CONTROL_STYLE} aria-selected={mobileView === 'daily'} className={mobileView === 'daily' ? 'is-active' : ''} onClick={() => handleMobileViewChange('daily')}>日期</button>
</div>
</div>
<div className="ehb-mobile-primary-filters">
{boardScope === 'global' ? <BiYearSelect value={year} onChange={(y) => { setYear(y); clearEntity(); }} /> : null}
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-context-switch"
aria-label={boardScope === 'station' ? '返回经营总览' : hostView === 'overview' ? '切换到日期' : '切换到总览'}
onClick={() => {
handleMobileViewChange(boardScope === 'station' ? 'overview' : hostView === 'overview' ? 'daily' : 'overview');
}}
>
<span aria-hidden></span>{boardScope === 'station' ? '总览' : hostView === 'overview' ? '总览' : '日期'}
<ChevronsUpDown size={14} aria-hidden />
</button>
{boardScope === 'global' ? <button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-order-chip"
onClick={() => { setVerifyScope(verifyScope === 'all' ? 'verified' : 'all'); clearEntity(); }}
>
{verifyScope === 'all' ? '全量订单' : '仅已核对'}
</button> : null}
{boardScope === 'global' ? <button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-fleet-chip"
onClick={() => {
const nextFleet = activeMobileFleet === 'all' ? 'own' : activeMobileFleet === 'own' ? 'external' : 'all';
mobileView === 'daily' ? setDailyFleetType(nextFleet) : setFleetScope(nextFleet);
clearEntity();
}}
>
{activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'}
</button> : null}
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-filter-disclosure"
aria-label={filtersOpen ? '收起更多筛选' : '展开更多筛选'}
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((open) => !open)}
>
{mobileFilterCount > 0 ? `筛选 ${mobileFilterCount}` : '筛选'}
</button>
</div>
<div className="ehb-mobile-current-range">
当前范围:{boardScope === 'global' ? '全部站点' : '当前站点'} · {mobileView === 'daily' ? `${dailyStartDate}${dailyEndDate}` : `${activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · ${verifyScope === 'all' ? '全量订单' : '仅已核对订单'}`}
</div>
{mobileView === 'daily' ? (
<div className="ehb-pill-tabs ehb-mobile-daily-presets"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'week' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('week')}>本周</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'month' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('month')}>本月</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === '15days' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('15days')}>15</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'custom' ? 'is-active' : ''}`} onClick={() => { handleDailyPresetChange('custom'); setFiltersOpen(true); }}>自定义</button></div>
) : null}
{filtersOpen ? (
<div className="ehb-mobile-filter-body">
{mobileView === 'overview' ? (
<>
<div className="ehb-mobile-verify-filter"><div className="ehb-mobile-filter-field"><span>订单范围</span><div className="ehb-pill-tabs"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`} onClick={() => { setVerifyScope('all'); clearEntity(); }}>全量订单</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`} onClick={() => { setVerifyScope('verified'); clearEntity(); }}>仅已核对</button></div></div></div>
<div className="ehb-mobile-filter-field"><span>车辆范围</span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${fleetScope === 'all' ? 'is-active' : ''}`} onClick={() => { setFleetScope('all'); clearEntity(); }}>全部车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${fleetScope === 'own' ? 'is-active' : ''}`} onClick={() => { setFleetScope('own'); clearEntity(); }}>羚牛车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${fleetScope === 'external' ? 'is-active' : ''}`} onClick={() => { setFleetScope('external'); clearEntity(); }}>外部车辆</button></div></div>
</>
) : (
<>
<div className="ehb-mobile-date-fields"><BiCustomDatePicker label="开始日期" value={dailyStartDate} onChange={(val) => { setDailyStartDate(val); setDailyRangePreset('custom'); }} /><BiCustomDatePicker label="结束日期" value={dailyEndDate} onChange={(val) => { setDailyEndDate(val); setDailyRangePreset('custom'); }} /></div>
{boardScope === 'global' ? <div className="ehb-mobile-filter-field"><span>车辆范围</span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'all' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('all')}>全部车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'own' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('own')}>羚牛车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'external' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('external')}>外部车辆</button></div></div> : null}
<button type="button" className="ehb-mobile-refresh" onClick={handleRefreshData} disabled={dailyRefreshing} aria-busy={dailyRefreshing}><RefreshCw size={14} className={dailyRefreshing ? 'is-spinning' : ''} aria-hidden />{dailyRefreshing ? '加载中…' : '刷新数据'}</button>
</>
)}
</div>
) : null}
</div>
</header>
{boardScope === 'station' ? (
<StationDailyApp
embedded
startDate={dailyStartDate}
endDate={dailyEndDate}
onStartDateChange={(value: string) => { setDailyStartDate(value); setDailyRangePreset('custom'); }}
onEndDateChange={(value: string) => { setDailyEndDate(value); setDailyRangePreset('custom'); }}
refreshToken={dailyReloadToken}
onLoadingChange={setDailyRefreshing}
/>
) : (
<>
{hostView === 'daily' ? (
<PrototypeRealDailyView
startDate={dailyStartDate}
endDate={dailyEndDate}
onStartDateChange={setDailyStartDate}
onEndDateChange={setDailyEndDate}
fleetScope={dailyFleetType}
onFleetScopeChange={setDailyFleetType}
verifyScope={verifyScope}
onRefresh={handleRefreshData}
refreshToken={dailyReloadToken}
onLoadingChange={setDailyRefreshing}
/>
) : (
<>
{/* 总览视角筛选条 (包含年份选择、核对筛选、车辆归属及刷新,样式与按日视角全面对齐) */}
<button
type="button"
className="ehb-filter-toggle"
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((open) => !open)}
>
{filtersOpen ? '收起筛选' : '查看筛选'}
<ChevronDown size={16} className={filtersOpen ? 'is-open' : ''} aria-hidden />
</button>
<section className={`ehb-daily-filter-card ehb-overview-filter ${filtersOpen ? 'is-open' : ''}`} style={{ marginBottom: 12 }}>
<div className="ehb-daily-filter-row">
<div className="ehb-daily-filter-group">
<BiYearSelect
value={year}
onChange={(y) => {
setYear(y);
clearEntity();
}}
/>
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === 'all' ? 'is-active' : ''}`}
onClick={() => {
setFleetScope('all');
clearEntity();
}}
>
全部车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === 'own' ? 'is-active' : ''}`}
onClick={() => {
setFleetScope('own');
clearEntity();
}}
>
<span className="ehb-filter-dot is-own" aria-hidden />
羚牛车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === 'external' ? 'is-active' : ''}`}
onClick={() => {
setFleetScope('external');
clearEntity();
}}
>
<span className="ehb-filter-dot is-external" aria-hidden />
外部车辆
</button>
</div>
</div>
<div className="ehb-daily-filter-group">
<div className="ehb-pill-tabs">
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
onClick={() => {
setVerifyScope('all');
clearEntity();
}}
>
全量订单
</button>
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
onClick={() => {
setVerifyScope('verified');
clearEntity();
}}
>
仅已核对
</button>
</div>
<span className="ehb-chrome__clock" style={{ fontSize: 12, color: '#64748b' }}>
{updatedAt}
</span>
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={handleRefreshData}
title="数据刷新"
>
<RefreshCw size={14} aria-hidden />
刷新
</button>
</div>
</div>
</section>
<section className="ehb-host" aria-label="经营总览">
<h2 className="ehb-mobile-section-title">核心经营指标</h2>
<section className="ehb-mobile-operating-overview" aria-label="累计经营总览">
<div className="ehb-mobile-operating-overview__head">
<strong>累计经营概览</strong>
<span>{year} 年累计</span>
</div>
<div className="ehb-mobile-operating-overview__metrics">
<button type="button" onClick={() => setKpiDrillType('累计加氢量')}>
<span>累计加氢量</span>
<strong>{hostKpi.totalKgT}<small>T</small></strong>
</button>
<button type="button" onClick={() => setKpiDrillType('累计加氢费')}>
<span>累计加氢费</span>
<strong><small>¥</small>{hostKpi.totalFeeWan}<small></small></strong>
</button>
</div>
<div className="ehb-mobile-operating-overview__bar" aria-label="加氢量承担构成">
<span className="is-company" style={{ width: `${bearerShares.company}%` }} />
<span className="is-customer" style={{ width: `${bearerShares.customer}%` }} />
<span className="is-pending" style={{ width: `${bearerShares.pending}%` }} />
</div>
<div className="ehb-mobile-operating-overview__legend">
<div><span><i className="is-company" />我司</span><strong>{hostKpi.companyKgT} T</strong><small>({bearerShares.company}%)</small></div>
<div><span><i className="is-customer" />客户</span><strong>{hostKpi.customerKgT} T</strong><small>({bearerShares.customer}%)</small></div>
<div><span><i className="is-pending" />待核准</span><strong>{hostKpi.pendingKgT} T</strong><small>({bearerShares.pending}%)</small></div>
</div>
<button type="button" className="ehb-mobile-operating-overview__action" onClick={() => setKpiDrillType('累计加氢量')}>
查看构成 <ChevronRight size={16} aria-hidden />
</button>
</section>
<button type="button" className="ehb-mobile-profit-card" onClick={() => setKpiDrillType('加氢利润')}>
<span className="ehb-mobile-profit-card__icon"><TrendingUp size={22} aria-hidden /></span>
<span className="ehb-mobile-profit-card__result">
<span>加氢利润</span>
<strong><small>¥</small>{hostKpi.profitWan}<small></small></strong>
</span>
<span className="ehb-mobile-profit-card__breakdown">
<span><ReceiptText size={15} aria-hidden /><span>收入<strong>¥{hostKpi.incomeWan} </strong></span></span>
<span><Wallet size={15} aria-hidden /><span>成本<strong>¥{hostKpi.costWan} </strong></span></span>
</span>
</button>
<div className="ehb-mobile-period-cards" aria-label="近期加氢指标">
<button type="button" className="ehb-mobile-period-card" onClick={() => setKpiDrillType('本月加氢')}>
<span className="ehb-mobile-period-card__head"><span>本月加氢量</span><i><Calendar size={17} aria-hidden /></i></span>
<strong>{hostKpi.monthKgT}<small>T</small></strong>
<span className="ehb-mobile-period-card__footer">费用&nbsp; ¥{hostKpi.monthFeeWan}</span>
</button>
<button type="button" className="ehb-mobile-period-card" onClick={() => setKpiDrillType('本日加氢')}>
<span className="ehb-mobile-period-card__head"><span>今日加氢量</span><i><Zap size={17} aria-hidden /></i></span>
<strong>{hostKpi.dayKg}<small>Kg</small></strong>
<span className="ehb-mobile-period-card__footer">费用&nbsp; ¥{hostKpi.dayFee.toLocaleString('zh-CN')}</span>
</button>
</div>
<div className="ehb-metric-grid">
<div className="ehb-host-kpi">
<HostKpi
icon={<Fuel size={14} />}
tone="blue"
label="累计加氢量"
value={hostKpi.totalKgT}
unit="T"
parts={[
{ label: '我司承担', value: `${hostKpi.companyKgT} T` },
{ label: '客户承担', value: `${hostKpi.customerKgT} T` },
{ label: '待核准', value: `${hostKpi.pendingKgT} T` },
]}
onClick={() => setKpiDrillType('累计加氢量')}
/>
<HostKpi
icon={<Wallet size={14} />}
tone="blue"
label="累计加氢费"
prefix="¥"
value={hostKpi.totalFeeWan}
unit="万"
parts={[
{ label: '我司承担', value: ${hostKpi.companyFeeWan} 万` },
{ label: '客户承担', value: ${hostKpi.customerFeeWan} 万` },
{ label: '待核准', value: ${hostKpi.pendingFeeWan} 万` },
]}
onClick={() => setKpiDrillType('累计加氢费')}
/>
<HostKpi
icon={<Activity size={14} />}
tone="green"
label="加氢利润"
prefix="¥"
value={hostKpi.profitWan}
unit="万"
left={`收入 ¥${hostKpi.incomeWan} 万`}
right={`成本 ¥${hostKpi.costWan} 万`}
onClick={() => setKpiDrillType('加氢利润')}
/>
<div className="ehb-recent-kpis" aria-label="近期加氢">
<HostKpi
icon={<Truck size={14} />}
tone="amber"
label="本月加氢量"
value={hostKpi.monthKgT}
unit="T"
left={`加氢费 ¥${hostKpi.monthFeeWan} 万`}
right={`占累计 ${hostKpi.monthYearPct}%`}
onClick={() => setKpiDrillType('本月加氢')}
/>
<HostKpi
icon={<Zap size={14} />}
tone="purple"
label="今日加氢量"
value={hostKpi.dayKg}
unit="Kg"
left={`加氢费 ¥${hostKpi.dayFee.toLocaleString('zh-CN')}`}
right={`占本月 ${hostKpi.dayMonthPct}%`}
onClick={() => setKpiDrillType('本日加氢')}
/>
</div>
</div>
<section className="ehb-diagnosis" aria-label="经营诊断">
<strong className="ehb-diagnosis__heading">经营诊断</strong>
<div className="ehb-diagnosis__item">
<span>月度环比</span>
<strong className={liveMonthComparison && liveMonthComparison.value < 0 ? 'is-warning' : 'is-positive'}>
{liveMonthComparison ? `${liveMonthComparison.value >= 0 ? '+' : ''}${liveMonthComparison.value.toFixed(1)}%` : '—'}
</strong>
<small>{liveMonthComparison?.label ?? '暂无可比月份'}</small>
</div>
<div className="ehb-diagnosis__item">
<span>单公斤毛利</span>
<strong>¥{unitProfitYuan}/kg</strong>
<small>按累计加氢量计算</small>
</div>
<button
type="button"
className={`ehb-diagnosis__item is-action ${stationRankOpen ? 'is-open' : ''}`}
ref={stationRankRef}
onClick={() => setStationRankOpen((v) => !v)}
title="点击查看加氢站加氢量排名"
>
<span>头部站点占比</span>
<strong>{top5SharePct}% <ChevronDown size={13} className={stationRankOpen ? 'is-open' : ''} aria-hidden /></strong>
<small>5站占总量,查看排名</small>
{stationRankOpen && (
<div
className="ehb-station-rank-dropdown"
onClick={(e) => e.stopPropagation()}
role="listbox"
aria-label="加氢站加氢量排名"
>
<div className="ehb-station-rank-dropdown__head">
<span>加氢站加氢量排名</span>
<span className="ehb-station-rank-dropdown__meta">
· {stationRankList.length}
</span>
</div>
<div className="ehb-station-rank-dropdown__list">
{stationRankList.map((st) => (
<button
key={st.name}
type="button"
className="ehb-station-rank-item"
onClick={() => {
setStationRankOpen(false);
setKpiDrillType(`加氢站客户量:${st.name}`);
}}
title="查看该站明细"
>
<span className={`ehb-station-rank-item__rank ${st.rank <= 3 ? 'is-top' : ''}`}>
{st.rank}
</span>
<span className="ehb-station-rank-item__main">
<span className="ehb-station-rank-item__name">{st.name}</span>
<span className="ehb-station-rank-item__bar">
<span style={{ width: `${st.barPct}%` }} />
</span>
</span>
<span className="ehb-station-rank-item__val">
{(st.kg / 1000).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} T
</span>
<span className="ehb-station-rank-item__share">{st.sharePct}%</span>
</button>
))}
{stationRankList.length === 0 && (
<div className="ehb-station-rank-empty">当前筛选下暂无站点数据</div>
)}
</div>
</div>
)}
</button>
<button type="button" className="ehb-diagnosis__item is-action" onClick={() => setKpiDrillType('待核对订单')} title="查看待核对订单">
<span>待核对订单</span>
<strong className={risk.count > 0 ? 'is-warning' : ''}>{risk.count} · ¥{unverifiedWan}</strong>
<small>核对上线期初:{HYDROGEN_VERIFY_START_DATE}(含)· 查看待核对订单</small>
</button>
</section>
</div>
</section>
<details className="ehb-mobile-month-summary">
<summary className="ehb-mobile-summary-head">
<strong>经营诊断</strong>
<span>
<i className="ehb-mobile-summary-state--closed">展开查看</i>
<i className="ehb-mobile-summary-state--open">收起</i>
<ChevronDown size={15} aria-hidden />
</span>
</summary>
<div className="ehb-mobile-diagnosis-grid">
<div><span>月度环比</span><strong className={liveMonthComparison && liveMonthComparison.value < 0 ? 'is-warning' : 'is-positive'}>{liveMonthComparison ? `${liveMonthComparison.value >= 0 ? '+' : ''}${liveMonthComparison.value.toFixed(1)}%` : '—'}</strong><small>{liveMonthComparison?.label ?? '暂无可比月份'}</small></div>
<div><span>单公斤毛利</span><strong>¥{unitProfitYuan}<small>/kg</small></strong><small>按累计加氢量计算</small></div>
<button type="button" className="ehb-mobile-diagnosis-action" onClick={() => setKpiDrillType('加氢站加氢量排名')}><span>头部站点占比</span><strong>{top5SharePct}%</strong><small>5站占总量</small></button>
<button type="button" className="ehb-mobile-diagnosis-action" onClick={() => setKpiDrillType('待核对订单')}><span>待核对订单</span><strong className={risk.count > 0 ? 'is-warning' : ''}>{risk.count}</strong><small>¥{unverifiedWan} · 上线期初 {HYDROGEN_VERIFY_START_DATE}(含)</small></button>
</div>
</details>
{/* 经营趋势图表大盘:月度加氢量、月度收支对比、Top5站加氢量、各区域加氢占比 */}
<OverviewTrendsDashboard
year={year}
fleetScope={fleetScope}
verifyScope={verifyScope}
overview={liveOverview}
onOpenDrill={(lbl) => setKpiDrillType(lbl)}
onOpenCustomerBill={(custName) => setSelectedBillCustomer(custName)}
onOpenStationBill={(stName, prov) => setSelectedStationForDrill({ name: stName, province: prov })}
/>
</>
)}
</>
)}
{/* KPI 点击下钻数据来源穿透 Modal */}
{boardScope === 'global' && kpiDrillType === '区域市:其他' && (
<RegionRemainderModal
kind="市"
items={remainderRegions.city}
onSelect={(label) => setKpiDrillType(`区域市:${label}`)}
onClose={() => setKpiDrillType(null)}
/>
)}
{boardScope === 'global' && kpiDrillType === '区域省:其他' && (
<RegionRemainderModal
kind="省"
items={remainderRegions.province}
onSelect={(label) => setKpiDrillType(`区域省:${label}`)}
onClose={() => setKpiDrillType(null)}
/>
)}
{boardScope === 'global' && kpiDrillType && !/^区域(?:市|省):其他$/.test(kpiDrillType) && (
<PrototypeDrillModal
kind="kpi"
label={kpiDrillType}
query={{ year, vehicleScope: prototypeFleetScope(fleetScope), verifyScope }}
onClose={() => setKpiDrillType(null)}
/>
)}
{/* 客户账单专属下钻 Modal (客户 → 日期 → 车牌加氢记录) */}
{boardScope === 'global' && selectedBillCustomer && (
<PrototypeDrillModal
kind="customer"
label={`客户:${selectedBillCustomer}`}
query={{ year, vehicleScope: prototypeFleetScope(fleetScope), verifyScope, customerName: selectedBillCustomer }}
onClose={() => setSelectedBillCustomer(null)}
/>
)}
{/* 加氢站账单专属下钻 Modal(按日经营汇总 → 单笔加氢明细) */}
{boardScope === 'global' && selectedStationForDrill && (
<PrototypeDrillModal
kind="station"
label={`加氢站:${selectedStationForDrill.name}`}
query={{
year,
vehicleScope: prototypeFleetScope(fleetScope),
verifyScope,
stationId: liveOverview?.stations?.find((station: any) => station.name === selectedStationForDrill.name)?.id ?? null,
}}
onClose={() => setSelectedStationForDrill(null)}
/>
)}
<nav className="ehb-mobile-bottom-nav" aria-label="移动端主导航" aria-hidden="true" inert style={{ display: 'none' }}>
<button type="button" className="is-active" aria-label="氢能" tabIndex={-1}><Fuel size={22} aria-hidden /><span>氢能</span></button>
<button type="button" aria-label="电能" tabIndex={-1}><Zap size={22} aria-hidden /><span>电能</span></button>
<button type="button" aria-label="ETC" tabIndex={-1}><ReceiptText size={22} aria-hidden /><span>ETC</span></button>
</nav>
</div>
</div>
);
};
function PlugZapHint() {
return <Zap size={36} className="ehb-empty__icon" aria-hidden />;
}
function HostKpi({
icon,
tone,
label,
value,
prefix,
unit,
left,
right,
parts,
onClick,
}: {
icon: React.ReactNode;
tone: 'blue' | 'green' | 'amber' | 'purple' | 'cyan';
label: string;
value: React.ReactNode;
prefix?: string;
unit?: string;
left?: string;
right?: string;
parts?: Array<{ label: string; value: string }>;
onClick?: () => void;
}) {
return (
<div
className={`ehb-kpi-dual is-${tone}`}
onClick={onClick}
onKeyDown={(event) => {
if (onClick && (event.key === 'Enter' || event.key === ' ')) onClick();
}}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
title="点击展开站 → 客户 → 车牌数据来源穿透明细"
>
<div className="ehb-kpi-dual__head">
<span className="ehb-kpi-dual__label">
{label}
<span className="ehb-kpi-drill-hint">
<Search size={10} /> 查看明细
</span>
</span>
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
</div>
<div className="ehb-kpi-dual__val">
{prefix && <span className="ehb-kpi-dual__symbol">{prefix}</span>}
<span className="ehb-kpi-dual__num">{value}</span>
{unit && <span className="ehb-kpi-dual__unit">{unit}</span>}
</div>
<div className={`ehb-kpi-dual__deck ${parts?.length === 3 ? 'is-three' : ''}`}>
{parts?.length
? parts.map((part) => (
<span key={part.label}><small>{part.label}</small><strong>{part.value}</strong></span>
))
: <><span>{left}</span><span>{right}</span></>}
</div>
</div>
);
}
function makeVehicleOrders(
certPrefix: string,
fleetCategory: FleetCategory,
mode: 'verified' | 'unverified' | 'partial',
totalKg: number,
) {
const isOwn = fleetCategory === 'own';
const unitPrice = 4.5;
const list = [
{ time: '2026-08-08 09:15:00', factor: 1.2, source: 'api' as const },
{ time: '2026-08-08 14:30:00', factor: 0.9, source: 'api' as const },
{ time: '2026-08-07 11:20:00', factor: 1.1, source: 'station_report' as const },
{ time: '2026-08-06 16:45:00', factor: 0.8, source: 'lingniu_report' as const },
{ time: '2026-08-05 10:10:00', factor: 1.05, source: 'api' as const },
{ time: '2026-08-04 15:25:00', factor: 0.95, source: 'station_report' as const },
{ time: '2026-08-03 08:50:00', factor: 1.15, source: 'api' as const },
{ time: '2026-08-02 17:05:00', factor: 0.85, source: 'lingniu_report' as const },
{ time: '2026-08-01 12:40:00', factor: 1.0, source: 'station_report' as const },
{ time: '2026-07-31 09:30:00', factor: 0.9, source: 'api' as const },
];
return list.map((item, idx) => {
const seq = String(idx + 1).padStart(2, '0');
const kg = Math.round(((totalKg / 100) * item.factor) * 10) / 10;
const certNo =
item.source === 'api'
? `API-20260808-${certPrefix}-${seq}`
: item.source === 'station_report'
? `ST-20260808-${certPrefix}-${seq}`
: `LN-20260808-${certPrefix}-${seq}`;
let verifyStatus: 'verified' | 'unverified' | null = null;
if (isOwn) {
if (mode === 'verified') verifyStatus = 'verified';
else if (mode === 'unverified') verifyStatus = 'unverified';
else {
verifyStatus = idx % 2 === 0 ? 'verified' : 'unverified';
}
}
return {
orderId: `ORD-20260808-${certPrefix}-${seq}`,
time: item.time,
kg,
unitPrice,
amount: Math.round(kg * unitPrice),
source: item.source,
certNo,
verifyStatus,
};
});
}
function computeVehicleVerifyStatus(
orders: { verifyStatus?: 'verified' | 'unverified' | null }[],
fleetCategory: FleetCategory,
): 'verified' | 'unverified' | 'partial' | null {
if (fleetCategory !== 'own') return null;
if (!orders || orders.length === 0) return 'unverified';
const verifiedCount = orders.filter((o) => o.verifyStatus === 'verified').length;
const unverifiedCount = orders.filter((o) => o.verifyStatus === 'unverified').length;
if (verifiedCount > 0 && unverifiedCount > 0) return 'partial';
if (verifiedCount > 0 && unverifiedCount === 0) return 'verified';
return 'unverified';
}
/** 站/客户层:按下属车辆核对态汇总。全已核→已核对;全未核→未核对;有混杂或任一带部分→部分核对。外部车不参与。 */
function aggregateVehiclesVerifyStatus(
vehicles: { orders: { verifyStatus?: 'verified' | 'unverified' | null }[]; fleetCategory: FleetCategory; plateNo?: string }[],
): 'verified' | 'unverified' | 'partial' | null {
const statuses = vehicles
.map((vh) => computeVehicleVerifyStatus(vh.orders, vh.fleetCategory))
.filter((s): s is 'verified' | 'unverified' | 'partial' => s !== null);
if (statuses.length === 0) return null;
if (statuses.every((s) => s === 'verified')) return 'verified';
if (statuses.every((s) => s === 'unverified')) return 'unverified';
return 'partial';
}
/** 穿透表标签悬浮说明 */
const FLEET_TAG_TIP = {
own: '羚牛车辆:车牌可识别,且归属羚牛自有/合作车队',
external: '外部车辆:非羚牛车队;无法识别车牌的归入「无车牌」并标外部车辆',
} as const;
const SOURCE_TAG_TIP: Record<'api' | 'station_report' | 'lingniu_report', string> = {
api: 'API接入:加氢数据由接口自动归集,可按接口流水追溯',
station_report: '站点上报:由加氢站报送的加氢数据',
lingniu_report: '羚牛上报:从 OneOS 归集的加氢记录',
};
const VERIFY_TAG_TIP = {
verified: '已核对:范围内加氢订单均已完成核对',
partial: '部分核对:范围内既有已核对,也有未核对订单',
unverified: '未核对:范围内加氢订单均尚未核对',
order_verified: '已核对:该笔加氢订单已完成核对',
order_unverified: '未核对:该笔加氢订单尚未核对',
external_skip: '外部车辆不参与核对,故无核对状态',
} as const;
function renderFleetTag(isOwnFleet: boolean) {
return (
<span
className={`ehb-tag ${isOwnFleet ? 'ehb-tag--own-fleet' : 'ehb-tag--ext-fleet'}`}
title={isOwnFleet ? FLEET_TAG_TIP.own : FLEET_TAG_TIP.external}
>
{isOwnFleet ? '羚牛车辆' : '外部车辆'}
</span>
);
}
function renderSourceTag(source: 'api' | 'station_report' | 'lingniu_report' | string) {
const key = source === 'api' || source === 'station_report' || source === 'lingniu_report' ? source : 'api';
const mod = key === 'api' ? 'api' : key === 'station_report' ? 'station' : 'lingniu';
return (
<span className={`ehb-tag ehb-tag--source-${mod}`} title={SOURCE_TAG_TIP[key]}>
{SOURCE_TYPE_LABEL[key] || source}
</span>
);
}
function renderOrderVerifyTag(
fleetCategory: FleetCategory,
verifyStatus: 'verified' | 'unverified' | null | undefined,
) {
if (fleetCategory !== 'own') {
return (
<span style={{ color: '#94a3b8' }} title={VERIFY_TAG_TIP.external_skip}>
-
</span>
);
}
if (verifyStatus === 'verified') {
return (
<span className="ehb-tag ehb-tag--verify-ok" title={VERIFY_TAG_TIP.order_verified}>
已核对
</span>
);
}
return (
<span className="ehb-tag ehb-tag--verify-warn" title={VERIFY_TAG_TIP.order_unverified}>
未核对
</span>
);
}
function renderVehicleVerifyTag(
fleetCategory: FleetCategory,
status: 'verified' | 'unverified' | 'partial' | null,
) {
if (fleetCategory !== 'own' || status === null) {
return (
<span style={{ color: '#94a3b8' }} title={VERIFY_TAG_TIP.external_skip}>
-
</span>
);
}
if (status === 'verified') {
return (
<span className="ehb-tag ehb-tag--verify-ok" title="该车辆下全部加氢订单均已核对">
已核对
</span>
);
}
if (status === 'partial') {
return (
<span className="ehb-tag ehb-tag--verify-partial" title="该车辆下部分订单已核对,部分未核对">
部分核对
</span>
);
}
return (
<span className="ehb-tag ehb-tag--verify-warn" title="该车辆下全部加氢订单均未核对">
未核对
</span>
);
}
function renderAggVerifyTag(
status: 'verified' | 'unverified' | 'partial' | null,
titlePrefix = '下属车辆',
) {
if (status === 'verified') {
return (
<span className="ehb-tag ehb-tag--verify-ok" title={`${VERIFY_TAG_TIP.verified}${titlePrefix}`}>
已核对
</span>
);
}
if (status === 'partial') {
return (
<span className="ehb-tag ehb-tag--verify-partial" title={`${VERIFY_TAG_TIP.partial}${titlePrefix}`}>
部分核对
</span>
);
}
if (status === 'unverified') {
return (
<span className="ehb-tag ehb-tag--verify-warn" title={`${VERIFY_TAG_TIP.unverified}${titlePrefix}`}>
未核对
</span>
);
}
return (
<span style={{ color: '#94a3b8' }} title={VERIFY_TAG_TIP.external_skip}>
-
</span>
);
}
type DrillPeriodMode = 'month' | 'custom';
function resolveDrillPeriod(mode: DrillPeriodMode, monthValue: string, customStart: string, customEnd: string) {
if (mode === 'custom') {
const start = customStart <= customEnd ? customStart : customEnd;
const end = customStart <= customEnd ? customEnd : customStart;
return { start, end, label: `${start}${end}` };
}
const [yearText, monthText] = monthValue.split('-');
const lastDay = new Date(Number(yearText), Number(monthText), 0).getDate();
const monthEnd = `${monthValue}-${String(lastDay).padStart(2, '0')}`;
const dataSnapshotEnd = '2026-08-08';
const end = monthEnd > dataSnapshotEnd ? dataSnapshotEnd : monthEnd;
return { start: `${monthValue}-01`, end, label: `${monthValue}-01 至 ${end}` };
}
function StationMonthTable({
rows,
activeId,
onOpen,
}: {
rows: ReturnType<typeof stationMonthAgg>;
activeId: string | null;
onOpen: (id: string, label: string) => void;
}) {
if (!rows.length) return <div className="ehb-empty">本筛选下暂无站月发生额</div>;
return (
<div className="ehb-table-wrap">
<table className="ehb-table">
<thead>
<tr>
<th>#</th>
<th>加氢站</th>
<th>月份</th>
<th>发生额</th>
<th>加氢量</th>
<th>未核金额</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr
key={`${r.stationId}-${r.month}`}
className={`is-clickable${activeId === r.stationId ? ' is-active' : ''}`}
onClick={() => onOpen(r.stationId, r.stationName)}
>
<td className="ehb-mono ehb-idx">{i + 1}</td>
<td className="ehb-entity-cell">{r.stationName}</td>
<td className="ehb-mono">{r.month}</td>
<td className="ehb-mono">{formatYuan(r.amount)}</td>
<td className="ehb-mono">{formatKg(r.quantityKg)}</td>
<td className="ehb-mono">{formatYuan(r.unverifiedAmount)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function CustomerAttrTable({
rows,
activeId,
onOpen,
}: {
rows: ReturnType<typeof customerAttrAgg>;
activeId: string | null;
onOpen: (id: string, label: string) => void;
}) {
if (!rows.length) return <div className="ehb-empty">本筛选下暂无客户归属</div>;
return (
<div className="ehb-table-wrap">
<table className="ehb-table">
<thead>
<tr>
<th>#</th>
<th>客户</th>
<th>承担方</th>
<th>加氢量</th>
<th>我司成本</th>
<th>未核</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr
key={r.customerId}
className={`is-clickable${activeId === r.customerId ? ' is-active' : ''}`}
onClick={() => onOpen(r.customerId, r.customerName)}
>
<td className="ehb-mono ehb-idx">{i + 1}</td>
<td className="ehb-entity-cell">{r.customerName}</td>
<td>{r.borneLabel}</td>
<td className="ehb-mono">{formatKg(r.quantityKg)}</td>
<td className="ehb-mono">{formatYuan(r.companyCost)}</td>
<td className="ehb-mono">{formatYuan(r.unverifiedAmount)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function OrderTable({ rows }: { rows: H2OrderRow[] }) {
if (!rows.length) {
return <div className="ehb-empty">本筛选下暂无我司成本明细</div>;
}
return (
<div className="ehb-table-wrap">
<table className="ehb-table">
<thead>
<tr>
<th>时间</th>
<th>加氢站</th>
<th>车牌</th>
<th>客户</th>
<th>加氢量</th>
<th>金额</th>
<th>成本维度</th>
<th>核对</th>
<th>来源</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="ehb-mono">{r.occurredAt}</td>
<td className="ehb-entity-cell">{r.stationName}</td>
<td className="ehb-mono">{r.plateNo}</td>
<td className="ehb-entity-cell">{r.customerName}</td>
<td className="ehb-mono">{formatKg(r.quantityKg)}</td>
<td className="ehb-mono">{formatYuan(r.amount)}</td>
<td>{costDimLabel(r)}</td>
<td>
<span
className={`ehb-badge ${r.verifyStatus === 'verified' ? 'is-ok' : 'is-warn'}`}
>
{r.verifyStatus === 'verified' ? '已核对' : '未核对'}
</span>
</td>
<td>{SOURCE_LABEL[r.source]}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
interface BiCustomDatePickerProps {
label: string;
value: string; // YYYY-MM-DD | YYYY-MM | YYYY
onChange: (dateStr: string) => void;
}
function BiCustomDatePicker({ label, value, onChange }: BiCustomDatePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 面板视图模式:'day' | 'month' | 'year'
const [pickerMode, setPickerMode] = useState<'day' | 'month' | 'year'>('day');
const parsedDate = useMemo(() => {
const parts = value.split('-');
const year = parseInt(parts[0], 10) || 2026;
const month = parseInt(parts[1], 10) || 8;
const day = parseInt(parts[2], 10) || 1;
return { year, month, day };
}, [value]);
const [viewYear, setViewYear] = useState(parsedDate.year);
const [viewMonth, setViewMonth] = useState(parsedDate.month);
useEffect(() => {
if (isOpen) {
setViewYear(parsedDate.year);
setViewMonth(parsedDate.month);
const parts = value.split('-');
if (parts.length === 1 && value.length === 4) {
setPickerMode('year');
} else if (parts.length === 2) {
setPickerMode('month');
} else {
setPickerMode('day');
}
}
}, [isOpen, value, parsedDate]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const yearsList = [2026, 2025, 2024, 2023, 2022, 2021, 2020];
const monthsList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const handleSelectYear = (y: number, e: React.MouseEvent) => {
e.stopPropagation();
setViewYear(y);
if (pickerMode === 'year') {
onChange(`${y}`);
setIsOpen(false);
} else {
setPickerMode(pickerMode === 'day' ? 'month' : 'day');
}
};
const handleSelectMonth = (m: number, e: React.MouseEvent) => {
e.stopPropagation();
setViewMonth(m);
const mm = m < 10 ? `0${m}` : `${m}`;
if (pickerMode === 'month') {
onChange(`${viewYear}-${mm}`);
setIsOpen(false);
} else {
setPickerMode('day');
}
};
const handleSelectDay = (d: number, e: React.MouseEvent) => {
e.stopPropagation();
const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`;
const dd = d < 10 ? `0${d}` : `${d}`;
onChange(`${viewYear}-${mm}-${dd}`);
setIsOpen(false);
};
const handlePrev = (e: React.MouseEvent) => {
e.stopPropagation();
if (pickerMode === 'day') {
if (viewMonth === 1) {
setViewYear((prev) => prev - 1);
setViewMonth(12);
} else {
setViewMonth((prev) => prev - 1);
}
} else {
setViewYear((prev) => prev - 1);
}
};
const handleNext = (e: React.MouseEvent) => {
e.stopPropagation();
if (pickerMode === 'day') {
if (viewMonth === 12) {
setViewYear((prev) => prev + 1);
setViewMonth(1);
} else {
setViewMonth((prev) => prev + 1);
}
} else {
setViewYear((prev) => prev + 1);
}
};
const daysInMonth = new Date(viewYear, viewMonth, 0).getDate();
const firstDayWeek = new Date(viewYear, viewMonth - 1, 1).getDay();
const daysArray = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const emptyPrefixSlots = Array.from({ length: firstDayWeek }, (_, i) => i);
return (
<div className="ehb-daily-date-picker-wrapper" ref={containerRef}>
<div
className={`ehb-daily-date-picker ${isOpen ? 'is-active' : ''}`}
onClick={() => setIsOpen(!isOpen)}
>
<span className="ehb-date-label">{label}</span>
<span className="ehb-date-val">{value}</span>
<Calendar size={14} style={{ color: '#94a3b8' }} />
</div>
{isOpen && (
<div className="ehb-date-popover">
{/* 1. 粒度模式选择器: 按日 | 按月 | 按年 */}
<div className="ehb-dp-mode-bar">
<button
type="button"
className={`ehb-dp-mode-btn ${pickerMode === 'day' ? 'is-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
setPickerMode('day');
}}
>
按日
</button>
<button
type="button"
className={`ehb-dp-mode-btn ${pickerMode === 'month' ? 'is-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
setPickerMode('month');
}}
>
按月
</button>
<button
type="button"
className={`ehb-dp-mode-btn ${pickerMode === 'year' ? 'is-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
setPickerMode('year');
}}
>
按年
</button>
</div>
{/* 2. 标头快速切年月 */}
<div className="ehb-dp-header">
<button type="button" className="ehb-dp-nav-btn" onClick={handlePrev}>
<ChevronLeft size={14} />
</button>
<div className="ehb-dp-title-group">
<button
type="button"
className={`ehb-dp-title-btn ${pickerMode === 'year' ? 'is-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
setPickerMode('year');
}}
>
{viewYear}
</button>
{pickerMode === 'day' && (
<button
type="button"
className={`ehb-dp-title-btn ${pickerMode === 'month' ? 'is-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
setPickerMode('month');
}}
>
{viewMonth}
</button>
)}
</div>
<button type="button" className="ehb-dp-nav-btn" onClick={handleNext}>
<ChevronRight size={14} />
</button>
</div>
{/* 3. 日视图 */}
{pickerMode === 'day' && (
<>
<div className="ehb-dp-week-row">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="ehb-dp-grid">
{emptyPrefixSlots.map((s) => (
<span key={`empty-${s}`} className="ehb-dp-day is-empty" />
))}
{daysArray.map((d) => {
const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`;
const dd = d < 10 ? `0${d}` : `${d}`;
const isSelected = value === `${viewYear}-${mm}-${dd}`;
return (
<button
key={d}
type="button"
className={`ehb-dp-day ${isSelected ? 'is-selected' : ''}`}
onClick={(e) => handleSelectDay(d, e)}
>
{d}
</button>
);
})}
</div>
</>
)}
{/* 4. 月视图 */}
{pickerMode === 'month' && (
<div className="ehb-dp-month-grid">
{monthsList.map((m) => {
const mm = m < 10 ? `0${m}` : `${m}`;
const isSelected = value === `${viewYear}-${mm}` || (value.split('-').length === 3 && parsedDate.year === viewYear && parsedDate.month === m);
return (
<button
key={m}
type="button"
className={`ehb-dp-month-item ${isSelected ? 'is-selected' : ''}`}
onClick={(e) => handleSelectMonth(m, e)}
>
{m}
</button>
);
})}
</div>
)}
{/* 5. 年视图 */}
{pickerMode === 'year' && (
<div className="ehb-dp-year-grid">
{yearsList.map((y) => {
const isSelected = value === `${y}` || parsedDate.year === y;
return (
<button
key={y}
type="button"
className={`ehb-dp-year-item ${isSelected ? 'is-selected' : ''}`}
onClick={(e) => handleSelectYear(y, e)}
>
{y}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
interface HostDailyViewProps {
updatedAt?: string;
onRefresh?: () => void;
startDate: string;
endDate: string;
onStartDateChange: (val: string) => void;
onEndDateChange: (val: string) => void;
rangePreset: DailyRangePreset;
onRangePresetChange: (preset: DailyRangePreset) => void;
fleetType: FleetCategoryFilter;
onFleetTypeChange: (fleet: FleetCategoryFilter) => void;
}
function HostDailyView({
updatedAt,
onRefresh,
startDate,
endDate,
onStartDateChange,
onEndDateChange,
rangePreset,
onRangePresetChange,
fleetType,
onFleetTypeChange,
}: HostDailyViewProps) {
const [remoteDaily, setRemoteDaily] = useState<any>(null);
const [remoteAllDaily, setRemoteAllDaily] = useState<any>(null);
const [remotePreviousTotal, setRemotePreviousTotal] = useState<number | null>(null);
const [remoteTrees, setRemoteTrees] = useState<Record<string, any>>({});
const [remoteDailyError, setRemoteDailyError] = useState<string | null>(null);
// 上方时间预设连动 KPI 卡片标题
const kpiRangeTitle = useMemo(() => {
if (rangePreset === 'week') return '本周加氢量';
if (rangePreset === 'month') return '本月加氢量';
if (rangePreset === '15days') return '近 15 天加氢量';
return '自定义区间加氢量';
}, [rangePreset]);
// 日期归一化转换(兼容手选 年 YYYY、月 YYYY-MM、日 YYYY-MM-DD
const normalizeDateStr = (dateStr: string, isEnd: boolean) => {
if (!dateStr) return isEnd ? '9999-12-31' : '0000-01-01';
const parts = dateStr.split('-');
if (parts.length === 1) {
return isEnd ? `${parts[0]}-12-31` : `${parts[0]}-01-01`;
}
if (parts.length === 2) {
const y = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (isEnd) {
const lastDay = new Date(y, m, 0).getDate();
const dd = lastDay < 10 ? `0${lastDay}` : `${lastDay}`;
return `${parts[0]}-${parts[1]}-${dd}`;
}
return `${parts[0]}-${parts[1]}-01`;
}
return dateStr;
};
const normStart = useMemo(() => normalizeDateStr(startDate, false), [startDate]);
const normEnd = useMemo(() => normalizeDateStr(endDate, true), [endDate]);
useEffect(() => {
let active = true;
const vehicleScope = fleetType === 'own' ? 'lingniu' : fleetType;
const currentStart = new Date(`${normStart}T00:00:00`);
const currentEnd = new Date(`${normEnd}T00:00:00`);
const rangeDays = Math.max(1, Math.round((currentEnd.getTime() - currentStart.getTime()) / 86400000) + 1);
const previousEnd = new Date(currentStart);
previousEnd.setDate(previousEnd.getDate() - 1);
const previousStart = new Date(currentStart);
previousStart.setDate(previousStart.getDate() - rangeDays);
const toIso = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
const base = { year: Number(normEnd.slice(0, 4)), startDate: normStart, endDate: normEnd, vehicleScope, verifyScope: 'all' as const };
setRemoteDailyError(null);
Promise.all([
fetchH2BiDaily(base),
vehicleScope === 'all' ? fetchH2BiDaily(base) : fetchH2BiDaily({ ...base, vehicleScope: 'all' }),
fetchH2BiDaily({ ...base, startDate: toIso(previousStart), endDate: toIso(previousEnd) }),
]).then(([daily, allDaily, previous]) => {
if (!active) return;
setRemoteDaily(daily);
setRemoteAllDaily(allDaily);
setRemotePreviousTotal(Number(previous.kpis.totalKg) || 0);
}).catch((error) => {
if (!active) return;
setRemoteDailyError(error instanceof Error ? error.message : String(error));
});
return () => { active = false; };
}, [normStart, normEnd, fleetType]);
// 1. 根据 startDate & endDate 动态生成或提取指定日期范围内的全量每日加氢数据列表
const dateFilteredList = useMemo(() => {
if (remoteDaily) {
return remoteDaily.days.map((item: any) => {
const tree = remoteTrees[item.date];
const stations = tree?.stations?.map((station: any) => ({
stationId: String(station.id), stationName: station.name, stationType: 'self_use',
unitPrice: station.kg ? station.cost / station.kg : 0,
quantityKg: Number(station.kg) || 0, amountYuan: Number(station.cost) || 0,
customers: station.customers.map((customer: any) => ({
customerId: String(customer.id), customerName: customer.name,
customerCategory: 'internal', quantityKg: Number(customer.kg) || 0,
amountYuan: Number(customer.cost) || 0, vehicles: [],
})),
})) ?? [];
return {
date: item.date, shortDate: item.date.slice(5),
unitPrice: item.kg ? item.cost / item.kg : 0,
quantityKg: Number(item.kg) || 0, amountYuan: Number(item.cost) || 0,
momPct: item.chainPct === null || item.chainPct === undefined ? null : Number(item.chainPct),
stations, _stationCount: Number(item.stationCount) || 0,
_ownKg: Number(item.lingniuKg) || 0, _extKg: Number(item.externalKg) || 0,
};
});
}
return getDailyDataForRange(normStart, normEnd);
}, [normStart, normEnd, remoteDaily, remoteTrees]);
// 2. 根据 fleetType 过滤出对应车辆归属下的加氢列表 ('all' 时包含内部与外部合并显示)
const filteredDailyList = useMemo(() => {
if (remoteDaily) return dateFilteredList;
return filterDailyDataByFleet(dateFilteredList, fleetType);
}, [dateFilteredList, fleetType, remoteDaily]);
// 2. 动态计算关联的 KPI 及柱图统计数据
const dailyKpis = useMemo(() => {
const calculated = calculateDailyKpis(filteredDailyList, fleetType);
if (!remoteDaily) return calculated;
const nonZero = filteredDailyList.filter((item: any) => item.quantityKg > 0);
const peak = nonZero.slice().sort((a: any, b: any) => b.quantityKg - a.quantityKg)[0];
const trough = nonZero.slice().sort((a: any, b: any) => a.quantityKg - b.quantityKg)[0];
return {
...calculated,
totalQuantityKg: Number(remoteDaily.kpis.totalKg) || 0,
dailyAvgKgNum: Number(remoteDaily.kpis.averageDailyKg) || 0,
dailyAvgKg: `${Number(remoteDaily.kpis.averageDailyKg || 0).toLocaleString('zh-CN')} Kg`,
activeDays: `${Number(remoteDaily.kpis.activeDays) || 0} 天`,
stationCount: Number(remoteDaily.kpis.stationCount) || 0,
ownKg: filteredDailyList.reduce((sum: number, item: any) => sum + item._ownKg, 0),
extKg: filteredDailyList.reduce((sum: number, item: any) => sum + item._extKg, 0),
peakDayLabel: peak ? `${peak.shortDate} · ${Math.round(peak.quantityKg).toLocaleString('zh-CN')}` : '-',
troughDayLabel: trough ? `${trough.shortDate} · ${Math.round(trough.quantityKg).toLocaleString('zh-CN')}` : '-',
zeroDaysCount: Math.max(0, filteredDailyList.length - Number(remoteDaily.kpis.activeDays || 0)),
};
}, [filteredDailyList, fleetType, remoteDaily]);
const [peakDate = '-', peakValue = '-'] = dailyKpis.peakDayLabel.split(' · ');
const [troughDate = '-', troughValue = '-'] = dailyKpis.troughDayLabel.split(' · ');
const rangeFleetKpis = useMemo(() => {
if (remoteAllDaily) {
return {
ownKg: remoteAllDaily.days.reduce((sum: number, item: any) => sum + Number(item.lingniuKg || 0), 0),
extKg: remoteAllDaily.days.reduce((sum: number, item: any) => sum + Number(item.externalKg || 0), 0),
};
}
return calculateDailyKpis(dateFilteredList, 'all');
}, [dateFilteredList, remoteAllDaily]);
const previousPeriod = useMemo(() => {
if (remotePreviousTotal !== null) {
const changeKg = Math.round((dailyKpis.totalQuantityKg - remotePreviousTotal) * 10) / 10;
const changePct = remotePreviousTotal > 0 ? Math.round(changeKg / remotePreviousTotal * 1000) / 10 : 0;
return { changeKg, changePct };
}
const toDate = (value: string) => new Date(`${value}T00:00:00`);
const toIso = (value: Date) => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const currentStart = toDate(normStart);
const currentEnd = toDate(normEnd);
const rangeDays = Math.max(1, Math.round((currentEnd.getTime() - currentStart.getTime()) / 86400000) + 1);
const previousEnd = new Date(currentStart);
previousEnd.setDate(previousEnd.getDate() - 1);
const previousStart = new Date(currentStart);
previousStart.setDate(previousStart.getDate() - rangeDays);
const previousItems = filterDailyDataByFleet(
getDailyDataForRange(toIso(previousStart), toIso(previousEnd)),
fleetType,
);
const previousTotalKg = Math.round(
previousItems.reduce((sum, item) => sum + item.quantityKg, 0) * 10,
) / 10;
const changeKg = Math.round((dailyKpis.totalQuantityKg - previousTotalKg) * 10) / 10;
const changePct = previousTotalKg > 0 ? Math.round((changeKg / previousTotalKg) * 1000) / 10 : 0;
return { changeKg, changePct };
}, [dailyKpis.totalQuantityKg, fleetType, normEnd, normStart, remotePreviousTotal]);
const totalNetworkStations = 65;
const stationCoveragePct = Math.round((dailyKpis.stationCount / totalNetworkStations) * 1000) / 10;
const fleetTotalKg = rangeFleetKpis.ownKg + rangeFleetKpis.extKg;
const ownFleetPct = fleetTotalKg > 0 ? Math.round((rangeFleetKpis.ownKg / fleetTotalKg) * 1000) / 10 : 0;
const externalFleetPct = fleetTotalKg > 0 ? Math.round((rangeFleetKpis.extKg / fleetTotalKg) * 1000) / 10 : 0;
// 深层折叠/展开状态
const [expandedDate, setExpandedDate] = useState<string | null>('2026-08-08'); // 默认展开最新一天
const [expandedStations, setExpandedStations] = useState<Record<string, boolean>>({
'2026-08-08_st-jx': true, // 默认展开嘉兴站,演示效果
});
const [expandedCustomers, setExpandedCustomers] = useState<Record<string, boolean>>({
'2026-08-08_st-jx_c-ln': true, // 默认展开羚牛客户,直观展示车辆与数据来源
});
// 点击柱状图后的高亮锚点状态
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
// 保证当前选中的展开日期始终在当前过滤数据集中
useEffect(() => {
if (filteredDailyList.length > 0 && (!expandedDate || !filteredDailyList.some((d) => d.date === expandedDate))) {
setExpandedDate(filteredDailyList[0].date);
}
}, [filteredDailyList, expandedDate]);
useEffect(() => {
if (!remoteDaily || !expandedDate || remoteTrees[expandedDate]) return;
const vehicleScope = fleetType === 'own' ? 'lingniu' : fleetType;
fetchH2BiDailyTree(expandedDate, { vehicleScope, verifyScope: 'all', stationId: null })
.then((tree) => setRemoteTrees((current) => ({ ...current, [expandedDate]: tree })))
.catch((error) => setRemoteDailyError(error instanceof Error ? error.message : String(error)));
}, [expandedDate, fleetType, remoteDaily, remoteTrees]);
const maxQty = useMemo(() => {
if (!filteredDailyList.length) return 4000;
return Math.max(...filteredDailyList.map((d) => d.quantityKg), 3000);
}, [filteredDailyList]);
const totalSum = useMemo(() => {
const sum = filteredDailyList.reduce((acc, item) => acc + item.quantityKg, 0);
return sum.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}, [filteredDailyList]);
/** 点击柱状图上的柱子:展开该日、锚点平滑滚动并高亮 */
const handleBarClick = (date: string) => {
setExpandedDate(date);
setHighlightedDate(date);
setTimeout(() => {
const el = document.getElementById(`daily-row-${date}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 60);
setTimeout(() => {
setHighlightedDate((prev) => (prev === date ? null : prev));
}, 2000);
};
const toggleStation = (dateKey: string, stationId: string, e: React.MouseEvent) => {
e.stopPropagation();
const key = `${dateKey}_${stationId}`;
setExpandedStations((prev) => ({ ...prev, [key]: !prev[key] }));
};
const toggleCustomer = (dateKey: string, stationId: string, customerId: string, e: React.MouseEvent) => {
e.stopPropagation();
const key = `${dateKey}_${stationId}_${customerId}`;
setExpandedCustomers((prev) => ({ ...prev, [key]: !prev[key] }));
};
/** 导出按日加氢数据明细为 Excel (.xlsx) */
const handleExportExcel = () => {
const aoa: (string | number)[][] = [
['日期', '加氢站名称', '加氢站类型', '客户名称', '客户属性', '加氢时间', '车牌号', '车辆归属', '数据来源', '核对状态', '单价(元/Kg)', '加氢量(Kg)', '加氢金额(元)', '预充值余额'],
];
filteredDailyList.forEach((d) => {
d.stations.forEach((st) => {
const stationPrecharge = st.prechargeBalance ?? (st.stationId.includes('jx') ? 128500 : st.stationId.includes('tx') ? 86200 : 45000);
st.customers?.forEach((cust) => {
const isInternalCust = cust.customerCategory === 'internal' || cust.customerId === 'c-ln';
cust.vehicles?.forEach((vh) => {
aoa.push([
d.date,
st.stationName,
STATION_TYPE_LABEL[st.stationType] || st.stationType,
cust.customerName,
isInternalCust ? '羚牛车辆' : '外部车辆',
vh.time,
vh.plateNo || '无车牌(散车)',
vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆',
SOURCE_TYPE_LABEL[vh.source] || vh.source,
vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[vh.verifyStatus || 'unverified'] || '未核对') : '-',
vh.unitPrice,
vh.quantityKg,
vh.amountYuan,
${stationPrecharge.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} (对接站点管理)`,
]);
});
});
});
});
const fileDateStr = `${startDate.replace(/[/]/g, '')}-${endDate.replace(/[/]/g, '')}`;
const fleetName = fleetType === 'all' ? '全部车辆' : fleetType === 'own' ? '羚牛车辆' : '外部车辆';
exportAoaSheet(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细');
};
return (
<div className="ehb-daily-container">
{remoteDailyError ? <div className="ehb-live-data-state is-error">按日统计加载失败:{remoteDailyError}</div> : null}
{/* 1. 顶栏时间/范围筛选器 */}
<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 ${rangePreset === 'week' ? 'is-active' : ''}`}
onClick={() => onRangePresetChange('week')}
>
本周
</button>
<button
type="button"
className={`ehb-pill-btn ${rangePreset === 'month' ? 'is-active' : ''}`}
onClick={() => onRangePresetChange('month')}
>
本月
</button>
<button
type="button"
className={`ehb-pill-btn ${rangePreset === '15days' ? 'is-active' : ''}`}
onClick={() => onRangePresetChange('15days')}
>
15
</button>
<button
type="button"
className={`ehb-pill-btn ${rangePreset === 'custom' ? 'is-active' : ''}`}
onClick={() => onRangePresetChange('custom')}
>
自定义
</button>
</div>
<BiCustomDatePicker
label="开始日期"
value={startDate}
onChange={(val) => {
onStartDateChange(val);
onRangePresetChange('custom');
}}
/>
<BiCustomDatePicker
label="结束日期"
value={endDate}
onChange={(val) => {
onEndDateChange(val);
onRangePresetChange('custom');
}}
/>
</div>
<div className="ehb-daily-filter-group">
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${fleetType === 'all' ? 'is-active' : ''}`}
onClick={() => onFleetTypeChange('all')}
>
<Truck size={14} />
全部车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetType === 'own' ? 'is-active' : ''}`}
onClick={() => onFleetTypeChange('own')}
>
<Truck size={14} />
羚牛车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetType === 'external' ? 'is-active' : ''}`}
onClick={() => onFleetTypeChange('external')}
>
<Truck size={14} />
外部车辆
</button>
</div>
{updatedAt && (
<span className="ehb-chrome__clock" style={{ fontSize: 12, color: '#64748b' }}>
{updatedAt}
</span>
)}
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={onRefresh}
title="数据刷新"
>
<RefreshCw size={14} aria-hidden />
刷新
</button>
</div>
</div>
<div className="ehb-daily-range-summary">
<span>区间车辆构成</span>
<strong>羚牛车辆 {rangeFleetKpis.ownKg.toLocaleString('zh-CN')} Kg{ownFleetPct}%</strong>
<i aria-hidden>·</i>
<strong>外部车辆 {rangeFleetKpis.extKg.toLocaleString('zh-CN')} Kg{externalFleetPct}%</strong>
</div>
</section>
{/* 2. 4卡 Bento KPI */}
<section className="ehb-daily-kpi-grid">
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">{kpiRangeTitle}</span>
<span className="ehb-kpi-dual__badge is-blue">
<Fuel size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">{dailyKpis.totalQuantityKg.toLocaleString('zh-CN')}</span>
<span className="ehb-kpi-dual__unit">Kg</span>
</div>
<div className="ehb-daily-kpi-sub">{dailyKpis.dateRange}</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">日均加氢量</span>
<span className="ehb-kpi-dual__badge is-green">
<TrendingUp size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">{dailyKpis.dailyAvgKgNum.toLocaleString('zh-CN')}</span>
<span className="ehb-kpi-dual__unit">Kg</span>
</div>
<div className="ehb-daily-kpi-sub">{dailyKpis.activeDays}有加氢记录</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">较上一周期</span>
<span className={`ehb-kpi-dual__badge ${previousPeriod.changeKg >= 0 ? 'is-green' : 'is-amber'}`}>
<TrendingUp size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className={`ehb-kpi-dual__num ${previousPeriod.changeKg >= 0 ? 'ehb-value-up' : 'ehb-value-down'}`}>
{previousPeriod.changePct >= 0 ? '+' : ''}{previousPeriod.changePct}%
</span>
</div>
<div className="ehb-daily-kpi-sub">
{previousPeriod.changeKg >= 0 ? '增加' : '减少'} {Math.abs(previousPeriod.changeKg).toLocaleString('zh-CN')} Kg
</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">活跃加氢站</span>
<span className="ehb-kpi-dual__badge is-purple">
<Zap size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">{dailyKpis.stationCount} / {totalNetworkStations}</span>
<span className="ehb-kpi-dual__unit"></span>
</div>
<div className="ehb-daily-kpi-sub">覆盖率 {stationCoveragePct}% · 有加氢记录</div>
</div>
</section>
{/* 3. 每日加氢量堆积柱状图(分别显示羚牛车辆与外部车辆加氢量,点击柱子下锚定位) */}
<section className="ehb-daily-chart-section">
<div className="ehb-daily-chart-head">
<div className="ehb-daily-chart-title">
<span>每日加氢量</span>
<span className="ehb-title-sub ehb-hide-h5">(点击柱体下锚定位到对应日期明细)</span>
<span className="ehb-title-sub ehb-show-h5">(点击柱体定位)</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 className="ehb-desktop-legend-label">羚牛车辆</span><span className="ehb-mobile-legend-label">羚牛车辆</span>
</span>
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-ext" />
<span className="ehb-desktop-legend-label">外部车辆</span><span className="ehb-mobile-legend-label">外部车辆</span>
</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 className="ehb-daily-pill-item__label">峰值</span>
<span className="ehb-daily-pill-item__date">{peakDate}</span>
<strong className="ehb-daily-pill-item__value">{peakValue} <small>Kg</small></strong>
</div>
<div className="ehb-daily-pill-item">
<span className="ehb-daily-pill-item__label">低谷</span>
<span className="ehb-daily-pill-item__date">{troughDate}</span>
<strong className="ehb-daily-pill-item__value">{troughValue} <small>Kg</small></strong>
</div>
<div className="ehb-daily-pill-item">
<span className="ehb-daily-pill-item__label">零记录</span>
<span className="ehb-daily-pill-item__date">统计区间</span>
<strong className="ehb-daily-pill-item__value">{dailyKpis.zeroDaysCount} <small></small></strong>
</div>
</div>
<div className="ehb-h5-scroll-hint"> 左右滑动查看每日加氢趋势 </div>
<div className="ehb-daily-bar-container">
<div
className="ehb-daily-avg-line"
style={{
bottom: `${maxQty > 0 ? Math.min(92, Math.round((dailyKpis.dailyAvgKgNum / maxQty) * 100)) : 50}%`,
}}
>
<span className="ehb-daily-avg-label">均值 {dailyKpis.dailyAvgKg}</span>
</div>
{[...filteredDailyList].reverse().map((item) => {
// 计算当天羚牛车辆加氢量与外部车辆加氢量
let dayOwnKg = 0;
let dayExtKg = 0;
item.stations.forEach((st) => {
st.customers.forEach((cust) => {
cust.vehicles.forEach((vh) => {
if (vh.fleetCategory === 'own') {
dayOwnKg += vh.quantityKg;
} else {
dayExtKg += vh.quantityKg;
}
});
});
});
if (remoteDaily) {
dayOwnKg = Number(item._ownKg) || 0;
dayExtKg = Number(item._extKg) || 0;
}
dayOwnKg = Math.round(dayOwnKg * 10) / 10;
dayExtKg = Math.round(dayExtKg * 10) / 10;
const totalKg = item.quantityKg > 0 ? item.quantityKg : 1;
const pct = Math.min(100, Math.round((item.quantityKg / maxQty) * 100));
const ownRatio = Math.round((dayOwnKg / totalKg) * 100);
const extRatio = Math.max(0, 100 - ownRatio);
const isBarActive = expandedDate === item.date;
const tooltipText = `${item.date} 加氢总量 ${Math.round(item.quantityKg).toLocaleString('zh-CN')} Kg\n├─ 羚牛车辆: ${Math.round(dayOwnKg).toLocaleString('zh-CN')} Kg (${ownRatio}%)\n└─ 外部车辆: ${Math.round(dayExtKg).toLocaleString('zh-CN')} Kg (${extRatio}%)\n(点击下锚定位到该日明细)`;
return (
<div
key={item.date}
className="ehb-daily-bar-col"
title={tooltipText}
onClick={() => handleBarClick(item.date)}
>
<div
className="ehb-daily-bar-val"
style={{ color: isBarActive ? '#2f6bff' : undefined, fontWeight: isBarActive ? 700 : undefined }}
>
{Math.round(item.quantityKg)}
</div>
{/* 堆积柱体:上部外部车辆,下部羚牛车辆 */}
<div
className={`ehb-daily-bar-fill is-stacked ${isBarActive ? 'is-active' : ''}`}
style={{
height: `${pct}%`,
}}
>
{dayExtKg > 0 && (
<div
className="ehb-bar-segment is-ext"
style={{ height: `${extRatio}%` }}
title={`外部车辆: ${Math.round(dayExtKg)} Kg (${extRatio}%)`}
/>
)}
{dayOwnKg > 0 && (
<div
className="ehb-bar-segment is-own"
style={{ height: `${ownRatio}%` }}
title={`羚牛车辆: ${Math.round(dayOwnKg)} Kg (${ownRatio}%)`}
/>
)}
</div>
<div
className="ehb-daily-bar-label"
style={{ color: isBarActive ? '#2f6bff' : undefined, fontWeight: isBarActive ? 700 : undefined }}
>
{item.shortDate}
</div>
</div>
);
})}
</div>
</section>
{/* 4. 每日数据明细多层钻取表格 */}
<section className="ehb-daily-table-card" data-mobile-fullscreen-list>
<div className="ehb-daily-table-head">
<div className="ehb-daily-table-title">
<span>每日加氢数据明细</span>
<span className="ehb-title-sub ehb-hide-h5">(可多层下钻:按日 加氢站 客户 车辆及数据源)</span>
<span className="ehb-title-sub ehb-show-h5">(可逐级下钻)</span>
</div>
<MobileListFullscreenButton label="横屏全屏查看每日加氢数据明细" />
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={handleExportExcel}
title="导出 Excel 表格"
>
<Download size={14} aria-hidden />
导出 Excel
</button>
</div>
<div className="ehb-table-wrap">
<table className="ehb-table">
<thead>
<tr>
<th>日期 / 加氢站 / 客户 / 车辆明细</th>
<th>单价 (/Kg)</th>
<th>加氢量 (Kg)</th>
<th>金额 () / 环比</th>
<th title="对接站点管理预充值余额">预充值余额</th>
</tr>
</thead>
<tbody>
{/* 合计行 */}
<tr style={{ background: '#f8fafc', fontWeight: 700 }}>
<td>
<strong style={{ color: '#0f172a' }}>合计</strong>
</td>
<td></td>
<td style={{ fontFamily: 'var(--bi-font-mono)' }}>{totalSum}</td>
<td></td>
<td style={{ fontSize: 11, color: '#64748b', fontWeight: 400 }} title="对接站点管理预充值余额">
对接站点管理
</td>
</tr>
{filteredDailyList.map((row) => {
const isDateExpanded = expandedDate === row.date;
const isHighlighted = highlightedDate === row.date;
return (
<React.Fragment key={row.date}>
{/* Level 1: 日期行 */}
<tr
id={`daily-row-${row.date}`}
className={`${isHighlighted ? 'is-highlight-target' : ''}`}
style={{ cursor: 'pointer', background: isDateExpanded ? '#f0f9ff' : undefined }}
onClick={() => setExpandedDate(isDateExpanded ? null : row.date)}
>
<td style={{ fontWeight: 600 }}>
<span style={{ marginRight: 6, display: 'inline-block', width: 14, color: '#2f6bff' }}>
{(row._stationCount ?? row.stations.length) > 0 ? (isDateExpanded ? '▼' : '►') : '•'}
</span>
<span>{row.date}</span>
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 400, marginLeft: 8 }}>
({row._stationCount ?? row.stations.length} 个加氢站)
</span>
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)' }}>{row.unitPrice.toFixed(2)}</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', fontWeight: 700, color: '#0f172a' }}>
{row.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)' }}>
{row.momPct !== null ? (
<span className={`ehb-trend-value ${row.momPct < 0 ? 'is-down' : 'is-up'}`}>
{row.momPct > 0 ? `+${row.momPct.toFixed(1)}%` : `${row.momPct.toFixed(1)}%`}
</span>
) : (
'-'
)}
</td>
<td style={{ color: '#64748b' }}>-</td>
</tr>
{/* Level 2: 加氢站层 */}
{isDateExpanded &&
row.stations.map((st) => {
const stKey = `${row.date}_${st.stationId}`;
const isStExpanded = !!expandedStations[stKey];
const stPrecharge = st.prechargeBalance ?? (st.stationId.includes('jx') ? 128500 : st.stationId.includes('tx') ? 86200 : 45000);
return (
<React.Fragment key={stKey}>
<tr
style={{ background: isStExpanded ? '#f1f5f9' : '#f8fafc', cursor: 'pointer', fontSize: 13 }}
onClick={(e) => toggleStation(row.date, st.stationId, e)}
>
<td style={{ paddingLeft: 24, fontWeight: 600 }}>
<span style={{ marginRight: 6, color: '#2f6bff', display: 'inline-block', width: 14 }}>
{st.customers?.length ? (isStExpanded ? '▼' : '►') : '•'}
</span>
<span> {st.stationName}</span>
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', color: '#475569' }}>
{st.unitPrice.toFixed(2)}
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', fontWeight: 600, color: '#334155' }}>
{st.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', color: '#475569' }}>
¥{st.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td
style={{ fontFamily: 'var(--bi-font-mono)', fontWeight: 600, color: '#2f6bff' }}
title="对接站点管理预充值余额"
>
¥{stPrecharge.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
</tr>
{/* Level 3: 客户层 */}
{isStExpanded &&
st.customers?.map((cust) => {
const custKey = `${row.date}_${st.stationId}_${cust.customerId}`;
const isCustExpanded = !!expandedCustomers[custKey];
const isInternalCust = cust.customerCategory === 'internal' || cust.customerId === 'c-ln';
return (
<React.Fragment key={custKey}>
<tr
style={{ background: isCustExpanded ? '#e2e8f0' : '#f1f5f9', cursor: 'pointer', fontSize: 12 }}
onClick={(e) => toggleCustomer(row.date, st.stationId, cust.customerId, e)}
>
<td style={{ paddingLeft: 44, color: '#1e293b' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>
<span style={{ marginRight: 6, color: '#2f6bff', display: 'inline-block', width: 14 }}>
{cust.vehicles?.length ? (isCustExpanded ? '▼' : '►') : '•'}
</span>
<strong style={{ fontWeight: 600 }}>└─ 客户:{cust.customerName}</strong>
</span>
{isInternalCust ? (
<span className="ehb-split-tag is-own" title="羚牛车辆加氢数据">
<span className="ehb-split-tag__label">羚牛车辆</span>
<span className="ehb-split-tag__val">{cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} Kg</span>
<span className="ehb-split-tag__price">¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
</span>
) : (
<span className="ehb-split-tag is-ext" title="外部车辆加氢数据">
<span className="ehb-split-tag__label">外部车辆</span>
<span className="ehb-split-tag__val">{cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} Kg</span>
<span className="ehb-split-tag__price">¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
</span>
)}
</div>
</td>
<td style={{ color: '#64748b' }}>-</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', color: '#1e293b', fontWeight: 600 }}>
{cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', color: '#1e293b' }}>
¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ color: '#64748b' }}>-</td>
</tr>
{/* Level 4: 车辆及数据来源明细层 */}
{isCustExpanded &&
cust.vehicles?.map((vh) => (
<tr key={vh.id} style={{ background: '#ffffff', fontSize: 12 }}>
<td style={{ paddingLeft: 64 }}>
<span style={{ color: '#94a3b8', marginRight: 8 }}>└──</span>
<span className="ehb-vehicle-time-stack" style={{ marginRight: 10 }}>
<strong>{vh.plateNo || '无车牌(散车)'}</strong>
<small>{vh.time.slice(0, 5)}</small>
</span>
<span style={{ marginRight: 6 }}>
{renderFleetTag(vh.fleetCategory === 'own')}
</span>
<span style={{ marginRight: 6 }}>
{renderSourceTag(vh.source)}
</span>
{/* 规则:仅内部车辆(羚牛车辆)展示核对状态;外部车辆不参与核对 */}
{vh.fleetCategory === 'own' && vh.verifyStatus && (
<span>{renderOrderVerifyTag(vh.fleetCategory, vh.verifyStatus)}</span>
)}
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', color: '#64748b' }}>
{vh.unitPrice.toFixed(2)}
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', color: '#334155' }}>
{vh.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ fontFamily: 'var(--bi-font-mono)', color: '#334155' }}>
¥{vh.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ color: '#64748b' }}>-</td>
</tr>
))}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}